diff --git a/.env.example b/.env.example index d5f5b5e..df586af 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,17 @@ 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 diff --git a/docs/community-server-db.md b/docs/community-server-db.md new file mode 100644 index 0000000..56d4104 --- /dev/null +++ b/docs/community-server-db.md @@ -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:@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//files/folder_/file_/v/content. +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:@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)). diff --git a/file_pg.go b/file_pg.go new file mode 100644 index 0000000..d78cd8c --- /dev/null +++ b/file_pg.go @@ -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//files/folder_/file_/v/content. +// +// 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 +} diff --git a/file_pg_integration_test.go b/file_pg_integration_test.go new file mode 100644 index 0000000..aa725de --- /dev/null +++ b/file_pg_integration_test.go @@ -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 +} diff --git a/file_pg_test.go b/file_pg_test.go new file mode 100644 index 0000000..8f8d168 --- /dev/null +++ b/file_pg_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index e988b59..1c7a882 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0bef785..a51dcf0 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/storage_fallback.go b/storage_fallback.go index 1d5db52..e29e178 100644 --- a/storage_fallback.go +++ b/storage_fallback.go @@ -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) }