Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cc288d281 | ||
|
|
ce4778bdf1 | ||
|
|
3fe43ee82c | ||
|
|
6d93ab5b1f | ||
|
|
95d79925ea | ||
|
|
bf3ef025aa | ||
|
|
ba29738482 |
+15
-1
@@ -41,7 +41,21 @@ ONLYOFFICE_PROJECT_ID=33
|
||||
# ONLYOFFICE_ES_URL=http://127.0.0.1:9200
|
||||
# ONLYOFFICE_ES_INDEX=files_file
|
||||
# ONLYOFFICE_TENANT=
|
||||
#
|
||||
|
||||
# Read-only SQL file store over the Community Server database (see
|
||||
# docs/community-server-db.md). The live portal runs MySQL; ONLYOFFICE_DSN is
|
||||
# `user:pass@tcp(host:port)/onlyoffice?parseTime=true`, or a `postgres://` URL.
|
||||
# ONLYOFFICE_DSN=
|
||||
# ONLYOFFICE_PG_DRIVER= # postgres | mysql (auto-detected from DSN)
|
||||
# ONLYOFFICE_PG_TENANT=1 # falls back to ONLYOFFICE_TENANT
|
||||
# Alternatively build a PostgreSQL DSN from parts:
|
||||
# ONLYOFFICE_PG_HOST=
|
||||
# ONLYOFFICE_PG_PORT=5432
|
||||
# ONLYOFFICE_PG_USER=
|
||||
# ONLYOFFICE_PG_PASSWORD=
|
||||
# ONLYOFFICE_PG_DBNAME=onlyoffice
|
||||
# ONLYOFFICE_PG_SSLMODE=disable
|
||||
|
||||
# oo index / oo search --backend own — own full-text index for PDF/scans,
|
||||
# filled by `oo index` from internal/docpipe (pdftotext + OCR). Defaults to
|
||||
# oo_docs_text. Uses the same ONLYOFFICE_ES_URL.
|
||||
|
||||
@@ -701,9 +701,11 @@ Requires `ONLYOFFICE_ES_URL` (plus optional `ONLYOFFICE_ES_INDEX`,
|
||||
|
||||
The OnlyOffice index covers Office formats only, so PDFs (`S1019`-style invoice
|
||||
numbers) are not searchable by content. `oo index` extracts PDF text with
|
||||
`internal/docpipe` (pdftotext, OCR for scans) into a separate index
|
||||
(`ONLYOFFICE_ES_TEXT_INDEX`, default `oo_docs_text`); the OnlyOffice server and
|
||||
its index are **not** modified. Then search it with `--backend own`.
|
||||
`internal/docpipe` (pdftotext, OCR for scans) — including the text of embedded
|
||||
PDF attachments (`pdfdetach`: `<doc>.md`, `.xml`, covers the original/scan and
|
||||
ZUGFeRD e-invoice XML) — into a separate index (`ONLYOFFICE_ES_TEXT_INDEX`,
|
||||
default `oo_docs_text`); the OnlyOffice server and its index are **not**
|
||||
modified. Then search it with `--backend own`.
|
||||
|
||||
```bash
|
||||
oo index folder 634 --recursive --exts pdf # populate (idempotent upsert)
|
||||
|
||||
@@ -17,6 +17,18 @@ const MailListPageSize = 25
|
||||
// Loader fetches list items for a menu subject using the OnlyOffice client.
|
||||
type Loader struct {
|
||||
Client *onlyoffice.Client
|
||||
|
||||
// Files is the backend-agnostic file store used for file download, preview
|
||||
// and delete. When nil it falls back to Client.FileStore(ProviderREST).
|
||||
Files onlyoffice.FileStore
|
||||
}
|
||||
|
||||
// fileStore returns the configured file store, defaulting to REST.
|
||||
func (l *Loader) fileStore() onlyoffice.FileStore {
|
||||
if l.Files != nil {
|
||||
return l.Files
|
||||
}
|
||||
return l.Client.FileStore(onlyoffice.ProviderREST)
|
||||
}
|
||||
|
||||
// List returns items for the given list spec (nav leaf).
|
||||
@@ -171,11 +183,7 @@ func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, er
|
||||
}
|
||||
return fmt.Sprintf("Deleted message %s", item.Title), nil
|
||||
case model.KindFile:
|
||||
id, err := strconv.Atoi(item.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil {
|
||||
if err := l.fileStore().Delete(ctx, []string{item.ID}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted file %s", item.Title), nil
|
||||
@@ -199,7 +207,7 @@ func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := l.Client.DownloadFile(ctx, item.ID, f); err != nil {
|
||||
if _, err := l.fileStore().Download(ctx, item.ID, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Downloaded to %s", destPath), nil
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
@@ -30,13 +29,11 @@ func (l *Loader) filePreviewMarkdown(ctx context.Context, item model.Item) (stri
|
||||
return "", fmt.Errorf("file id missing")
|
||||
}
|
||||
name := item.Title
|
||||
if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil {
|
||||
if t := onlyoffice.FileEntryTitle(meta); t != "" {
|
||||
name = t
|
||||
}
|
||||
if e, err := l.fileStore().Stat(ctx, item.ID); err == nil && e.Title != "" {
|
||||
name = e.Title
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
|
||||
if _, err := l.fileStore().Download(ctx, item.ID, &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return preview.FileBytesToMarkdown(name, buf.Bytes())
|
||||
|
||||
+49
-45
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -12,8 +13,8 @@ func init() {
|
||||
rootCmd.AddCommand(davCmd())
|
||||
}
|
||||
|
||||
// davCmd exposes the Documents module through the same Dav calls that back
|
||||
// oo-webdav (ListDavFolder / MoveDavItems / CopyDavItems / DownloadDavFile).
|
||||
// davCmd exposes the Documents module through the backend-agnostic FileStore
|
||||
// (DAV backend). The underlying Dav calls are the oo-webdav proven path:
|
||||
// MoveDavItems sends resolveType=Skip + holdResult=true, which the legacy
|
||||
// fileops/move call without those params silently ignores (200 without move).
|
||||
func davCmd() *cobra.Command {
|
||||
@@ -61,35 +62,21 @@ func davLsCmd() *cobra.Command {
|
||||
printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows)
|
||||
return nil
|
||||
}
|
||||
l, err := c.ListDavFolder(ctx, args[0])
|
||||
entries, err := c.FileStore(onlyoffice.ProviderDAV).List(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,
|
||||
})
|
||||
folders := make([]onlyoffice.Entry, 0, len(entries))
|
||||
files := make([]onlyoffice.Entry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Kind == onlyoffice.Folder {
|
||||
folders = append(folders, e)
|
||||
} else {
|
||||
files = append(files, e)
|
||||
}
|
||||
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 := make([]map[string]any, 0, len(folders))
|
||||
for _, f := range folders {
|
||||
frows = append(frows, map[string]any{
|
||||
"id": f.ID,
|
||||
"title": f.Title,
|
||||
@@ -97,23 +84,24 @@ func davLsCmd() *cobra.Command {
|
||||
"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 := make([]map[string]any, 0, len(files))
|
||||
for _, f := range files {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": f.ID,
|
||||
"title": f.Title,
|
||||
"size": f.Size,
|
||||
"updated": f.Updated,
|
||||
"updated": entryUpdated(f),
|
||||
})
|
||||
}
|
||||
if outputFormat == "table" {
|
||||
fmt.Println("files:")
|
||||
if outputFormat == "json" {
|
||||
printObject(map[string]any{"folders": frows, "files": rows})
|
||||
return nil
|
||||
}
|
||||
if len(frows) > 0 {
|
||||
fmt.Println("folders:")
|
||||
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
|
||||
}
|
||||
fmt.Println("files:")
|
||||
printTable([]string{"id", "title", "size", "updated"}, rows)
|
||||
return nil
|
||||
},
|
||||
@@ -121,6 +109,18 @@ func davLsCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// entryUpdated prefers the backend-native timestamp string so table/JSON output
|
||||
// round-trips what the API returned.
|
||||
func entryUpdated(e onlyoffice.Entry) string {
|
||||
if e.Updated != "" {
|
||||
return e.Updated
|
||||
}
|
||||
if e.Modified.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return e.Modified.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func davMoveCmd() *cobra.Command {
|
||||
var folderIDs []string
|
||||
cmd := &cobra.Command{
|
||||
@@ -132,7 +132,8 @@ func davMoveCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.MoveDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
||||
ids := append(append([]string{}, folderIDs...), args[1:]...)
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Move(cmd.Context(), ids, args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"moved_files": args[1:], "moved_folders": folderIDs, "dest": args[0]})
|
||||
@@ -154,7 +155,8 @@ func davCopyCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.CopyDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
||||
ids := append(append([]string{}, folderIDs...), args[1:]...)
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Copy(cmd.Context(), ids, args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"copied_files": args[1:], "copied_folders": folderIDs, "dest": args[0]})
|
||||
@@ -175,7 +177,7 @@ func davMkdirCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := c.CreateDavFolder(cmd.Context(), args[0], args[1])
|
||||
f, err := c.FileStore(onlyoffice.ProviderDAV).CreateFolder(cmd.Context(), args[0], args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -200,7 +202,8 @@ func davRemoveCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.DeleteDavItems(cmd.Context(), folderIDs, args); err != nil {
|
||||
ids := append(append([]string{}, args...), folderIDs...)
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Delete(cmd.Context(), ids); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"deleted_files": args, "deleted_folders": folderIDs})
|
||||
@@ -221,7 +224,7 @@ func davRenameFileCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.RenameDavFile(cmd.Context(), args[0], args[1]); err != nil {
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Rename(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||
@@ -240,7 +243,7 @@ func davRenameFolderCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.RenameDavFolder(cmd.Context(), args[0], args[1]); err != nil {
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Rename(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||
@@ -261,20 +264,21 @@ func davDownloadCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
f, err := c.GetFile(ctx, args[0])
|
||||
store := c.FileStore(onlyoffice.ProviderDAV)
|
||||
e, err := store.Stat(ctx, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := to
|
||||
if path == "" {
|
||||
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
|
||||
path = onlyoffice.SafeLocalFileName(e.Title)
|
||||
}
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
n, err := c.DownloadDavFile(ctx, args[0], out)
|
||||
n, err := store.Download(ctx, args[0], out)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -159,20 +160,22 @@ func prjFilesDownloadCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := c.GetFile(cmd.Context(), args[0])
|
||||
ctx := cmd.Context()
|
||||
store := c.Files()
|
||||
e, err := store.Stat(ctx, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := to
|
||||
if path == "" {
|
||||
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
|
||||
path = onlyoffice.SafeLocalFileName(e.Title)
|
||||
}
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
n, err := c.DownloadFile(cmd.Context(), args[0], out)
|
||||
n, err := store.Download(ctx, args[0], out)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
@@ -199,11 +202,16 @@ func prjFilesRenameCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := c.RenameFile(cmd.Context(), args[0], args[1])
|
||||
store := c.Files()
|
||||
if err := store.Rename(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := store.Stat(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(fileEntryToMap(entry))
|
||||
entry.Title = args[1]
|
||||
printObject(entryToMap(entry))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -228,7 +236,7 @@ func prjFilesDeleteCmd() *cobra.Command {
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := c.DeleteFiles(cmd.Context(), ids); err != nil {
|
||||
if err := c.Files().Delete(cmd.Context(), args); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"deleted": ids})
|
||||
@@ -317,6 +325,29 @@ func fileEntryToMap(f *onlyoffice.FileEntry) map[string]any {
|
||||
return m
|
||||
}
|
||||
|
||||
// entryToMap renders a canonical Entry with the same keys as fileEntryToMap.
|
||||
func entryToMap(e onlyoffice.Entry) map[string]any {
|
||||
m := map[string]any{
|
||||
"id": e.ID,
|
||||
"title": e.Title,
|
||||
"fileExst": filepath.Ext(e.Title),
|
||||
"contentLength": contentLengthString(e.Size),
|
||||
}
|
||||
if e.Updated != "" {
|
||||
m["updated"] = e.Updated
|
||||
} else if !e.Modified.IsZero() {
|
||||
m["updated"] = e.Modified.Format(time.RFC3339)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func contentLengthString(n int64) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
|
||||
func fileIDStr(f *onlyoffice.FileEntry) string {
|
||||
if f == nil || f.ID == nil {
|
||||
return ""
|
||||
|
||||
+7
-4
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -12,9 +13,9 @@ func init() {
|
||||
rootCmd.AddCommand(searchCmd())
|
||||
}
|
||||
|
||||
// searchCmd queries the OnlyOffice Elasticsearch index directly. The REST
|
||||
// /api/2.0/files/@search endpoint only searches file names in the database;
|
||||
// content search needs ES (see docs/elasticsearch.md).
|
||||
// searchCmd queries the OnlyOffice document index through the file facade. The
|
||||
// REST /api/2.0/files/@search endpoint only searches file names in the database;
|
||||
// content search needs Elasticsearch (see docs/elasticsearch.md).
|
||||
func searchCmd() *cobra.Command {
|
||||
var (
|
||||
content bool
|
||||
@@ -40,13 +41,15 @@ func searchCmd() *cobra.Command {
|
||||
if asJSON {
|
||||
outputFormat = "json"
|
||||
}
|
||||
bootstrap.LoadEnv()
|
||||
c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
||||
var (
|
||||
searcher onlyoffice.Searcher
|
||||
err error
|
||||
)
|
||||
switch strings.ToLower(strings.TrimSpace(backend)) {
|
||||
case "", "oo", "elasticsearch":
|
||||
searcher, err = onlyoffice.NewESSearcher(onlyoffice.ESConfigFromEnv())
|
||||
searcher, err = c.Files().Search()
|
||||
case "own", "es-text":
|
||||
searcher, err = onlyoffice.NewESTextIndex(onlyoffice.ESTextConfigFromEnv())
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
type: reference
|
||||
status: current
|
||||
related:
|
||||
- README.md
|
||||
- file_pg.go
|
||||
- docs/elasticsearch.md
|
||||
---
|
||||
|
||||
# Community Server DB — прямой SQL-доступ (read-only)
|
||||
|
||||
## Что это
|
||||
|
||||
Бэкенд `pgStore` (`file_pg.go`) читает файлы и папки **напрямую из БД
|
||||
Community Server**, без HTTP-слоя. Реализует `FileStore` (`List`/`Stat`/
|
||||
`Download`) и `Searcher` по имени. Запись запрещена: все write-методы
|
||||
возвращают `ErrReadOnly`.
|
||||
|
||||
## Что за БД (research, live)
|
||||
|
||||
Проверено на VM `onlyoffice-v2` (SSH `127.0.0.1:32`):
|
||||
|
||||
- Community Server работает на **MySQL 8.0**, не на PostgreSQL.
|
||||
- Хост: `127.0.0.1:3306` внутри VM, база `onlyoffice`.
|
||||
- Конфиг: `/etc/onlyoffice/communityserver/appsettings.production.json`,
|
||||
`providerName: MySql.Data.MySqlClient`.
|
||||
- Таблицы: `files_file`, `files_folder`, `files_folder_tree`,
|
||||
`files_security`, тенанты — `tenants_tenants` (не `tenants`).
|
||||
- PostgreSQL 16 в той же VM — **наш** контур (`edw_docs`, роли `edw`/`edw_ro`,
|
||||
office-assistant), к OnlyOffice отношения не имеет. `files_file` в PG нет.
|
||||
- Портал хранит файлы в **S3/MinIO** (DiscStorage только для мелочи).
|
||||
Бакет `office`, объект — по ключу (см. ниже).
|
||||
|
||||
Вывод: бэкенд назван по issue «PostgreSQL», но живой источник — MySQL.
|
||||
`database/sql` + драйвер по DSN: `mysql` для MySQL, `pgx` для PostgreSQL.
|
||||
`Name()` возвращает фактический движок (`mysql` или `postgres`).
|
||||
|
||||
## Схема
|
||||
|
||||
`files_file` — одна строка **на версию** (PK `tenant_id, id, version`):
|
||||
|
||||
| поле | смысл |
|
||||
|------|-------|
|
||||
| `id` | id файла (тот же, что в REST/ES) |
|
||||
| `version` | номер версии этой строки |
|
||||
| `version_group` | номер версии |
|
||||
| `current_version` | `1` = текущая версия, `0` = старая |
|
||||
| `folder_id` | id родительской папки |
|
||||
| `title` | имя файла с расширением |
|
||||
| `content_length` | размер в байтах |
|
||||
| `create_on`, `modified_on` | даты (UTC, без зоны) |
|
||||
| `tenant_id` | тенант (портал) |
|
||||
|
||||
`files_folder`: `id`, `parent_id`, `title`, `create_on`, `modified_on`,
|
||||
`tenant_id`. `files_folder_tree`: `folder_id`, `parent_id`, `level` — готовое
|
||||
дерево, пока не используется.
|
||||
|
||||
Текущую строку файла берём по `current_version = 1`.
|
||||
|
||||
## Доступ (SSH-туннель)
|
||||
|
||||
MySQL слушает только `127.0.0.1:3306` внутри VM. Снаружи — SSH-туннель
|
||||
(SSH в VM открыт как `127.0.0.1:32`):
|
||||
|
||||
```bash
|
||||
ssh -f -N -o ControlMaster=no -o ControlPath=none \
|
||||
-p 32 -i ~/.ssh/id_ed25519 \
|
||||
-L 13306:127.0.0.1:3306 root@127.0.0.1
|
||||
|
||||
# MySQL DSN затем:
|
||||
# root:<pw>@tcp(127.0.0.1:13306)/onlyoffice?parseTime=true
|
||||
```
|
||||
|
||||
`-o ControlMaster=no -o ControlPath=none` обязательны: иначе forward уходит в
|
||||
persistent master из `~/.ssh/config`.
|
||||
|
||||
## Переменные
|
||||
|
||||
| env | default | смысл |
|
||||
|-----|---------|-------|
|
||||
| `ONLYOFFICE_DSN` | — | DSN драйвера (MySQL `...@tcp(...)/...` или `postgres://...`) |
|
||||
| `ONLYOFFICE_PG_DRIVER` | авто | `postgres` или `mysql`; иначе по форме DSN |
|
||||
| `ONLYOFFICE_PG_TENANT` | `ONLYOFFICE_TENANT` | фильтр `tenant_id` (пусто = все) |
|
||||
| `ONLYOFFICE_PG_HOST/PORT/USER/PASSWORD/DBNAME/SSLMODE` | — | собрать PG DSN, если `ONLYOFFICE_DSN` пуст |
|
||||
|
||||
Имена — в [`.env.example`](../.env.example). Секретов нет.
|
||||
|
||||
## Download (MinIO)
|
||||
|
||||
`Download` не ходит в REST. Ключ объекта собирается из строки `files_file`:
|
||||
|
||||
```
|
||||
00/00/<tenant>/files/folder_<shard>/file_<id>/v<version>/content.<ext>
|
||||
shard = (id/1000 + 1) * 1000
|
||||
```
|
||||
|
||||
`shard` — не `folder_id`, а следующая тысяча над `id` (файл 3727 →
|
||||
`folder_4000`). Проверено live по бакету `office`.
|
||||
|
||||
Стриминг переиспользует `downloadMinioObject` из `storage_fallback.go`
|
||||
(та же подпись SigV4 и `MINIO_*`), без дублирования.
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
go test ./... # unit: rebind, csObjectKey, маппинг
|
||||
go test -race ./...
|
||||
|
||||
# integration (нужен DSN; skip без него)
|
||||
ONLYOFFICE_DSN='root:<pw>@tcp(127.0.0.1:13306)/onlyoffice?parseTime=true' \
|
||||
ONLYOFFICE_PG_TENANT=1 \
|
||||
ONLYOFFICE_PG_TEST_FILE_ID=22484 \
|
||||
ONLYOFFICE_PG_TEST_FOLDER_ID=649 \
|
||||
MINIO_ACCESS_KEY=... MINIO_SECRET_KEY=... \
|
||||
go test -tags=integration -run TestIntegrationPGStore -v ./...
|
||||
```
|
||||
|
||||
Integration сверяет `Stat`/`List`/`Download` с REST (`c.Files()`) и проверяет,
|
||||
что write-методы дают `ErrReadOnly`.
|
||||
|
||||
## Грабли
|
||||
|
||||
- MySQL хранит `datetime` без зоны; `parseTime=true` (ставится автоматически)
|
||||
читает их как UTC. REST отдаёт `+02:00` — сравнивать моменты, не строки.
|
||||
- `GetFile` (REST) не отдаёт `contentLength` — размер сверять с `Stat` SQL.
|
||||
- Один файл = много строк `files_file` (по версиям). Без `current_version = 1`
|
||||
получите дубликаты.
|
||||
- `folder_id` не входит в ключ MinIO; ключ считает `shard` от `id`.
|
||||
- Searcher SQL ищет только по имени (`LIKE`). Контент — Elasticsearch
|
||||
([elasticsearch.md](elasticsearch.md)).
|
||||
+26
-2
@@ -174,6 +174,22 @@ OnlyOffice PDF лежит только по имени.
|
||||
- CLI: `oo index folder|files` наполняет индекс; `oo search --backend own`
|
||||
ищет по нему.
|
||||
|
||||
### Встроенные вложения PDF
|
||||
|
||||
Оцифрованные PDF несут вложения (`<doc>.md` — текст/таблицы скана,
|
||||
`<doc>.yaml`/`.json` — метаданные, `.xml` — EN 16931 CII eRechnung,
|
||||
`factur-x.xml` у ZUGFeRD; см. `office-assistant/docs/reference/document-metadata.md`).
|
||||
`TextIndexer` обходит их: `pdfdetach -list` перечисляет, `-save` сохраняет,
|
||||
каждое вложение проходит штатный `docpipe.ToMarkdown` (PDF/картинки → OCR,
|
||||
`.md`/`.txt` — как есть). Форматы, которые docpipe не конвертирует
|
||||
(`.xml`/`.html` — снимаются теги; `.json`/`.csv` — как текст), извлекаются
|
||||
текстом; нечитаемые — пропускаются.
|
||||
|
||||
Текст склеивается: тело, затем по секции на вложение с маркером
|
||||
`[attachment: <имя>]` (функция `docpipe.JoinWithAttachments`). Индекс — тот же
|
||||
`file_id`, upsert идемпотентен. Нет вложений или pdfdetach/формат нечитаем —
|
||||
индексируется тело (без падения).
|
||||
|
||||
Поля `oo_docs_text`:
|
||||
|
||||
| поле | тип | смысл |
|
||||
@@ -217,8 +233,12 @@ ONLYOFFICE_ES_URL=http://127.0.0.1:9200 \
|
||||
```
|
||||
|
||||
Интеграционный тест создаёт временный индекс, наполняет, ищет по контенту,
|
||||
проверяет фильтры и удаление, затем удаляет индекс. Unit-тесты используют
|
||||
fake-store/fake-extractor и не требуют pdftotext/OCR.
|
||||
проверяет фильтры и удаление, затем удаляет индекс;
|
||||
`TestIntegrationESTextIndexPDFAttachment` индексирует
|
||||
`testdata/pdf-with-attachment.pdf` реальным конвейером (pdfdetach + pdftotext)
|
||||
и ищет токен, лежащий только во вложении. Unit-тесты используют
|
||||
fake-store/fake-extractor и не требуют pdftotext/OCR (парсер списка, склейка
|
||||
`JoinWithAttachments`, снятие тегов `xmlToText` — чистые).
|
||||
|
||||
## Грабли
|
||||
|
||||
@@ -229,3 +249,7 @@ fake-store/fake-extractor и не требуют pdftotext/OCR.
|
||||
- `folder` фильтруется как id папки, а не как путь.
|
||||
- Дубликаты (напр. `S1055.pdf` и `2026-08-20-S1055-…`) дадут несколько строк —
|
||||
это ожидаемо, дедуп — на стороне потребителя.
|
||||
- Вложения: нужен `pdfdetach` (poppler); если его нет — индексируется только
|
||||
тело. Вложенный PDF/картинка с плохим текстовым слоем проходит OCR, это
|
||||
медленно. `.json`-метаданные (CuraSoft) индексируются как текст и могут
|
||||
добавить шумовых токенов.
|
||||
|
||||
+19
-4
@@ -52,8 +52,17 @@ type Entry struct {
|
||||
MIME string
|
||||
Created time.Time
|
||||
Modified time.Time
|
||||
// Updated is the backend-native timestamp string, when the backend exposes
|
||||
// one. It lets list output round-trip the API value; Modified is the
|
||||
// parsed form for logic.
|
||||
Updated string
|
||||
Version int
|
||||
Provider string
|
||||
|
||||
// Folder-only counters. Zero for files and for backends that do not
|
||||
// report them.
|
||||
FilesCount int
|
||||
FoldersCount int
|
||||
}
|
||||
|
||||
// FileStore is the operation surface every file backend implements.
|
||||
@@ -97,8 +106,8 @@ type Searcher interface {
|
||||
}
|
||||
|
||||
// FileStore returns the adapter for a backend name: ProviderREST (default) or
|
||||
// ProviderDAV. Unknown or empty names select the REST backend. The full facade
|
||||
// (backend composition) is deliberately left to a later change.
|
||||
// ProviderDAV. Unknown or empty names select the REST backend. The composed
|
||||
// facade (backend selection/fallback) lives on FileClient in file_facade.go.
|
||||
func (c *Client) FileStore(backend string) FileStore {
|
||||
switch strings.ToLower(strings.TrimSpace(backend)) {
|
||||
case ProviderDAV, "webdav":
|
||||
@@ -108,8 +117,9 @@ func (c *Client) FileStore(backend string) FileStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Files returns the default (REST) file store.
|
||||
func (c *Client) Files() FileStore { return c.FileStore(ProviderREST) }
|
||||
// Files returns the composed file facade. The returned *FileClient implements
|
||||
// FileStore, so callers that used Files() as the plain REST store keep working.
|
||||
func (c *Client) Files() *FileClient { return c.newFileClient() }
|
||||
|
||||
// retryStoreOp runs one store operation under the shared deterministic
|
||||
// transient-error policy (429/502/503/504).
|
||||
@@ -140,6 +150,7 @@ func FileEntryToEntry(f *FileEntry, provider string) Entry {
|
||||
e.MIME = mimeForTitle(e.Title, exst)
|
||||
if f.Updated != nil {
|
||||
e.Modified = *f.Updated
|
||||
e.Updated = f.Updated.Format(time.RFC3339)
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -153,6 +164,7 @@ func DavFileToEntry(f DavFile, provider string) Entry {
|
||||
Size: f.Size,
|
||||
MIME: mimeForTitle(f.Title, ""),
|
||||
Modified: f.ModTime(),
|
||||
Updated: f.Updated,
|
||||
Provider: provider,
|
||||
}
|
||||
}
|
||||
@@ -165,7 +177,10 @@ func DavFolderToEntry(f DavFolder, provider string) Entry {
|
||||
Title: f.Title,
|
||||
Kind: Folder,
|
||||
Modified: f.ModTime(),
|
||||
Updated: f.Updated,
|
||||
Provider: provider,
|
||||
FilesCount: f.FilesCount,
|
||||
FoldersCount: f.FoldersCount,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/internal/docpipe"
|
||||
)
|
||||
|
||||
// TestIntegrationESTextIndex verifies the own full-text index end to end
|
||||
@@ -90,3 +92,67 @@ func TestIntegrationESTextIndex(t *testing.T) {
|
||||
t.Errorf("after delete search returned %d hits, want 0", len(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationESTextIndexPDFAttachment indexes testdata/pdf-with-attachment.pdf
|
||||
// through the real pipeline (TextIndexer + docpipe: pdfdetach + pdftotext) and
|
||||
// verifies that text living only in the embedded attachment is searchable.
|
||||
//
|
||||
// Requires ONLYOFFICE_ES_URL plus poppler (pdfdetach/pdftotext). No OnlyOffice
|
||||
// credentials are needed: a fixture FileStore serves the PDF bytes.
|
||||
func TestIntegrationESTextIndexPDFAttachment(t *testing.T) {
|
||||
esURL := strings.TrimSpace(os.Getenv("ONLYOFFICE_ES_URL"))
|
||||
if esURL == "" {
|
||||
t.Skip("ONLYOFFICE_ES_URL not set — skipping Elasticsearch integration test")
|
||||
}
|
||||
if docpipe.LookPath().PDFDetach == "" {
|
||||
t.Skip("pdfdetach not on PATH — skipping PDF attachment integration test")
|
||||
}
|
||||
pdf, err := os.ReadFile("testdata/pdf-with-attachment.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
|
||||
stamp := time.Now().UTC().Format("20060102150405")
|
||||
idx, err := NewESTextIndex(ESTextConfig{URL: esURL, Index: "oo_docs_text_it_att_" + stamp})
|
||||
if err != nil {
|
||||
t.Fatalf("NewESTextIndex: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, done := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer done()
|
||||
_, _, _ = idx.do(cleanupCtx, http.MethodDelete, "/"+idx.Index(), nil, "")
|
||||
})
|
||||
if err := idx.Ensure(ctx); err != nil {
|
||||
t.Fatalf("Ensure: %v", err)
|
||||
}
|
||||
|
||||
store := &textFakeStore{files: map[string][]byte{"9001": pdf}}
|
||||
ix := NewTextIndexer(store, idx)
|
||||
res, err := ix.IndexEntries(ctx, []Entry{{ID: "9001", Title: "scan.pdf", ParentID: "777", Kind: File}}, IndexOptions{MinChars: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("IndexEntries: %v", err)
|
||||
}
|
||||
if res.Indexed != 1 || res.Failed != 0 {
|
||||
t.Fatalf("result = %+v, want one indexed doc", res)
|
||||
}
|
||||
|
||||
// Token appears only inside the embedded goo-note.txt attachment.
|
||||
hits, err := idx.Search(ctx, SearchQuery{Text: "gooattachmenttoken"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search attachment token: %v", err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].ID != "9001" {
|
||||
t.Fatalf("attachment-token hits = %+v, want doc 9001", hits)
|
||||
}
|
||||
if !strings.Contains(hits[0].Highlight, "gooattachmenttoken") {
|
||||
t.Errorf("highlight = %q, want attachment token", hits[0].Highlight)
|
||||
}
|
||||
// Body text is indexed as before.
|
||||
if hits, err := idx.Search(ctx, SearchQuery{Text: "goobodytoken"}); err != nil {
|
||||
t.Fatalf("Search body token: %v", err)
|
||||
} else if len(hits) != 1 {
|
||||
t.Errorf("body-token hits = %d, want 1", len(hits))
|
||||
}
|
||||
}
|
||||
|
||||
+28
-28
@@ -135,12 +135,12 @@ func TestESTextConfigFromEnvIndexDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTextIndexerIndexEntries(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
store := &textFakeStore{
|
||||
files: map[string][]byte{"1": []byte("PDFBYTES")},
|
||||
}
|
||||
idx := &fakeIndex{}
|
||||
idx := &textFakeIndex{}
|
||||
ix := NewTextIndexer(store, idx)
|
||||
ix.Extractor = fakeExtractor{prefix: "TEXT "}
|
||||
ix.Extractor = textFakeExtractor{prefix: "TEXT "}
|
||||
|
||||
res, err := ix.IndexEntries(context.Background(), []Entry{
|
||||
{ID: "1", Title: "Rechnung.PDF", ParentID: "649", Kind: File},
|
||||
@@ -164,10 +164,10 @@ func TestTextIndexerIndexEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTextIndexerRecordsExtractionFailure(t *testing.T) {
|
||||
store := &fakeStore{files: map[string][]byte{"1": []byte("x")}}
|
||||
idx := &fakeIndex{}
|
||||
store := &textFakeStore{files: map[string][]byte{"1": []byte("x")}}
|
||||
idx := &textFakeIndex{}
|
||||
ix := NewTextIndexer(store, idx)
|
||||
ix.Extractor = failingExtractor{}
|
||||
ix.Extractor = textFailingExtractor{}
|
||||
|
||||
res, err := ix.IndexEntries(context.Background(), []Entry{{ID: "1", Title: "a.pdf", Kind: File}}, IndexOptions{})
|
||||
if err != nil {
|
||||
@@ -179,7 +179,7 @@ func TestTextIndexerRecordsExtractionFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTextIndexerPlanFolder(t *testing.T) {
|
||||
store := &fakeStore{dirs: map[string][]Entry{
|
||||
store := &textFakeStore{dirs: map[string][]Entry{
|
||||
"root": {
|
||||
{ID: "10", Title: "a.pdf", Kind: File},
|
||||
{ID: "11", Title: "sub", Kind: Folder},
|
||||
@@ -189,7 +189,7 @@ func TestTextIndexerPlanFolder(t *testing.T) {
|
||||
{ID: "13", Title: "c.xlsx", Kind: File},
|
||||
},
|
||||
}}
|
||||
ix := NewTextIndexer(store, &fakeIndex{})
|
||||
ix := NewTextIndexer(store, &textFakeIndex{})
|
||||
|
||||
flat, err := ix.PlanFolder(context.Background(), "root", IndexOptions{})
|
||||
if err != nil {
|
||||
@@ -209,26 +209,26 @@ func TestTextIndexerPlanFolder(t *testing.T) {
|
||||
|
||||
// --- fakes -----------------------------------------------------------------
|
||||
|
||||
type fakeStore struct {
|
||||
type textFakeStore struct {
|
||||
dirs map[string][]Entry
|
||||
files map[string][]byte
|
||||
stat map[string]Entry
|
||||
}
|
||||
|
||||
func (f *fakeStore) Name() string { return "fake" }
|
||||
func (f *textFakeStore) Name() string { return "fake" }
|
||||
|
||||
func (f *fakeStore) List(_ context.Context, parentID string) ([]Entry, error) {
|
||||
func (f *textFakeStore) List(_ context.Context, parentID string) ([]Entry, error) {
|
||||
return f.dirs[parentID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Stat(_ context.Context, id string) (Entry, error) {
|
||||
func (f *textFakeStore) Stat(_ context.Context, id string) (Entry, error) {
|
||||
if e, ok := f.stat[id]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return Entry{}, fmt.Errorf("not found: %s", id)
|
||||
}
|
||||
|
||||
func (f *fakeStore) Download(_ context.Context, id string, w io.Writer) (int64, error) {
|
||||
func (f *textFakeStore) Download(_ context.Context, id string, w io.Writer) (int64, error) {
|
||||
b, ok := f.files[id]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("no bytes for %s", id)
|
||||
@@ -237,30 +237,30 @@ func (f *fakeStore) Download(_ context.Context, id string, w io.Writer) (int64,
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateFolder(context.Context, string, string) (Entry, error) {
|
||||
func (f *textFakeStore) CreateFolder(context.Context, string, string) (Entry, error) {
|
||||
return Entry{}, nil
|
||||
}
|
||||
func (f *fakeStore) Upload(context.Context, string, string, io.Reader) (Entry, error) {
|
||||
func (f *textFakeStore) Upload(context.Context, string, string, io.Reader) (Entry, error) {
|
||||
return Entry{}, nil
|
||||
}
|
||||
func (f *fakeStore) Move(context.Context, []string, string) error { return nil }
|
||||
func (f *fakeStore) Copy(context.Context, []string, string) error { return nil }
|
||||
func (f *fakeStore) Rename(context.Context, string, string) error { return nil }
|
||||
func (f *fakeStore) Delete(context.Context, []string) error { return nil }
|
||||
func (f *textFakeStore) Move(context.Context, []string, string) error { return nil }
|
||||
func (f *textFakeStore) Copy(context.Context, []string, string) error { return nil }
|
||||
func (f *textFakeStore) Rename(context.Context, string, string) error { return nil }
|
||||
func (f *textFakeStore) Delete(context.Context, []string) error { return nil }
|
||||
|
||||
type fakeIndex struct{ docs []TextDoc }
|
||||
type textFakeIndex struct{ docs []TextDoc }
|
||||
|
||||
func (f *fakeIndex) Put(_ context.Context, docs []TextDoc) error {
|
||||
func (f *textFakeIndex) Put(_ context.Context, docs []TextDoc) error {
|
||||
f.docs = append(f.docs, docs...)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeIndex) Delete(context.Context, []string) error { return nil }
|
||||
func (f *fakeIndex) Search(context.Context, SearchQuery) ([]SearchHit, error) { return nil, nil }
|
||||
func (f *fakeIndex) Name() string { return "fake" }
|
||||
func (f *textFakeIndex) Delete(context.Context, []string) error { return nil }
|
||||
func (f *textFakeIndex) Search(context.Context, SearchQuery) ([]SearchHit, error) { return nil, nil }
|
||||
func (f *textFakeIndex) Name() string { return "fake" }
|
||||
|
||||
type fakeExtractor struct{ prefix string }
|
||||
type textFakeExtractor struct{ prefix string }
|
||||
|
||||
func (f fakeExtractor) Extract(path, _, _ string, _ int) (string, error) {
|
||||
func (f textFakeExtractor) Extract(path, _, _ string, _ int) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -268,8 +268,8 @@ func (f fakeExtractor) Extract(path, _, _ string, _ int) (string, error) {
|
||||
return f.prefix + string(b), nil
|
||||
}
|
||||
|
||||
type failingExtractor struct{}
|
||||
type textFailingExtractor struct{}
|
||||
|
||||
func (failingExtractor) Extract(string, string, string, int) (string, error) {
|
||||
func (textFailingExtractor) Extract(string, string, string, int) (string, error) {
|
||||
return "", fmt.Errorf("boom")
|
||||
}
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package onlyoffice
|
||||
|
||||
// Single file client (epic #34, F4 #38). FileClient composes the registered
|
||||
// FileStore and Searcher backends and picks one per operation: REST/DAV for
|
||||
// writes, PostgreSQL (when registered) for fast reads, Elasticsearch for name
|
||||
// and content search. Client.Files returns the facade; it also implements
|
||||
// FileStore, so existing callers keep compiling.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProviderES is the composed Elasticsearch searcher. The SQL store owns
|
||||
// ProviderPG/ProviderMySQL (file_pg.go); the facade references ProviderPG in
|
||||
// readOrder.
|
||||
const ProviderES = "elasticsearch"
|
||||
|
||||
var (
|
||||
errNoReadBackend = errors.New("onlyoffice: no file backend registered for reads")
|
||||
errNoWriteBackend = errors.New("onlyoffice: no file backend registered for writes")
|
||||
errNoSearcher = errors.New("onlyoffice: no search backend registered (set ONLYOFFICE_ES_URL)")
|
||||
)
|
||||
|
||||
// FileClient is the single entry point for file operations. It holds the
|
||||
// registered backends and the order in which each operation tries them.
|
||||
type FileClient struct {
|
||||
stores map[string]FileStore
|
||||
searchers map[string]Searcher
|
||||
|
||||
readOrder []string
|
||||
writeOrder []string
|
||||
searchOrder []string
|
||||
}
|
||||
|
||||
// newFileClient builds the facade over the built-in REST and DAV stores. The
|
||||
// Elasticsearch searcher is registered when ONLYOFFICE_ES_URL is set; the
|
||||
// missing-credential case is left to Search so read-only commands still work.
|
||||
func (c *Client) newFileClient() *FileClient {
|
||||
f := &FileClient{
|
||||
stores: map[string]FileStore{
|
||||
ProviderREST: &restStore{c: c},
|
||||
ProviderDAV: &davStore{c: c},
|
||||
},
|
||||
searchers: map[string]Searcher{},
|
||||
readOrder: []string{ProviderPG, ProviderREST, ProviderDAV},
|
||||
writeOrder: []string{ProviderREST, ProviderDAV},
|
||||
searchOrder: []string{ProviderES},
|
||||
}
|
||||
if cfg := ESConfigFromEnv(); cfg.URL != "" {
|
||||
if es, err := NewESSearcher(cfg); err == nil {
|
||||
f.searchers[ProviderES] = es
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// RegisterStore adds or replaces a named backend (for example the PostgreSQL
|
||||
// read store). The name is matched case-insensitively.
|
||||
func (f *FileClient) RegisterStore(name string, s FileStore) {
|
||||
if f == nil || s == nil {
|
||||
return
|
||||
}
|
||||
name = normalizeProvider(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if f.stores == nil {
|
||||
f.stores = map[string]FileStore{}
|
||||
}
|
||||
f.stores[name] = s
|
||||
}
|
||||
|
||||
// RegisterSearcher adds or replaces a named search backend.
|
||||
func (f *FileClient) RegisterSearcher(name string, s Searcher) {
|
||||
if f == nil || s == nil {
|
||||
return
|
||||
}
|
||||
name = normalizeProvider(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if f.searchers == nil {
|
||||
f.searchers = map[string]Searcher{}
|
||||
}
|
||||
f.searchers[name] = s
|
||||
}
|
||||
|
||||
// Read returns the preferred backend for reads: PostgreSQL when registered,
|
||||
// then REST, then WebDAV.
|
||||
func (f *FileClient) Read() FileStore { return f.firstStore(f.readOrder) }
|
||||
|
||||
// Write returns the preferred backend for writes: REST, then WebDAV.
|
||||
func (f *FileClient) Write() FileStore { return f.firstStore(f.writeOrder) }
|
||||
|
||||
// Search returns the preferred name/content searcher (Elasticsearch), or an
|
||||
// error when no search backend is configured.
|
||||
func (f *FileClient) Search() (Searcher, error) {
|
||||
if f == nil {
|
||||
return nil, errNoSearcher
|
||||
}
|
||||
for _, name := range f.searchOrder {
|
||||
if s := f.searchers[normalizeProvider(name)]; s != nil {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return nil, errNoSearcher
|
||||
}
|
||||
|
||||
// firstStore returns the first registered store in the order.
|
||||
func (f *FileClient) firstStore(order []string) FileStore {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
for _, name := range order {
|
||||
if s := f.stores[normalizeProvider(name)]; s != nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderedStores returns the registered stores in the order.
|
||||
func (f *FileClient) orderedStores(order []string) []FileStore {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]FileStore, 0, len(order))
|
||||
for _, name := range order {
|
||||
if s := f.stores[normalizeProvider(name)]; s != nil {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeProvider(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
// Name implements FileStore and reports the preferred read backend.
|
||||
func (f *FileClient) Name() string {
|
||||
if s := f.Read(); s != nil {
|
||||
return s.Name()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// List reads from the preferred backend, falling back to the next read backend
|
||||
// only on a transient error (429/502/503/504).
|
||||
func (f *FileClient) List(ctx context.Context, parentID string) ([]Entry, error) {
|
||||
return fallbackRead(ctx, f.orderedStores(f.readOrder), func(s FileStore) ([]Entry, error) {
|
||||
return s.List(ctx, parentID)
|
||||
})
|
||||
}
|
||||
|
||||
// Stat reads from the preferred backend, with the same transient fallback.
|
||||
func (f *FileClient) Stat(ctx context.Context, id string) (Entry, error) {
|
||||
return fallbackRead(ctx, f.orderedStores(f.readOrder), func(s FileStore) (Entry, error) {
|
||||
return s.Stat(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
// Download streams file bytes. It does not fall back: a failed attempt may have
|
||||
// already written partial bytes into w, so a second backend would append.
|
||||
func (f *FileClient) Download(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||
s := f.Read()
|
||||
if s == nil {
|
||||
return 0, errNoReadBackend
|
||||
}
|
||||
return s.Download(ctx, id, w)
|
||||
}
|
||||
|
||||
// CreateFolder writes to the preferred write backend.
|
||||
func (f *FileClient) CreateFolder(ctx context.Context, parentID, title string) (Entry, error) {
|
||||
s := f.Write()
|
||||
if s == nil {
|
||||
return Entry{}, errNoWriteBackend
|
||||
}
|
||||
return s.CreateFolder(ctx, parentID, title)
|
||||
}
|
||||
|
||||
// Upload writes to the preferred write backend.
|
||||
func (f *FileClient) Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error) {
|
||||
s := f.Write()
|
||||
if s == nil {
|
||||
return Entry{}, errNoWriteBackend
|
||||
}
|
||||
return s.Upload(ctx, parentID, title, r)
|
||||
}
|
||||
|
||||
// Move writes to the preferred write backend.
|
||||
func (f *FileClient) Move(ctx context.Context, ids []string, parentID string) error {
|
||||
s := f.Write()
|
||||
if s == nil {
|
||||
return errNoWriteBackend
|
||||
}
|
||||
return s.Move(ctx, ids, parentID)
|
||||
}
|
||||
|
||||
// Copy writes to the preferred write backend.
|
||||
func (f *FileClient) Copy(ctx context.Context, ids []string, parentID string) error {
|
||||
s := f.Write()
|
||||
if s == nil {
|
||||
return errNoWriteBackend
|
||||
}
|
||||
return s.Copy(ctx, ids, parentID)
|
||||
}
|
||||
|
||||
// Rename writes to the preferred write backend.
|
||||
func (f *FileClient) Rename(ctx context.Context, id, title string) error {
|
||||
s := f.Write()
|
||||
if s == nil {
|
||||
return errNoWriteBackend
|
||||
}
|
||||
return s.Rename(ctx, id, title)
|
||||
}
|
||||
|
||||
// Delete writes to the preferred write backend.
|
||||
func (f *FileClient) Delete(ctx context.Context, ids []string) error {
|
||||
s := f.Write()
|
||||
if s == nil {
|
||||
return errNoWriteBackend
|
||||
}
|
||||
return s.Delete(ctx, ids)
|
||||
}
|
||||
|
||||
// fallbackRead runs op against each store in order, moving on only when the
|
||||
// error is transient. Non-transient errors (not found, forbidden) are final.
|
||||
func fallbackRead[T any](ctx context.Context, stores []FileStore, op func(FileStore) (T, error)) (T, error) {
|
||||
var zero T
|
||||
if len(stores) == 0 {
|
||||
return zero, errNoReadBackend
|
||||
}
|
||||
var err error
|
||||
for i, s := range stores {
|
||||
var v T
|
||||
v, err = op(s)
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
if i == len(stores)-1 || !Transient(err) {
|
||||
return zero, err
|
||||
}
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeStore is a FileStore test double; it records which backend served a call
|
||||
// and returns a canned result or error.
|
||||
type fakeStore struct {
|
||||
name string
|
||||
entries []Entry
|
||||
err error
|
||||
calls *[]string
|
||||
}
|
||||
|
||||
func (f *fakeStore) record(op string) {
|
||||
if f.calls != nil {
|
||||
*f.calls = append(*f.calls, op+":"+f.name)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeStore) Name() string { return f.name }
|
||||
|
||||
func (f *fakeStore) List(_ context.Context, _ string) ([]Entry, error) {
|
||||
f.record("list")
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.entries, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Stat(_ context.Context, id string) (Entry, error) {
|
||||
f.record("stat")
|
||||
if f.err != nil {
|
||||
return Entry{}, f.err
|
||||
}
|
||||
return Entry{ID: id, Title: "t-" + f.name, Provider: f.name}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateFolder(_ context.Context, _, title string) (Entry, error) {
|
||||
f.record("mkdir")
|
||||
if f.err != nil {
|
||||
return Entry{}, f.err
|
||||
}
|
||||
return Entry{ID: "new", Title: title, Provider: f.name}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Upload(_ context.Context, _, title string, _ io.Reader) (Entry, error) {
|
||||
f.record("upload")
|
||||
return Entry{ID: "up", Title: title, Provider: f.name}, f.err
|
||||
}
|
||||
|
||||
func (f *fakeStore) Download(_ context.Context, _ string, _ io.Writer) (int64, error) {
|
||||
f.record("download")
|
||||
return 0, f.err
|
||||
}
|
||||
|
||||
func (f *fakeStore) Move(_ context.Context, _ []string, _ string) error {
|
||||
f.record("move")
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeStore) Copy(_ context.Context, _ []string, _ string) error {
|
||||
f.record("copy")
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeStore) Rename(_ context.Context, _, _ string) error {
|
||||
f.record("rename")
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeStore) Delete(_ context.Context, _ []string) error {
|
||||
f.record("delete")
|
||||
return f.err
|
||||
}
|
||||
|
||||
type fakeSearcher struct{ name string }
|
||||
|
||||
func (s *fakeSearcher) Name() string { return s.name }
|
||||
|
||||
func (s *fakeSearcher) Search(_ context.Context, _ SearchQuery) ([]SearchHit, error) {
|
||||
return []SearchHit{{Entry: Entry{Title: s.name}}}, nil
|
||||
}
|
||||
|
||||
func newFacadeTestClient(stores map[string]FileStore, read, write []string) *FileClient {
|
||||
return &FileClient{
|
||||
stores: stores,
|
||||
searchers: map[string]Searcher{},
|
||||
readOrder: read,
|
||||
writeOrder: write,
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileClientIsFileStore guarantees the facade can stand in for the
|
||||
// interface anywhere a plain FileStore is expected.
|
||||
func TestFileClientIsFileStore(t *testing.T) {
|
||||
var _ FileStore = (*FileClient)(nil)
|
||||
}
|
||||
|
||||
func TestClientFilesPrefersRESTForReadsAndWrites(t *testing.T) {
|
||||
c := NewClient(Credentials{})
|
||||
f := c.Files()
|
||||
if got := f.Read().Name(); got != ProviderREST {
|
||||
t.Errorf("Read().Name() = %q, want %q", got, ProviderREST)
|
||||
}
|
||||
if got := f.Write().Name(); got != ProviderREST {
|
||||
t.Errorf("Write().Name() = %q, want %q", got, ProviderREST)
|
||||
}
|
||||
if got := f.Name(); got != ProviderREST {
|
||||
t.Errorf("Name() = %q, want %q", got, ProviderREST)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileClientPostgresTakesReadPriority(t *testing.T) {
|
||||
pg := &fakeStore{name: ProviderPG}
|
||||
f := newFacadeTestClient(
|
||||
map[string]FileStore{ProviderREST: &fakeStore{name: ProviderREST}, ProviderPG: pg},
|
||||
[]string{ProviderPG, ProviderREST},
|
||||
[]string{ProviderREST},
|
||||
)
|
||||
if got := f.Read().Name(); got != ProviderPG {
|
||||
t.Errorf("Read().Name() = %q, want %q", got, ProviderPG)
|
||||
}
|
||||
if got := f.Write().Name(); got != ProviderREST {
|
||||
t.Errorf("Write().Name() = %q, want %q (PG is read-only)", got, ProviderREST)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileClientRegisterStoreNormalizesName(t *testing.T) {
|
||||
pg := &fakeStore{name: "pg"}
|
||||
f := newFacadeTestClient(map[string]FileStore{}, []string{ProviderPG}, nil)
|
||||
f.RegisterStore(" POSTGRES ", pg)
|
||||
if got := f.Read(); got != pg {
|
||||
t.Fatalf("Read() = %v, want registered postgres store", got)
|
||||
}
|
||||
f.RegisterStore("", pg)
|
||||
f.RegisterStore("pg", nil)
|
||||
}
|
||||
|
||||
func TestFileClientReadFallsBackOnlyOnTransient(t *testing.T) {
|
||||
var calls []string
|
||||
primary := &fakeStore{name: "primary", err: fmt.Errorf("onlyoffice: list: 503 unavailable"), calls: &calls}
|
||||
secondary := &fakeStore{name: "secondary", entries: []Entry{{ID: "1"}}, calls: &calls}
|
||||
f := newFacadeTestClient(
|
||||
map[string]FileStore{"primary": primary, "secondary": secondary},
|
||||
[]string{"primary", "secondary"},
|
||||
nil,
|
||||
)
|
||||
got, err := f.List(context.Background(), "root")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "1" {
|
||||
t.Fatalf("List() = %+v, want secondary entry", got)
|
||||
}
|
||||
want := []string{"list:primary", "list:secondary"}
|
||||
if fmt.Sprint(calls) != fmt.Sprint(want) {
|
||||
t.Fatalf("call order = %v, want %v", calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileClientReadStopsOnPermanentError(t *testing.T) {
|
||||
var calls []string
|
||||
primary := &fakeStore{name: "primary", err: errors.New("onlyoffice: not found"), calls: &calls}
|
||||
secondary := &fakeStore{name: "secondary", entries: []Entry{{ID: "1"}}, calls: &calls}
|
||||
f := newFacadeTestClient(
|
||||
map[string]FileStore{"primary": primary, "secondary": secondary},
|
||||
[]string{"primary", "secondary"},
|
||||
nil,
|
||||
)
|
||||
if _, err := f.List(context.Background(), "root"); err == nil {
|
||||
t.Fatal("expected permanent error to be returned")
|
||||
}
|
||||
if len(calls) != 1 || calls[0] != "list:primary" {
|
||||
t.Fatalf("secondary backend must not run on a permanent error: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileClientWriteUsesWriteBackend(t *testing.T) {
|
||||
var calls []string
|
||||
rest := &fakeStore{name: ProviderREST, calls: &calls}
|
||||
dav := &fakeStore{name: ProviderDAV, calls: &calls}
|
||||
f := newFacadeTestClient(
|
||||
map[string]FileStore{ProviderREST: rest, ProviderDAV: dav},
|
||||
[]string{ProviderREST},
|
||||
[]string{ProviderREST, ProviderDAV},
|
||||
)
|
||||
if _, err := f.CreateFolder(context.Background(), "p", "t"); err != nil {
|
||||
t.Fatalf("CreateFolder: %v", err)
|
||||
}
|
||||
if _, err := f.Upload(context.Background(), "p", "t", nil); err != nil {
|
||||
t.Fatalf("Upload: %v", err)
|
||||
}
|
||||
if len(calls) != 2 || calls[0] != "mkdir:rest" || calls[1] != "upload:rest" {
|
||||
t.Fatalf("write calls = %v, want REST", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileClientWriteWithoutBackend(t *testing.T) {
|
||||
f := newFacadeTestClient(map[string]FileStore{}, nil, nil)
|
||||
if err := f.Delete(context.Background(), []string{"1"}); !errors.Is(err, errNoWriteBackend) {
|
||||
t.Fatalf("Delete err = %v, want errNoWriteBackend", err)
|
||||
}
|
||||
if _, err := f.List(context.Background(), "root"); !errors.Is(err, errNoReadBackend) {
|
||||
t.Fatalf("List err = %v, want errNoReadBackend", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileClientSearchSelection(t *testing.T) {
|
||||
f := &FileClient{searchers: map[string]Searcher{}, searchOrder: []string{ProviderES}}
|
||||
_, err := f.Search()
|
||||
if err == nil || !strings.Contains(err.Error(), "ONLYOFFICE_ES_URL") {
|
||||
t.Fatalf("Search without backend = %v, want ONLYOFFICE_ES_URL hint", err)
|
||||
}
|
||||
es := &fakeSearcher{name: "fake-es"}
|
||||
f.RegisterSearcher(ProviderES, es)
|
||||
got, err := f.Search()
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if got.Name() != "fake-es" {
|
||||
t.Fatalf("searcher = %q, want fake-es", got.Name())
|
||||
}
|
||||
}
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
package onlyoffice
|
||||
|
||||
// Read-only SQL backend of the unified file client (epic #34, F2 #36).
|
||||
//
|
||||
// The goal is to read files and folders straight from the Community Server
|
||||
// database, without the REST layer. Research on the live portal (VM
|
||||
// `onlyoffice-v2`) showed the server runs on **MySQL 8.0** (`files_file`,
|
||||
// `files_folder`, `files_folder_tree`, tenant `tenants_tenants`), not
|
||||
// PostgreSQL — see docs/community-server-db.md. The store below therefore
|
||||
// speaks `database/sql` and selects its driver from the DSN, so it works
|
||||
// against the live MySQL today and against PostgreSQL if the portal is ever
|
||||
// migrated. Every query is a SELECT; the write methods of FileStore return
|
||||
// ErrReadOnly.
|
||||
//
|
||||
// Downloads follow the portal's S3/MinIO object layout through the shared
|
||||
// MinIO helper in storage_fallback.go — no HTTP file endpoint is used.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// Provider names for the SQL backend. ProviderPG is the value Name reports for
|
||||
// a PostgreSQL connection and ProviderMySQL for MySQL.
|
||||
const (
|
||||
ProviderPG = "postgres"
|
||||
ProviderMySQL = "mysql"
|
||||
)
|
||||
|
||||
// ErrReadOnly is returned by every FileStore write method of the SQL backend.
|
||||
var ErrReadOnly = errors.New("onlyoffice: sql file store is read-only")
|
||||
|
||||
const (
|
||||
pgConnectTimeout = 10 * time.Second
|
||||
pgSearchLimit = 50
|
||||
pgSearchMaxLimit = 500
|
||||
)
|
||||
|
||||
// PGConfig configures the read-only SQL store. DSN is a driver DSN:
|
||||
// `user:pass@tcp(host:port)/onlyoffice?parseTime=true` for MySQL or a
|
||||
// `postgres://` / libpq keyword string for PostgreSQL. Driver, when set,
|
||||
// forces the engine ("postgres" or "mysql"); otherwise it is detected from the
|
||||
// DSN. Tenant filters rows (empty means all tenants).
|
||||
type PGConfig struct {
|
||||
DSN string
|
||||
Driver string
|
||||
Tenant string
|
||||
}
|
||||
|
||||
// PGConfigFromEnv reads ONLYOFFICE_DSN (or the ONLYOFFICE_PG_* parts),
|
||||
// ONLYOFFICE_PG_DRIVER and the tenant from ONLYOFFICE_PG_TENANT /
|
||||
// ONLYOFFICE_TENANT. The library never loads dotfiles — the CLI does that.
|
||||
func PGConfigFromEnv() PGConfig {
|
||||
dsn := strings.TrimSpace(os.Getenv("ONLYOFFICE_DSN"))
|
||||
if dsn == "" {
|
||||
dsn = pgDSNFromParts()
|
||||
}
|
||||
return PGConfig{
|
||||
DSN: dsn,
|
||||
Driver: strings.TrimSpace(os.Getenv("ONLYOFFICE_PG_DRIVER")),
|
||||
Tenant: firstNonEmpty(os.Getenv("ONLYOFFICE_PG_TENANT"), os.Getenv("ONLYOFFICE_TENANT")),
|
||||
}
|
||||
}
|
||||
|
||||
// pgDSNFromParts builds a libpq keyword DSN from ONLYOFFICE_PG_* variables.
|
||||
// It returns "" unless a host is set, which keeps the MySQL path (ONLYOFFICE_DSN)
|
||||
// the default.
|
||||
func pgDSNFromParts() string {
|
||||
host := strings.TrimSpace(os.Getenv("ONLYOFFICE_PG_HOST"))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
port := firstNonEmpty(os.Getenv("ONLYOFFICE_PG_PORT"), "5432")
|
||||
dbname := firstNonEmpty(os.Getenv("ONLYOFFICE_PG_DBNAME"), "onlyoffice")
|
||||
sslmode := firstNonEmpty(os.Getenv("ONLYOFFICE_PG_SSLMODE"), "disable")
|
||||
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||
host, port, os.Getenv("ONLYOFFICE_PG_USER"), os.Getenv("ONLYOFFICE_PG_PASSWORD"), dbname, sslmode)
|
||||
}
|
||||
|
||||
// pgStore is a read-only FileStore/Searcher over the Community Server database.
|
||||
type pgStore struct {
|
||||
db *sql.DB
|
||||
driver string
|
||||
tenantID int64
|
||||
hasTenant bool
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
_ FileStore = (*pgStore)(nil)
|
||||
_ Searcher = (*pgStore)(nil)
|
||||
)
|
||||
|
||||
// NewPGStore opens the database and verifies connectivity. It never writes.
|
||||
func NewPGStore(cfg PGConfig) (*pgStore, error) {
|
||||
dsn := strings.TrimSpace(cfg.DSN)
|
||||
if dsn == "" {
|
||||
return nil, fmt.Errorf("onlyoffice: sql file store: empty DSN (set ONLYOFFICE_DSN)")
|
||||
}
|
||||
driver := pgDriver(dsn, cfg.Driver)
|
||||
dsn, err := normalizeSQLDSN(driver, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open(sqlDriverName(driver), dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("onlyoffice: sql file store: open %s: %w", driver, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), pgConnectTimeout)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("onlyoffice: sql file store: ping %s: %w", driver, err)
|
||||
}
|
||||
s := &pgStore{db: db, driver: driver, http: &http.Client{}}
|
||||
if t := strings.TrimSpace(cfg.Tenant); t != "" {
|
||||
n, err := strconv.ParseInt(t, 10, 64)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("onlyoffice: sql file store: non-numeric tenant %q", t)
|
||||
}
|
||||
s.tenantID, s.hasTenant = n, true
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close releases the database handle.
|
||||
func (s *pgStore) Close() error { return s.db.Close() }
|
||||
|
||||
// Name implements FileStore and Searcher.
|
||||
func (s *pgStore) Name() string { return s.driver }
|
||||
|
||||
// pgDriver resolves the engine: the explicit value wins, otherwise the DSN
|
||||
// shape decides. A leading postgres:// scheme or a libpq keyword DSN (which
|
||||
// always carries '=') selects PostgreSQL; anything else is MySQL.
|
||||
func pgDriver(dsn, explicit string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(explicit)) {
|
||||
case ProviderPG, "pg", "postgresql", "pgx":
|
||||
return ProviderPG
|
||||
case ProviderMySQL, "mariadb":
|
||||
return ProviderMySQL
|
||||
}
|
||||
l := strings.ToLower(strings.TrimSpace(dsn))
|
||||
switch {
|
||||
case strings.HasPrefix(l, "postgres://"), strings.HasPrefix(l, "postgresql://"):
|
||||
return ProviderPG
|
||||
case strings.HasPrefix(l, "mysql://"), strings.Contains(l, "@tcp("), strings.Contains(l, "@unix("):
|
||||
return ProviderMySQL
|
||||
case strings.Contains(l, "="):
|
||||
return ProviderPG
|
||||
default:
|
||||
return ProviderMySQL
|
||||
}
|
||||
}
|
||||
|
||||
// sqlDriverName maps the engine to its registered database/sql driver.
|
||||
func sqlDriverName(driver string) string {
|
||||
if driver == ProviderPG {
|
||||
return "pgx"
|
||||
}
|
||||
return "mysql"
|
||||
}
|
||||
|
||||
// normalizeSQLDSN converts a mysql:// URL to the go-sql-driver form and forces
|
||||
// parseTime so datetime columns scan into time.Time. PostgreSQL DSNs pass
|
||||
// through untouched.
|
||||
func normalizeSQLDSN(driver, dsn string) (string, error) {
|
||||
if driver != ProviderMySQL {
|
||||
return dsn, nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(dsn), "mysql://") {
|
||||
converted, err := mysqlDSNFromURL(dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dsn = converted
|
||||
}
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("onlyoffice: sql file store: parse mysql DSN: %w", err)
|
||||
}
|
||||
cfg.ParseTime = true
|
||||
return cfg.FormatDSN(), nil
|
||||
}
|
||||
|
||||
// mysqlDSNFromURL turns mysql://user:pass@host:port/db into the driver DSN.
|
||||
func mysqlDSNFromURL(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", fmt.Errorf("onlyoffice: sql file store: bad mysql URL %q", raw)
|
||||
}
|
||||
user := ""
|
||||
if u.User != nil {
|
||||
user = u.User.Username()
|
||||
if p, ok := u.User.Password(); ok {
|
||||
user += ":" + p
|
||||
}
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("parseTime", "true")
|
||||
return fmt.Sprintf("%s@tcp(%s)/%s?%s", user, u.Host, strings.TrimPrefix(u.Path, "/"), q.Encode()), nil
|
||||
}
|
||||
|
||||
// rebind rewrites '?' placeholders to PostgreSQL's $1..$n. MySQL keeps them.
|
||||
func rebind(query, driver string) string {
|
||||
if driver != ProviderPG {
|
||||
return query
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(query) + 8)
|
||||
n := 0
|
||||
for _, r := range query {
|
||||
if r == '?' {
|
||||
n++
|
||||
b.WriteByte('$')
|
||||
b.WriteString(strconv.Itoa(n))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// List returns the folders and files directly below parentID, folders first.
|
||||
func (s *pgStore) List(ctx context.Context, parentID string) ([]Entry, error) {
|
||||
pid, err := parseEntryID(parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folders, err := s.queryFolders(ctx, "parent_id = ?", pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files, err := s.queryFiles(ctx, "folder_id = ? AND current_version = 1", pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Entry, 0, len(folders)+len(files))
|
||||
for _, f := range folders {
|
||||
out = append(out, folderRowToEntry(f, s.Name()))
|
||||
}
|
||||
for _, f := range files {
|
||||
out = append(out, fileRowToEntry(f, s.Name()))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stat resolves a folder or file id to an Entry. Folders win when both id
|
||||
// spaces overlap (they never do on a real portal, but the lookup is cheap).
|
||||
func (s *pgStore) Stat(ctx context.Context, id string) (Entry, error) {
|
||||
n, err := parseEntryID(id)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
folders, err := s.queryFolders(ctx, "id = ?", n)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if len(folders) > 0 {
|
||||
return folderRowToEntry(folders[0], s.Name()), nil
|
||||
}
|
||||
files, err := s.queryFiles(ctx, "id = ? AND current_version = 1", n)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return Entry{}, fmt.Errorf("onlyoffice: sql file store: id %s not found", id)
|
||||
}
|
||||
return fileRowToEntry(files[0], s.Name()), nil
|
||||
}
|
||||
|
||||
// Download streams the file's current version from the portal's S3/MinIO store.
|
||||
// The object key is reconstructed from the file id and version; the parent
|
||||
// folder id is not part of the key.
|
||||
func (s *pgStore) Download(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||
n, err := parseEntryID(id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
files, err := s.queryFiles(ctx, "id = ? AND current_version = 1", n)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return 0, fmt.Errorf("onlyoffice: sql file store: file %s not found", id)
|
||||
}
|
||||
f := files[0]
|
||||
key := csObjectKey(s.tenantID, f.id, f.version, filepath.Ext(f.title))
|
||||
return downloadMinioObject(ctx, s.http, key, w)
|
||||
}
|
||||
|
||||
// CreateFolder is unavailable: the SQL backend is read-only.
|
||||
func (s *pgStore) CreateFolder(context.Context, string, string) (Entry, error) {
|
||||
return Entry{}, ErrReadOnly
|
||||
}
|
||||
|
||||
// Upload is unavailable: the SQL backend is read-only.
|
||||
func (s *pgStore) Upload(context.Context, string, string, io.Reader) (Entry, error) {
|
||||
return Entry{}, ErrReadOnly
|
||||
}
|
||||
|
||||
// Move is unavailable: the SQL backend is read-only.
|
||||
func (s *pgStore) Move(context.Context, []string, string) error { return ErrReadOnly }
|
||||
|
||||
// Copy is unavailable: the SQL backend is read-only.
|
||||
func (s *pgStore) Copy(context.Context, []string, string) error { return ErrReadOnly }
|
||||
|
||||
// Rename is unavailable: the SQL backend is read-only.
|
||||
func (s *pgStore) Rename(context.Context, string, string) error { return ErrReadOnly }
|
||||
|
||||
// Delete is unavailable: the SQL backend is read-only.
|
||||
func (s *pgStore) Delete(context.Context, []string) error { return ErrReadOnly }
|
||||
|
||||
// Search matches file titles by substring. Content search lives in the
|
||||
// Elasticsearch backend; q.InContent is ignored here.
|
||||
func (s *pgStore) Search(ctx context.Context, q SearchQuery) ([]SearchHit, error) {
|
||||
text := strings.TrimSpace(q.Text)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("onlyoffice: empty search query")
|
||||
}
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = pgSearchLimit
|
||||
}
|
||||
if limit > pgSearchMaxLimit {
|
||||
limit = pgSearchMaxLimit
|
||||
}
|
||||
|
||||
where := "title LIKE ? AND current_version = 1"
|
||||
args := []any{"%" + text + "%"}
|
||||
if s.hasTenant {
|
||||
where += " AND tenant_id = ?"
|
||||
args = append(args, s.tenantID)
|
||||
}
|
||||
if fid := strings.TrimSpace(q.FolderID); fid != "" {
|
||||
n, err := parseEntryID(fid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where += " AND folder_id = ?"
|
||||
args = append(args, n)
|
||||
}
|
||||
for _, ext := range normalizeExtensions(q.Extensions) {
|
||||
where += " AND LOWER(title) LIKE ?"
|
||||
args = append(args, "%."+ext)
|
||||
}
|
||||
query := rebind(`SELECT id, folder_id, title, content_length, version, create_on, modified_on
|
||||
FROM files_file WHERE `+where+` ORDER BY modified_on DESC, id DESC LIMIT ?`, s.driver)
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("onlyoffice: sql search: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var hits []SearchHit
|
||||
for rows.Next() {
|
||||
f, err := scanFileRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hits = append(hits, SearchHit{Entry: fileRowToEntry(f, s.Name())})
|
||||
}
|
||||
return hits, rows.Err()
|
||||
}
|
||||
|
||||
// queryFolders runs a folder SELECT with the tenant filter applied.
|
||||
func (s *pgStore) queryFolders(ctx context.Context, where string, arg any) ([]pgFolderRow, error) {
|
||||
args := []any{arg}
|
||||
if s.hasTenant {
|
||||
where += " AND tenant_id = ?"
|
||||
args = append(args, s.tenantID)
|
||||
}
|
||||
query := rebind(`SELECT id, parent_id, title, create_on, modified_on
|
||||
FROM files_folder WHERE `+where+` ORDER BY title, id`, s.driver)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("onlyoffice: sql list folders: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []pgFolderRow
|
||||
for rows.Next() {
|
||||
var r pgFolderRow
|
||||
if err := rows.Scan(&r.id, &r.parentID, &r.title, &r.created, &r.modified); err != nil {
|
||||
return nil, fmt.Errorf("onlyoffice: sql folder row: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// queryFiles runs a file SELECT for the current version with the tenant filter.
|
||||
func (s *pgStore) queryFiles(ctx context.Context, where string, arg any) ([]pgFileRow, error) {
|
||||
args := []any{arg}
|
||||
if s.hasTenant {
|
||||
where += " AND tenant_id = ?"
|
||||
args = append(args, s.tenantID)
|
||||
}
|
||||
query := rebind(`SELECT id, folder_id, title, content_length, version, create_on, modified_on
|
||||
FROM files_file WHERE `+where+` ORDER BY title, id`, s.driver)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("onlyoffice: sql list files: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []pgFileRow
|
||||
for rows.Next() {
|
||||
f, err := scanFileRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// pgFileRow is one current files_file row.
|
||||
type pgFileRow struct {
|
||||
id int64
|
||||
folderID int64
|
||||
title string
|
||||
size int64
|
||||
version int
|
||||
created time.Time
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
// pgFolderRow is one files_folder row.
|
||||
type pgFolderRow struct {
|
||||
id int64
|
||||
parentID int64
|
||||
title string
|
||||
created time.Time
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
// scanFileRow reads the canonical file column order.
|
||||
func scanFileRow(rows *sql.Rows) (pgFileRow, error) {
|
||||
var f pgFileRow
|
||||
if err := rows.Scan(&f.id, &f.folderID, &f.title, &f.size, &f.version, &f.created, &f.modified); err != nil {
|
||||
return f, fmt.Errorf("onlyoffice: sql file row: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// fileRowToEntry maps a files_file row to the canonical model.
|
||||
func fileRowToEntry(f pgFileRow, provider string) Entry {
|
||||
return Entry{
|
||||
ID: strconv.FormatInt(f.id, 10),
|
||||
ParentID: strconv.FormatInt(f.folderID, 10),
|
||||
Title: f.title,
|
||||
Kind: File,
|
||||
Size: f.size,
|
||||
MIME: mimeForTitle(f.title, ""),
|
||||
Created: f.created.UTC(),
|
||||
Modified: f.modified.UTC(),
|
||||
Version: f.version,
|
||||
Provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
// folderRowToEntry maps a files_folder row to the canonical model.
|
||||
func folderRowToEntry(f pgFolderRow, provider string) Entry {
|
||||
return Entry{
|
||||
ID: strconv.FormatInt(f.id, 10),
|
||||
ParentID: strconv.FormatInt(f.parentID, 10),
|
||||
Title: f.title,
|
||||
Kind: Folder,
|
||||
Created: f.created.UTC(),
|
||||
Modified: f.modified.UTC(),
|
||||
Provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
// csObjectKey reconstructs the object key the portal's S3 consumer uses:
|
||||
//
|
||||
// 00/00/<tenant>/files/folder_<shard>/file_<id>/v<version>/content.<ext>
|
||||
//
|
||||
// The shard is the next thousand above the file id (file 3727 -> folder_4000),
|
||||
// NOT the parent folder id — verified live against the MinIO bucket.
|
||||
func csObjectKey(tenant int64, fileID int64, version int, ext string) string {
|
||||
shard := (fileID/1000 + 1) * 1000
|
||||
ext = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
|
||||
if ext == "" {
|
||||
ext = "bin"
|
||||
}
|
||||
if version < 1 {
|
||||
version = 1
|
||||
}
|
||||
if tenant <= 0 {
|
||||
tenant = 1
|
||||
}
|
||||
return fmt.Sprintf("00/00/%02d/files/folder_%d/file_%d/v%d/content.%s", tenant, shard, fileID, version, ext)
|
||||
}
|
||||
|
||||
// parseEntryID parses a numeric OnlyOffice id or returns a store error.
|
||||
func parseEntryID(id string) (int64, error) {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(id), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("onlyoffice: sql file store: non-numeric id %q", id)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//go:build integration
|
||||
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIntegrationPGStore exercises the read-only SQL backend against the live
|
||||
// Community Server database and cross-checks list/stat/download with the REST
|
||||
// FileStore. It needs ONLYOFFICE_DSN plus the usual ONLYOFFICE_URL/USER/PASS;
|
||||
// ONLYOFFICE_PG_TEST_FILE_ID / ONLYOFFICE_PG_TEST_FOLDER_ID pick a real file
|
||||
// (a file reachable over REST too). Download streams from MinIO, so it also
|
||||
// needs MINIO_ACCESS_KEY/MINIO_SECRET_KEY.
|
||||
//
|
||||
// The live Community Server runs on MySQL; PostgreSQL is supported by the same
|
||||
// code path when the DSN says so.
|
||||
func TestIntegrationPGStore(t *testing.T) {
|
||||
cfg := PGConfigFromEnv()
|
||||
if strings.TrimSpace(cfg.DSN) == "" {
|
||||
t.Skip("ONLYOFFICE_DSN not set — skipping SQL store integration test")
|
||||
}
|
||||
store, err := NewPGStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPGStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
t.Logf("sql store backend: %s", store.Name())
|
||||
ctx := context.Background()
|
||||
|
||||
if err := testPGStoreReadOnly(ctx, store); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileID := strings.TrimSpace(os.Getenv("ONLYOFFICE_PG_TEST_FILE_ID"))
|
||||
folderID := strings.TrimSpace(os.Getenv("ONLYOFFICE_PG_TEST_FOLDER_ID"))
|
||||
if fileID == "" || folderID == "" {
|
||||
t.Skip("ONLYOFFICE_PG_TEST_FILE_ID / ONLYOFFICE_PG_TEST_FOLDER_ID not set — skipping live comparison")
|
||||
}
|
||||
|
||||
c := liveClient(t)
|
||||
rest := c.Files()
|
||||
|
||||
dbFile, err := store.Stat(ctx, fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("sql Stat(%s): %v", fileID, err)
|
||||
}
|
||||
restFile, err := rest.Stat(ctx, fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("rest Stat(%s): %v", fileID, err)
|
||||
}
|
||||
if dbFile.Kind != File {
|
||||
t.Errorf("sql kind = %v, want file", dbFile.Kind)
|
||||
}
|
||||
if dbFile.ID != restFile.ID || dbFile.Title != restFile.Title || dbFile.ParentID != restFile.ParentID {
|
||||
t.Errorf("stat mismatch sql=%+v rest=%+v", dbFile, restFile)
|
||||
}
|
||||
// GetFile omits contentLength, so size is only comparable when REST has it.
|
||||
if restFile.Size > 0 && dbFile.Size != restFile.Size {
|
||||
t.Errorf("size sql=%d rest=%d", dbFile.Size, restFile.Size)
|
||||
}
|
||||
if d := dbFile.Modified.Sub(restFile.Modified); d > 2*time.Minute || d < -2*time.Minute {
|
||||
t.Errorf("modified sql=%v rest=%v", dbFile.Modified, restFile.Modified)
|
||||
}
|
||||
|
||||
list, err := store.List(ctx, folderID)
|
||||
if err != nil {
|
||||
t.Fatalf("sql List(%s): %v", folderID, err)
|
||||
}
|
||||
if entryByID(list, fileID) == nil {
|
||||
t.Errorf("file %s not in sql List(%s)", fileID, folderID)
|
||||
}
|
||||
|
||||
// Every file the REST layer can see in the folder must be in the SQL list
|
||||
// (the SQL store sees more, so only assert this direction).
|
||||
restList, err := rest.List(ctx, folderID)
|
||||
if err != nil {
|
||||
t.Fatalf("rest List(%s): %v", folderID, err)
|
||||
}
|
||||
dbIDs := make(map[string]bool, len(list))
|
||||
for _, e := range list {
|
||||
dbIDs[e.ID] = true
|
||||
}
|
||||
for _, e := range restList {
|
||||
if e.Kind == File && !dbIDs[e.ID] {
|
||||
t.Errorf("rest file %s (%q) missing from sql list", e.ID, e.Title)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
n, err := store.Download(ctx, fileID, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("sql Download(%s): %v", fileID, err)
|
||||
}
|
||||
if n == 0 || n != dbFile.Size {
|
||||
t.Errorf("sql Download = %d bytes, stat says %d", n, dbFile.Size)
|
||||
}
|
||||
if os.Getenv("MINIO_ACCESS_KEY") != "" && os.Getenv("MINIO_SECRET_KEY") != "" {
|
||||
var restBuf bytes.Buffer
|
||||
rn, err := rest.Download(ctx, fileID, &restBuf)
|
||||
if err != nil {
|
||||
t.Fatalf("rest Download(%s): %v", fileID, err)
|
||||
}
|
||||
if rn != n || !bytes.Equal(restBuf.Bytes(), buf.Bytes()) {
|
||||
t.Errorf("download mismatch sql=%d rest=%d bytes", n, rn)
|
||||
}
|
||||
} else {
|
||||
t.Log("MINIO_ACCESS_KEY/MINIO_SECRET_KEY not set — REST download cross-check skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// testPGStoreReadOnly asserts that every write method returns ErrReadOnly.
|
||||
func testPGStoreReadOnly(ctx context.Context, s *pgStore) error {
|
||||
if _, err := s.CreateFolder(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) {
|
||||
return errors.New("CreateFolder did not return ErrReadOnly")
|
||||
}
|
||||
if _, err := s.Upload(ctx, "1", "x", strings.NewReader("x")); !errors.Is(err, ErrReadOnly) {
|
||||
return errors.New("Upload did not return ErrReadOnly")
|
||||
}
|
||||
if err := s.Move(ctx, []string{"1"}, "2"); !errors.Is(err, ErrReadOnly) {
|
||||
return errors.New("Move did not return ErrReadOnly")
|
||||
}
|
||||
if err := s.Copy(ctx, []string{"1"}, "2"); !errors.Is(err, ErrReadOnly) {
|
||||
return errors.New("Copy did not return ErrReadOnly")
|
||||
}
|
||||
if err := s.Rename(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) {
|
||||
return errors.New("Rename did not return ErrReadOnly")
|
||||
}
|
||||
if err := s.Delete(ctx, []string{"1"}); !errors.Is(err, ErrReadOnly) {
|
||||
return errors.New("Delete did not return ErrReadOnly")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRebind(t *testing.T) {
|
||||
mysqlQuery := "SELECT id FROM files_file WHERE folder_id = ? AND title = ? LIMIT ?"
|
||||
if got := rebind(mysqlQuery, ProviderMySQL); got != mysqlQuery {
|
||||
t.Errorf("mysql query changed: %q", got)
|
||||
}
|
||||
want := "SELECT id FROM files_file WHERE folder_id = $1 AND title = $2 LIMIT $3"
|
||||
if got := rebind(mysqlQuery, ProviderPG); got != want {
|
||||
t.Errorf("rebind = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSPObjectKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
tenant int64
|
||||
fileID int64
|
||||
version int
|
||||
ext string
|
||||
want string
|
||||
}{
|
||||
{1, 2, 1, ".docx", "00/00/01/files/folder_1000/file_2/v1/content.docx"},
|
||||
{1, 999, 1, ".pdf", "00/00/01/files/folder_1000/file_999/v1/content.pdf"},
|
||||
{1, 1000, 1, ".xlsx", "00/00/01/files/folder_2000/file_1000/v1/content.xlsx"},
|
||||
{1, 3727, 1, ".pdf", "00/00/01/files/folder_4000/file_3727/v1/content.pdf"},
|
||||
{1, 22484, 1, ".PDF", "00/00/01/files/folder_23000/file_22484/v1/content.pdf"},
|
||||
{1, 4, 6, "xlsx", "00/00/01/files/folder_1000/file_4/v6/content.xlsx"},
|
||||
{0, 7, 0, "", "00/00/01/files/folder_1000/file_7/v1/content.bin"},
|
||||
{2, 11, 3, ".doc", "00/00/02/files/folder_1000/file_11/v3/content.doc"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := csObjectKey(tc.tenant, tc.fileID, tc.version, tc.ext); got != tc.want {
|
||||
t.Errorf("csObjectKey(%d,%d,%d,%q) = %q, want %q", tc.tenant, tc.fileID, tc.version, tc.ext, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGDriverDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
dsn, explicit, want string
|
||||
}{
|
||||
{"postgres://u:p@h:5432/onlyoffice", "", ProviderPG},
|
||||
{"postgresql://u:p@h/db", "", ProviderPG},
|
||||
{"host=h user=u password=p dbname=onlyoffice sslmode=disable", "", ProviderPG},
|
||||
{"root:secret@tcp(127.0.0.1:3306)/onlyoffice?parseTime=true", "", ProviderMySQL},
|
||||
{"mysql://root:secret@127.0.0.1:3306/onlyoffice", "", ProviderMySQL},
|
||||
{"root:secret@tcp(h:3306)/db", "postgres", ProviderPG},
|
||||
{"postgres://u:p@h/db", "mysql", ProviderMySQL},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := pgDriver(tc.dsn, tc.explicit); got != tc.want {
|
||||
t.Errorf("pgDriver(%q, %q) = %q, want %q", tc.dsn, tc.explicit, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSQLDSNMySQL(t *testing.T) {
|
||||
got, err := normalizeSQLDSN(ProviderMySQL, "mysql://root:secret@127.0.0.1:3306/onlyoffice")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeSQLDSN: %v", err)
|
||||
}
|
||||
want := "root:secret@tcp(127.0.0.1:3306)/onlyoffice?parseTime=true"
|
||||
if got != want {
|
||||
t.Errorf("normalize = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// A driver DSN keeps parseTime and gains it when missing.
|
||||
got, err = normalizeSQLDSN(ProviderMySQL, "root:secret@tcp(127.0.0.1:3306)/onlyoffice")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeSQLDSN: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("normalize = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRowToEntry(t *testing.T) {
|
||||
created := time.Date(2026, 9, 12, 18, 0, 37, 0, time.UTC)
|
||||
modified := time.Date(2026, 9, 13, 13, 50, 36, 0, time.UTC)
|
||||
e := fileRowToEntry(pgFileRow{
|
||||
id: 22484, folderID: 649, title: "Rechnung.pdf",
|
||||
size: 123433, version: 2, created: created, modified: modified,
|
||||
}, ProviderMySQL)
|
||||
if e.ID != "22484" || e.ParentID != "649" {
|
||||
t.Errorf("ids = %q/%q", e.ID, e.ParentID)
|
||||
}
|
||||
if e.Title != "Rechnung.pdf" || e.Kind != File {
|
||||
t.Errorf("title/kind = %q/%v", e.Title, e.Kind)
|
||||
}
|
||||
if e.Size != 123433 || e.Version != 2 {
|
||||
t.Errorf("size/version = %d/%d", e.Size, e.Version)
|
||||
}
|
||||
if e.MIME != "application/pdf" {
|
||||
t.Errorf("mime = %q", e.MIME)
|
||||
}
|
||||
if !e.Created.Equal(created) || !e.Modified.Equal(modified) {
|
||||
t.Errorf("times = %v/%v", e.Created, e.Modified)
|
||||
}
|
||||
if e.Provider != ProviderMySQL {
|
||||
t.Errorf("provider = %q", e.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderRowToEntry(t *testing.T) {
|
||||
modified := time.Date(2026, 8, 1, 10, 30, 0, 0, time.UTC)
|
||||
e := folderRowToEntry(pgFolderRow{id: 649, parentID: 647, title: "2025", modified: modified}, ProviderMySQL)
|
||||
if e.ID != "649" || e.ParentID != "647" || e.Title != "2025" {
|
||||
t.Errorf("folder = %+v", e)
|
||||
}
|
||||
if e.Kind != Folder {
|
||||
t.Errorf("kind = %v, want folder", e.Kind)
|
||||
}
|
||||
if e.Size != 0 || e.MIME != "" {
|
||||
t.Errorf("folder size/mime = %d/%q", e.Size, e.MIME)
|
||||
}
|
||||
if !e.Modified.Equal(modified) {
|
||||
t.Errorf("modified = %v", e.Modified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGStoreWriteMethodsReadOnly(t *testing.T) {
|
||||
s := &pgStore{driver: ProviderPG}
|
||||
ctx := context.Background()
|
||||
if _, err := s.CreateFolder(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) {
|
||||
t.Errorf("CreateFolder err = %v", err)
|
||||
}
|
||||
if _, err := s.Upload(ctx, "1", "x", nil); !errors.Is(err, ErrReadOnly) {
|
||||
t.Errorf("Upload err = %v", err)
|
||||
}
|
||||
if err := s.Move(ctx, nil, "1"); !errors.Is(err, ErrReadOnly) {
|
||||
t.Errorf("Move err = %v", err)
|
||||
}
|
||||
if err := s.Copy(ctx, nil, "1"); !errors.Is(err, ErrReadOnly) {
|
||||
t.Errorf("Copy err = %v", err)
|
||||
}
|
||||
if err := s.Rename(ctx, "1", "x"); !errors.Is(err, ErrReadOnly) {
|
||||
t.Errorf("Rename err = %v", err)
|
||||
}
|
||||
if err := s.Delete(ctx, nil); !errors.Is(err, ErrReadOnly) {
|
||||
t.Errorf("Delete err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGStoreName(t *testing.T) {
|
||||
if got := (&pgStore{driver: ProviderPG}).Name(); got != ProviderPG {
|
||||
t.Errorf("Name = %q, want %q", got, ProviderPG)
|
||||
}
|
||||
if got := (&pgStore{driver: ProviderMySQL}).Name(); got != ProviderMySQL {
|
||||
t.Errorf("Name = %q, want %q", got, ProviderMySQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGStoreStatRejectsNonNumeric(t *testing.T) {
|
||||
s := &pgStore{driver: ProviderPG}
|
||||
if _, err := s.Stat(context.Background(), "not-a-number"); err == nil {
|
||||
t.Error("Stat accepted a non-numeric id")
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -3,9 +3,10 @@ package onlyoffice
|
||||
// Text extraction pipeline for the own full-text index (epic #34, F6 #42).
|
||||
//
|
||||
// TextIndexer downloads stored documents, extracts text through docpipe
|
||||
// (pdftotext; OCR for scans) and writes the result to a TextIndex. It is the
|
||||
// write side of ESTextIndex and never touches the OnlyOffice server's own ES
|
||||
// index.
|
||||
// (pdftotext; OCR for scans) and writes the result to a TextIndex. For PDFs it
|
||||
// also indexes the text of embedded attachments (pdfdetach), so a scan filed
|
||||
// as an attachment is searchable too. It is the write side of ESTextIndex and
|
||||
// never touches the OnlyOffice server's own ES index.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -38,13 +39,14 @@ type TextExtractor interface {
|
||||
type docpipeExtractor struct{ tools docpipe.Tools }
|
||||
|
||||
// Extract renders the file as Markdown, OCRing PDFs/images with a weak text
|
||||
// layer first (docpipe.ToMarkdown).
|
||||
// layer first and appending the text of embedded PDF attachments
|
||||
// (docpipe.ToMarkdownWithAttachments).
|
||||
func (d docpipeExtractor) Extract(path, workDir, lang string, minChars int) (string, error) {
|
||||
res, err := d.tools.ToMarkdown(path, workDir, lang, minChars)
|
||||
text, err := d.tools.ToMarkdownWithAttachments(path, workDir, lang, minChars)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(res.Markdown), nil
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
// IndexOptions controls a TextIndexer run.
|
||||
|
||||
@@ -247,6 +247,8 @@ func (c *Client) UploadProjectFileReplacing(ctx context.Context, projectID, loca
|
||||
}
|
||||
|
||||
// GetFile returns file metadata including viewUrl for download.
|
||||
//
|
||||
// Deprecated: use FileStore.Stat via Client.Files()/Client.FileStore.
|
||||
func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) {
|
||||
if fileID == "" {
|
||||
return nil, fmt.Errorf("file id is required")
|
||||
@@ -260,6 +262,8 @@ func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error)
|
||||
}
|
||||
|
||||
// RenameFile sets a new title (including extension) for the file.
|
||||
//
|
||||
// Deprecated: use FileStore.Rename via Client.Files()/Client.FileStore.
|
||||
func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*FileEntry, error) {
|
||||
if fileID == "" || newTitle == "" {
|
||||
return nil, fmt.Errorf("file id and new title are required")
|
||||
@@ -275,6 +279,8 @@ func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*File
|
||||
// DeleteFiles permanently deletes files by numeric id (Documents module).
|
||||
// Uses per-file DELETE (DeleteDavItems); fileops/delete returns 200 on some
|
||||
// portals (e.g. produktor.io) without removing the file.
|
||||
//
|
||||
// Deprecated: use FileStore.Delete via Client.Files()/Client.FileStore.
|
||||
func (c *Client) DeleteFiles(ctx context.Context, fileIDs []int) error {
|
||||
if len(fileIDs) == 0 {
|
||||
return fmt.Errorf("no file ids to delete")
|
||||
@@ -288,6 +294,8 @@ func (c *Client) DeleteFiles(ctx context.Context, fileIDs []int) error {
|
||||
|
||||
// ListFolder returns the Documents module listing for a folder id
|
||||
// (GET /api/2.0/files/{folderId}).
|
||||
//
|
||||
// Deprecated: use FileStore.List via Client.Files()/Client.FileStore.
|
||||
func (c *Client) ListFolder(ctx context.Context, folderID string) (map[string]any, error) {
|
||||
if folderID == "" {
|
||||
return nil, fmt.Errorf("folder id is required")
|
||||
@@ -313,6 +321,8 @@ func (c *Client) CreateFolder(ctx context.Context, parentFolderID, title string)
|
||||
}
|
||||
|
||||
// MoveFiles moves file ids into destFolderID (Documents fileops/move).
|
||||
//
|
||||
// Deprecated: use FileStore.Move via Client.Files()/Client.FileStore.
|
||||
func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int) (map[string]any, error) {
|
||||
if destFolderID == 0 || len(fileIDs) == 0 {
|
||||
return nil, fmt.Errorf("dest folder and file ids are required")
|
||||
@@ -343,6 +353,8 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int)
|
||||
}
|
||||
|
||||
// UploadToFolder uploads a local file into an arbitrary Documents folder id.
|
||||
//
|
||||
// Deprecated: use FileStore.Upload via Client.Files()/Client.FileStore.
|
||||
func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string) (*FileEntry, error) {
|
||||
if folderID == "" || localPath == "" {
|
||||
return nil, fmt.Errorf("folder id and local path are required")
|
||||
@@ -400,6 +412,8 @@ func FileFolderID(f *FileEntry) string {
|
||||
// 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).
|
||||
//
|
||||
// Deprecated: use FileStore.Download via Client.Files()/Client.FileStore.
|
||||
func (c *Client) DownloadFile(ctx context.Context, fileID string, dst io.Writer) (int64, error) {
|
||||
f, err := c.GetFile(ctx, fileID)
|
||||
if err != nil {
|
||||
|
||||
@@ -52,6 +52,8 @@ type DavListing struct {
|
||||
|
||||
// ListDavFolder returns the contents of a folder by id, which may be a
|
||||
// symbolic root such as "@my". For "@root" use ListDavSections.
|
||||
//
|
||||
// Deprecated: use FileStore.List via Client.Files()/Client.FileStore.
|
||||
func (c *Client) ListDavFolder(ctx context.Context, id string) (*DavListing, error) {
|
||||
raw, err := c.getJSON(ctx, "/api/2.0/files/"+url.PathEscape(id))
|
||||
if err != nil {
|
||||
@@ -111,6 +113,8 @@ func (c *Client) ListDavSections(ctx context.Context) ([]DavFolder, error) {
|
||||
}
|
||||
|
||||
// CreateDavFolder creates a folder titled title inside parentID.
|
||||
//
|
||||
// Deprecated: use FileStore.CreateFolder via Client.Files()/Client.FileStore.
|
||||
func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*DavFolder, error) {
|
||||
raw, err := c.postJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentID),
|
||||
map[string]string{"title": title})
|
||||
@@ -130,6 +134,8 @@ func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*
|
||||
}
|
||||
|
||||
// RenameDavFolder renames a folder.
|
||||
//
|
||||
// Deprecated: use FileStore.Rename via Client.Files()/Client.FileStore.
|
||||
func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error {
|
||||
_, err := c.putJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id),
|
||||
map[string]string{"title": title})
|
||||
@@ -137,6 +143,8 @@ func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error {
|
||||
}
|
||||
|
||||
// RenameDavFile renames a file (title includes the extension).
|
||||
//
|
||||
// Deprecated: use FileStore.Rename via Client.Files()/Client.FileStore.
|
||||
func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
|
||||
_, err := c.putJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id),
|
||||
map[string]string{"title": title})
|
||||
@@ -147,6 +155,8 @@ func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
|
||||
// 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.
|
||||
//
|
||||
// Deprecated: use FileStore.Move via Client.Files()/Client.FileStore.
|
||||
func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
|
||||
"folderIds": nums(folderIDs),
|
||||
@@ -163,6 +173,8 @@ func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string,
|
||||
|
||||
// CopyDavItems copies the given folders and/or files into destFolderID.
|
||||
// Per-operation errors are surfaced like in MoveDavItems.
|
||||
//
|
||||
// Deprecated: use FileStore.Copy via Client.Files()/Client.FileStore.
|
||||
func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
|
||||
"folderIds": nums(folderIDs),
|
||||
@@ -226,6 +238,8 @@ func fileopsError(raw json.RawMessage) error {
|
||||
}
|
||||
|
||||
// DeleteDavItems deletes the given folders and/or files.
|
||||
//
|
||||
// Deprecated: use FileStore.Delete via Client.Files()/Client.FileStore.
|
||||
func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error {
|
||||
body := map[string]any{"DeleteAfter": true, "Immediately": true}
|
||||
for _, id := range folderIDs {
|
||||
@@ -242,6 +256,8 @@ func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string
|
||||
}
|
||||
|
||||
// UploadDavFile uploads src (fileName) into folderID, streaming from src.
|
||||
//
|
||||
// Deprecated: use FileStore.Upload via Client.Files()/Client.FileStore.
|
||||
func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, src io.Reader) (*DavFile, error) {
|
||||
raw, err := c.uploadReader(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+"/upload", "file", fileName, src)
|
||||
if err != nil {
|
||||
@@ -261,6 +277,8 @@ func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, s
|
||||
|
||||
// DownloadDavFile streams the file identified by id to w, returning bytes
|
||||
// copied. It shares the MinIO stale-S3 fallback with DownloadFile.
|
||||
//
|
||||
// Deprecated: use FileStore.Download via Client.Files()/Client.FileStore.
|
||||
func (c *Client) DownloadDavFile(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||
return c.DownloadFile(ctx, id, w)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ require (
|
||||
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3
|
||||
github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002
|
||||
github.com/eslider/go-xls/v2 v2.1.0
|
||||
github.com/go-sql-driver/mysql v1.10.1
|
||||
github.com/google/go-querystring v1.2.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-runewidth v0.0.15
|
||||
github.com/muesli/termenv v0.16.0
|
||||
@@ -24,6 +26,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
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
|
||||
@@ -36,6 +39,10 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -48,6 +55,7 @@ require (
|
||||
github.com/richardlehane/mscfb v1.0.7 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
|
||||
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
||||
@@ -35,6 +37,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
@@ -47,6 +51,8 @@ github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002 h1:LOFxQG4mxvlH7
|
||||
github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002/go.mod h1:fIgfH/E1j3rU8du4X4+7mxTD0GPtPQibTzytgitdJWU=
|
||||
github.com/eslider/go-xls/v2 v2.1.0 h1:HszWKqYQbXxACmAXXWdMsfNl1NDBfGVBnJUPtyUHQ7A=
|
||||
github.com/eslider/go-xls/v2 v2.1.0/go.mod h1:xgxO6JrfuBr9jGUB+0z5l/yDmFFZ5diGk0ATGihxlMU=
|
||||
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
|
||||
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
@@ -63,8 +69,20 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
@@ -98,6 +116,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
|
||||
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
@@ -107,6 +127,9 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
@@ -142,8 +165,10 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// - pandoc — md↔docx
|
||||
// - ocrmypdf — OCR into a searchable PDF
|
||||
// - pdftotext — extract text layer
|
||||
// - pdfdetach — list/save embedded PDF attachments
|
||||
// - tesseract — OCR single images when ocrmypdf is unsuitable
|
||||
// - ghostscript (gs) — PDF rewrite/optimize via PostScript (pdfwrite)
|
||||
package docpipe
|
||||
@@ -26,6 +27,7 @@ type Tools struct {
|
||||
Pandoc string
|
||||
OCRMyPDF string
|
||||
PDFToText string
|
||||
PDFDetach string
|
||||
Tesseract string
|
||||
Ghostscript string
|
||||
}
|
||||
@@ -44,6 +46,7 @@ func LookPath() Tools {
|
||||
Pandoc: find("pandoc"),
|
||||
OCRMyPDF: find("ocrmypdf"),
|
||||
PDFToText: find("pdftotext"),
|
||||
PDFDetach: find("pdfdetach"),
|
||||
Tesseract: find("tesseract"),
|
||||
Ghostscript: find("gs", "ghostscript"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package docpipe
|
||||
|
||||
// Embedded PDF attachments (F6 #42). Digitised invoices often carry the
|
||||
// original scan as a PDF attachment; the searchable body may hold only a
|
||||
// summary. pdfdetach (poppler) lists/saves them; each saved attachment is run
|
||||
// through the normal docpipe extraction (pdftotext/OCR).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PDFAttachment is one embedded file in a PDF.
|
||||
type PDFAttachment struct {
|
||||
Index int // 1-based number as `pdfdetach -list` reports it
|
||||
Name string // embedded file name
|
||||
}
|
||||
|
||||
// AttachmentText is the extracted text of one embedded attachment.
|
||||
type AttachmentText struct {
|
||||
Name string
|
||||
Text string
|
||||
}
|
||||
|
||||
// parseAttachmentList parses `pdfdetach -list` output. The first line is a
|
||||
// count ("N embedded files"); every following line is "<index>: <name>".
|
||||
// Pure, so it is unit-tested.
|
||||
func parseAttachmentList(out string) []PDFAttachment {
|
||||
var atts []PDFAttachment
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
colon := strings.Index(line, ":")
|
||||
if colon <= 0 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(line[:colon]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(line[colon+1:])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
atts = append(atts, PDFAttachment{Index: n, Name: name})
|
||||
}
|
||||
return atts
|
||||
}
|
||||
|
||||
// safeAttachmentName strips directories and leading dots so a hostile
|
||||
// attachment name cannot escape the extraction directory.
|
||||
func safeAttachmentName(name string) string {
|
||||
name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
|
||||
name = filepath.Base(name)
|
||||
name = strings.TrimLeft(name, ".")
|
||||
if name == "" || name == "." || name == "/" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// JoinWithAttachments appends attachment text to the document body, each
|
||||
// section preceded by an "[attachment: <name>]" marker so a search hit shows
|
||||
// its source. Empty attachments are skipped. Pure, so it is unit-tested.
|
||||
func JoinWithAttachments(body string, atts []AttachmentText) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(strings.TrimRight(body, "\n"))
|
||||
for _, a := range atts {
|
||||
text := strings.TrimSpace(a.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("\n\n[attachment: ")
|
||||
b.WriteString(a.Name)
|
||||
b.WriteString("]\n\n")
|
||||
b.WriteString(text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ListAttachments returns the embedded files of a PDF. A PDF without
|
||||
// attachments yields an empty slice and no error.
|
||||
func (t Tools) ListAttachments(pdfPath string) ([]PDFAttachment, error) {
|
||||
if t.PDFDetach == "" {
|
||||
return nil, fmt.Errorf("pdfdetach not found on PATH")
|
||||
}
|
||||
cmd := exec.Command(t.PDFDetach, "-list", pdfPath)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pdfdetach -list %s: %w (%s)", filepath.Base(pdfPath), err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return parseAttachmentList(string(out)), nil
|
||||
}
|
||||
|
||||
// SaveAttachment writes the n-th embedded file (1-based) to outPath.
|
||||
func (t Tools) SaveAttachment(pdfPath string, index int, outPath string) error {
|
||||
if t.PDFDetach == "" {
|
||||
return fmt.Errorf("pdfdetach not found on PATH")
|
||||
}
|
||||
if strings.TrimSpace(outPath) == "" {
|
||||
return fmt.Errorf("output path required")
|
||||
}
|
||||
if err := EnsureDir(outPath); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(t.PDFDetach, "-save", strconv.Itoa(index), "-o", outPath, pdfPath)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("pdfdetach -save %d: %w (%s)", index, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToMarkdownWithAttachments extracts the file as ToMarkdown does, then — for
|
||||
// PDFs — appends the text of every embedded attachment under an
|
||||
// "[attachment: <name>]" marker. Attachment failures are non-fatal: the body
|
||||
// is returned unchanged.
|
||||
func (t Tools) ToMarkdownWithAttachments(path, workDir, lang string, minChars int) (string, error) {
|
||||
res, err := t.ToMarkdown(path, workDir, lang, minChars)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if Ext(path) != ".pdf" {
|
||||
return res.Markdown, nil
|
||||
}
|
||||
atts, err := t.attachmentTexts(path, workDir, lang, minChars)
|
||||
if err != nil {
|
||||
return res.Markdown, nil
|
||||
}
|
||||
return JoinWithAttachments(res.Markdown, atts), nil
|
||||
}
|
||||
|
||||
// attachmentTexts saves and extracts every embedded attachment, skipping the
|
||||
// ones that cannot be read. It returns an error only when the attachment list
|
||||
// itself cannot be obtained.
|
||||
func (t Tools) attachmentTexts(pdfPath, workDir, lang string, minChars int) ([]AttachmentText, error) {
|
||||
list, err := t.ListAttachments(pdfPath)
|
||||
if err != nil || len(list) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
if workDir == "" {
|
||||
workDir = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(workDir, "att-"+trimExt(filepath.Base(pdfPath)))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
out := make([]AttachmentText, 0, len(list))
|
||||
for _, a := range list {
|
||||
name := safeAttachmentName(a.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
saved := filepath.Join(dir, fmt.Sprintf("%d-%s", a.Index, name))
|
||||
if err := t.SaveAttachment(pdfPath, a.Index, saved); err != nil {
|
||||
continue
|
||||
}
|
||||
text, err := t.attachmentMarkdown(saved, dir, lang, minChars)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, AttachmentText{Name: a.Name, Text: text})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachmentMarkdown extracts a saved attachment with the regular pipeline.
|
||||
// Structured attachments that docpipe does not convert (e-invoice XML,
|
||||
// CuraSoft JSON, CSV/HTML) fall back to their text content, so the embedded
|
||||
// original is still searchable. Other unreadable formats return an error and
|
||||
// the caller skips them.
|
||||
func (t Tools) attachmentMarkdown(path, workDir, lang string, minChars int) (string, error) {
|
||||
if res, err := t.ToMarkdown(path, workDir, lang, minChars); err == nil {
|
||||
return res.Markdown, nil
|
||||
}
|
||||
switch Ext(path) {
|
||||
case ".xml", ".html", ".htm":
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return xmlToText(raw), nil
|
||||
case ".json", ".csv":
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported attachment type %q", Ext(path))
|
||||
}
|
||||
}
|
||||
|
||||
// xmlToText returns the character data of an XML/HTML document: element text
|
||||
// values with decoded entities, one per line. Used for invoice XML (EN 16931
|
||||
// CII / ZUGFeRD) and HTML attachments. Pure, so it is unit-tested.
|
||||
func xmlToText(raw []byte) string {
|
||||
dec := xml.NewDecoder(bytes.NewReader(raw))
|
||||
dec.Strict = false
|
||||
var b strings.Builder
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
cd, ok := tok.(xml.CharData)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(string(cd))
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(s)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package docpipe
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseAttachmentList(t *testing.T) {
|
||||
out := "2 embedded files\n1: original.pdf\n2: scan_001.png\n"
|
||||
got := parseAttachmentList(out)
|
||||
want := []PDFAttachment{{Index: 1, Name: "original.pdf"}, {Index: 2, Name: "scan_001.png"}}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("att[%d] = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAttachmentListEmptyAndMalformed(t *testing.T) {
|
||||
for _, in := range []string{"", "0 embedded files\n", "garbage\n\n \n"} {
|
||||
if got := parseAttachmentList(in); len(got) != 0 {
|
||||
t.Errorf("parseAttachmentList(%q) = %+v, want empty", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeAttachmentName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"note.txt": "note.txt",
|
||||
"../../evil.pdf": "evil.pdf",
|
||||
`..\..\evil.pdf`: "evil.pdf",
|
||||
"/abs/scan_001.pdf": "scan_001.pdf",
|
||||
".hidden": "hidden",
|
||||
" spaced name.txt ": "spaced name.txt",
|
||||
"..": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := safeAttachmentName(in); got != want {
|
||||
t.Errorf("safeAttachmentName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinWithAttachments(t *testing.T) {
|
||||
body := "# scan.pdf\n\nbody token\n"
|
||||
atts := []AttachmentText{
|
||||
{Name: "original.pdf", Text: " original token "},
|
||||
{Name: "empty.txt", Text: " "},
|
||||
}
|
||||
got := JoinWithAttachments(body, atts)
|
||||
if !strings.Contains(got, "body token") {
|
||||
t.Errorf("body text lost: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[attachment: original.pdf]") {
|
||||
t.Errorf("marker missing: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "original token") {
|
||||
t.Errorf("attachment text missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "empty.txt") {
|
||||
t.Errorf("empty attachment must be skipped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinWithAttachmentsNoAttachments(t *testing.T) {
|
||||
got := JoinWithAttachments("# a.pdf\n\ntext\n\n", nil)
|
||||
if got != "# a.pdf\n\ntext" {
|
||||
t.Errorf("got %q, want trimmed body only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAttachmentsWithoutTool(t *testing.T) {
|
||||
if _, err := (Tools{}).ListAttachments("x.pdf"); err == nil || !strings.Contains(err.Error(), "pdfdetach") {
|
||||
t.Fatalf("want pdfdetach error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToMarkdownWithAttachmentsFixture exercises the real pdfdetach + pdftotext
|
||||
// pipeline on testdata/pdf-with-attachment.pdf (body token + embedded
|
||||
// goo-note.txt). Skips when poppler is not installed.
|
||||
func TestToMarkdownWithAttachmentsFixture(t *testing.T) {
|
||||
tools := LookPath()
|
||||
if tools.PDFDetach == "" || tools.PDFToText == "" {
|
||||
t.Skip("pdfdetach/pdftotext not on PATH — skipping attachment extraction test")
|
||||
}
|
||||
fixture := filepath.Join("..", "..", "testdata", "pdf-with-attachment.pdf")
|
||||
got, err := tools.ToMarkdownWithAttachments(fixture, t.TempDir(), "eng", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ToMarkdownWithAttachments: %v", err)
|
||||
}
|
||||
for _, want := range []string{"goobodytoken", "[attachment: goo-note.txt]", "gooattachmenttoken"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("result missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXMLToText(t *testing.T) {
|
||||
raw := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice><rsm:ExchangedDocument>
|
||||
<ram:ID>S1063</ram:ID></rsm:ExchangedDocument>
|
||||
<ram:Name>Edelweiss & Co</ram:Name><ram:GrandTotalAmount>42.00</ram:GrandTotalAmount>
|
||||
</rsm:CrossIndustryInvoice>`)
|
||||
got := xmlToText(raw)
|
||||
for _, want := range []string{"S1063", "Edelweiss & Co", "42.00"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("xmlToText missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(got, "<>") {
|
||||
t.Errorf("xmlToText left markup: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttachmentMarkdownFallback verifies structured attachments that docpipe
|
||||
// cannot convert are still reduced to searchable text, and unknown binary
|
||||
// formats error (so the caller skips them).
|
||||
func TestAttachmentMarkdownFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
xmlPath := filepath.Join(dir, "factur-x.xml")
|
||||
if err := os.WriteFile(xmlPath, []byte(`<Invoice><Number>S1063</Number></Invoice>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := (Tools{}).attachmentMarkdown(xmlPath, dir, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("attachmentMarkdown(xml): %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "S1063") {
|
||||
t.Errorf("xml attachment text = %q, want S1063", got)
|
||||
}
|
||||
|
||||
binPath := filepath.Join(dir, "data.bin")
|
||||
if err := os.WriteFile(binPath, []byte{0, 1, 2, 3}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (Tools{}).attachmentMarkdown(binPath, dir, "", 0); err == nil {
|
||||
t.Error("unsupported attachment: want error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToMarkdownWithAttachmentsPlainPDF ensures a PDF without attachments
|
||||
// returns just the body (pdfdetach prints "0 embedded files").
|
||||
func TestToMarkdownWithAttachmentsPlainPDF(t *testing.T) {
|
||||
tools := LookPath()
|
||||
if tools.PDFDetach == "" || tools.PDFToText == "" {
|
||||
t.Skip("pdfdetach/pdftotext not on PATH")
|
||||
}
|
||||
// The fixture itself is a PDF with one attachment; strip it by extracting
|
||||
// the body only through ToMarkdown and compare JoinWithAttachments(nil).
|
||||
res, err := tools.ToMarkdown(filepath.Join("..", "..", "testdata", "pdf-with-attachment.pdf"), t.TempDir(), "eng", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ToMarkdown: %v", err)
|
||||
}
|
||||
if strings.Contains(res.Markdown, "gooattachmenttoken") {
|
||||
t.Fatalf("body must not contain attachment text: %q", res.Markdown)
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -157,6 +157,13 @@ func (c *Client) downloadFileEntry(ctx context.Context, f *FileEntry, dst io.Wri
|
||||
|
||||
// downloadFromMinio streams objectKey from the configured MinIO bucket.
|
||||
func (c *Client) downloadFromMinio(ctx context.Context, objectKey string, dst io.Writer) (int64, error) {
|
||||
return downloadMinioObject(ctx, c.client, objectKey, dst)
|
||||
}
|
||||
|
||||
// downloadMinioObject streams objectKey from the configured MinIO bucket using
|
||||
// hc. It is the shared implementation behind (*Client).downloadFromMinio and
|
||||
// the read-only SQL store (file_pg.go), which has no *Client.
|
||||
func downloadMinioObject(ctx context.Context, hc *http.Client, objectKey string, dst io.Writer) (int64, error) {
|
||||
if objectKey == "" {
|
||||
return 0, fmt.Errorf("onlyoffice: minio fallback: empty object key")
|
||||
}
|
||||
@@ -177,7 +184,7 @@ func (c *Client) downloadFromMinio(ctx context.Context, objectKey string, dst io
|
||||
if err := signMinioRequest(ctx, cfg, req); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("onlyoffice: minio fallback: %w", err)
|
||||
}
|
||||
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
%PDF-1.7
|
||||
%Çì�¢
|
||||
%%Invocation: gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite ? ? ?
|
||||
5 0 obj
|
||||
<</Length 6 0 R/Filter /FlateDecode>>
|
||||
stream
|
||||
xœ-ŠA
|
||||
ƒ0÷ÿoÙnìO7q]èZ*ÿ�IˆmJ½½M
|
||||
³f7
|
||||
\¨6‘žfRÿŠ*ñºõª…8:g}‡f†Dºø”ÞiØsúØ ÝôÝ;炱(Ùn.ly]ìUFz
|
||||
½~Aë øendstream
|
||||
endobj
|
||||
6 0 obj
|
||||
110
|
||||
endobj
|
||||
4 0 obj
|
||||
<</Type/Page/MediaBox [0 0 612 792]
|
||||
/Rotate 0/Parent 3 0 R
|
||||
/Resources<</ProcSet[/PDF /Text]
|
||||
/Font 8 0 R
|
||||
>>
|
||||
/Contents 5 0 R
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Pages /Kids [
|
||||
4 0 R
|
||||
] /Count 1
|
||||
>>
|
||||
endobj
|
||||
1 0 obj
|
||||
<</Type /Catalog /Pages 3 0 R
|
||||
/Metadata 9 0 R
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<</R7
|
||||
7 0 R>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Helvetica/Type/Font
|
||||
/Subtype/Type1>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<</Type/Metadata
|
||||
/Subtype/XML/Length 1173>>stream
|
||||
<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
|
||||
<?adobe-xap-filters esc="CRLF"?>
|
||||
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='XMP toolkit 2.9.1-13, framework 1.6'>
|
||||
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:iX='http://ns.adobe.com/iX/1.0/'>
|
||||
<rdf:Description rdf:about="" xmlns:pdf='http://ns.adobe.com/pdf/1.3/' pdf:Producer='GPL Ghostscript 10.02.1'/>
|
||||
<rdf:Description rdf:about="" xmlns:xmp='http://ns.adobe.com/xap/1.0/'><xmp:ModifyDate>2026-09-16T17:29:12Z</xmp:ModifyDate>
|
||||
<xmp:CreateDate>2026-09-16T17:29:12Z</xmp:CreateDate>
|
||||
<xmp:CreatorTool>UnknownApplication</xmp:CreatorTool></rdf:Description>
|
||||
<rdf:Description rdf:about="" xmlns:xapMM='http://ns.adobe.com/xap/1.0/mm/' xapMM:DocumentID='uuid:ae8d7575-ea10-11fc-0000-3a98742e0b71'/>
|
||||
<rdf:Description rdf:about="" xmlns:dc='http://purl.org/dc/elements/1.1/' dc:format='application/pdf'><dc:title><rdf:Alt><rdf:li xml:lang='x-default'>Untitled</rdf:li></rdf:Alt></dc:title></rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
||||
|
||||
<?xpacket end='w'?>
|
||||
endstream
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Producer(GPL Ghostscript 10.02.1)
|
||||
/CreationDate(D:20260916172912Z00'00')
|
||||
/ModDate(D:20260916172912Z00'00')>>endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000476 00000 n
|
||||
0000001882 00000 n
|
||||
0000000417 00000 n
|
||||
0000000276 00000 n
|
||||
0000000077 00000 n
|
||||
0000000257 00000 n
|
||||
0000000569 00000 n
|
||||
0000000540 00000 n
|
||||
0000000633 00000 n
|
||||
trailer
|
||||
<< /Size 10 /Root 1 0 R /Info 2 0 R
|
||||
/ID [<5A562476FBF11852133AA873452909F9><5A562476FBF11852133AA873452909F9>]
|
||||
>>
|
||||
startxref
|
||||
2008
|
||||
%%EOF
|
||||
1 0 obj
|
||||
<</Type /Catalog /Pages 3 0 R /Metadata 9 0 R /Names <</EmbeddedFiles 12 0 R >> >>
|
||||
endobj
|
||||
10 0 obj
|
||||
<</Length 44 /Params <</Size 44 >> >> stream
|
||||
gooattachmenttoken embedded attachment text
|
||||
|
||||
endstream
|
||||
|
||||
endobj
|
||||
11 0 obj
|
||||
<</Type /Filespec /UF <feff0067006f006f002d006e006f00740065002e007400780074> /EF <</F 10 0 R >> >>
|
||||
endobj
|
||||
12 0 obj
|
||||
<</Names [<feff0067006f006f002d006e006f00740065002e007400780074> 11 0 R ] >>
|
||||
endobj
|
||||
xref
|
||||
0 2
|
||||
0000000002 65535 f
|
||||
0000002361 00000 n
|
||||
10 3
|
||||
0000002463 00000 n
|
||||
0000002586 00000 n
|
||||
0000002705 00000 n
|
||||
trailer
|
||||
<</Size 13 /ID [(ZV$vûñR:¨sE\) ù) (P…õrˆ‚ Åð¡Ï) ] /Root 1 0 R /Prev 2008 /Info 2 0 R >>
|
||||
startxref
|
||||
2802
|
||||
Reference in New Issue
Block a user