Automatic Merge
Этот коммит содержится в:
@@ -215,6 +215,7 @@
|
|||||||
"Directory": "./data/",
|
"Directory": "./data/",
|
||||||
"EnablePublicLink": false,
|
"EnablePublicLink": false,
|
||||||
"ExtractContent": true,
|
"ExtractContent": true,
|
||||||
|
"ExtractContentTimeout": 10,
|
||||||
"ArchiveRecursion": false,
|
"ArchiveRecursion": false,
|
||||||
"PublicLinkSalt": "",
|
"PublicLinkSalt": "",
|
||||||
"InitialFont": "nunito-bold.ttf",
|
"InitialFont": "nunito-bold.ttf",
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ const defaultServerConfig: AdminConfig = {
|
|||||||
Directory: './data/',
|
Directory: './data/',
|
||||||
EnablePublicLink: false,
|
EnablePublicLink: false,
|
||||||
ExtractContent: true,
|
ExtractContent: true,
|
||||||
|
ExtractContentTimeout: 10,
|
||||||
ArchiveRecursion: false,
|
ArchiveRecursion: false,
|
||||||
PublicLinkSalt: '',
|
PublicLinkSalt: '',
|
||||||
InitialFont: 'nunito-bold.ttf',
|
InitialFont: 'nunito-bold.ttf',
|
||||||
|
|||||||
@@ -858,12 +858,14 @@ func (a *App) UploadFileX(c request.CTX, channelID, name string, input io.Reader
|
|||||||
|
|
||||||
if *a.Config().FileSettings.ExtractContent && t.ExtractContent {
|
if *a.Config().FileSettings.ExtractContent && t.ExtractContent {
|
||||||
infoCopy := *t.fileinfo
|
infoCopy := *t.fileinfo
|
||||||
a.Srv().GoBuffered(func() {
|
if !a.Srv().GoExtraction(func() {
|
||||||
err := a.ExtractContentFromFileInfo(c, &infoCopy)
|
err := a.ExtractContentFromFileInfo(c, &infoCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Error("Failed to extract file content", mlog.Err(err), mlog.String("fileInfoId", infoCopy.Id))
|
c.Logger().Error("Failed to extract file content", mlog.Err(err), mlog.String("fileInfoId", infoCopy.Id))
|
||||||
}
|
}
|
||||||
})
|
}) {
|
||||||
|
c.Logger().Warn("Content extraction queue is full, skipping inline extraction; this file's content will not be searchable until an admin runs a content extraction job (e.g. mmctl extract)", mlog.String("fileInfoId", infoCopy.Id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.fileinfo, nil
|
return t.fileinfo, nil
|
||||||
@@ -1125,12 +1127,14 @@ func (a *App) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTe
|
|||||||
// and something we can do without.
|
// and something we can do without.
|
||||||
if *a.Config().FileSettings.ExtractContent && extractContent {
|
if *a.Config().FileSettings.ExtractContent && extractContent {
|
||||||
infoCopy := *info
|
infoCopy := *info
|
||||||
a.Srv().GoBuffered(func() {
|
if !a.Srv().GoExtraction(func() {
|
||||||
err := a.ExtractContentFromFileInfo(c, &infoCopy)
|
err := a.ExtractContentFromFileInfo(c, &infoCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Error("Failed to extract file content", mlog.Err(err), mlog.String("fileInfoId", infoCopy.Id))
|
c.Logger().Error("Failed to extract file content", mlog.Err(err), mlog.String("fileInfoId", infoCopy.Id))
|
||||||
}
|
}
|
||||||
})
|
}) {
|
||||||
|
c.Logger().Warn("Content extraction queue is full, skipping inline extraction; this file's content will not be searchable until an admin runs a content extraction job (e.g. mmctl extract)", mlog.String("fileInfoId", infoCopy.Id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, data, nil
|
return info, data, nil
|
||||||
@@ -1584,10 +1588,15 @@ func (a *App) ExtractContentFromFileInfo(rctx request.CTX, fileInfo *model.FileI
|
|||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
return errors.Wrap(aerr, "failed to open file for extract file content")
|
return errors.Wrap(aerr, "failed to open file for extract file content")
|
||||||
}
|
}
|
||||||
defer file.Close()
|
// Ownership of closing the file is handed to docextractor.Extract via
|
||||||
|
// ReaderCloser: with a timeout configured, extraction may continue on a
|
||||||
|
// detached goroutine after Extract returns, so closing the file here would
|
||||||
|
// race with that goroutine still reading it.
|
||||||
text, err := docextractor.Extract(rctx.Logger(), fileInfo.Name, file, docextractor.ExtractSettings{
|
text, err := docextractor.Extract(rctx.Logger(), fileInfo.Name, file, docextractor.ExtractSettings{
|
||||||
ArchiveRecursion: *a.Config().FileSettings.ArchiveRecursion,
|
ArchiveRecursion: *a.Config().FileSettings.ArchiveRecursion,
|
||||||
MaxFileSize: *a.Config().FileSettings.MaxFileSize,
|
MaxFileSize: *a.Config().FileSettings.MaxFileSize,
|
||||||
|
Timeout: time.Duration(*a.Config().FileSettings.ExtractContentTimeout) * time.Second,
|
||||||
|
ReaderCloser: file,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to extract file content")
|
return errors.Wrap(err, "failed to extract file content")
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
import "sync/atomic"
|
import (
|
||||||
|
"runtime"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
// Go creates a goroutine, but maintains a record of it to ensure that execution completes before
|
// Go creates a goroutine, but maintains a record of it to ensure that execution completes before
|
||||||
// the server is shutdown.
|
// the server is shutdown.
|
||||||
@@ -45,3 +48,55 @@ func (ps *PlatformService) GoBuffered(f func()) {
|
|||||||
<-ps.goroutineBuffered
|
<-ps.goroutineBuffered
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startExtractionWorkers launches the fixed-size pool of workers that run
|
||||||
|
// document extraction tasks submitted through GoExtraction.
|
||||||
|
func (ps *PlatformService) startExtractionWorkers() {
|
||||||
|
numWorkers := runtime.NumCPU()
|
||||||
|
for range numWorkers {
|
||||||
|
ps.extractionWG.Go(func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ps.extractionStop:
|
||||||
|
return
|
||||||
|
case f := <-ps.extractionQueue:
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopExtractionWorkers signals the extraction workers to exit and waits for
|
||||||
|
// any in-flight extraction to finish. Queued-but-not-started tasks are drained
|
||||||
|
// and discarded so a worker cannot dequeue and run them after shutdown has been
|
||||||
|
// signaled.
|
||||||
|
func (ps *PlatformService) stopExtractionWorkers() {
|
||||||
|
close(ps.extractionStop)
|
||||||
|
|
||||||
|
drain:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ps.extractionQueue:
|
||||||
|
default:
|
||||||
|
break drain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.extractionWG.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoExtraction submits f to the bounded document extraction worker pool. It
|
||||||
|
// never blocks the caller: if every worker is busy and the queue is full it
|
||||||
|
// returns false without running f. Skipped files stay unextracted until an
|
||||||
|
// admin runs a content extraction job (e.g. mmctl extract); there is no
|
||||||
|
// scheduler that picks them up automatically. This keeps expensive extractions
|
||||||
|
// from stalling the request goroutines that dispatch them.
|
||||||
|
func (ps *PlatformService) GoExtraction(f func()) bool {
|
||||||
|
select {
|
||||||
|
case ps.extractionQueue <- f:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
79
server/channels/app/platform/goroutines_test.go
Обычный файл
79
server/channels/app/platform/goroutines_test.go
Обычный файл
@@ -0,0 +1,79 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoExtraction(t *testing.T) {
|
||||||
|
mainHelper.Parallel(t)
|
||||||
|
|
||||||
|
t.Run("runs submitted work on the pool", func(t *testing.T) {
|
||||||
|
const tasks = 5
|
||||||
|
ps := &PlatformService{
|
||||||
|
extractionQueue: make(chan func(), tasks),
|
||||||
|
extractionStop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
ps.startExtractionWorkers()
|
||||||
|
defer ps.stopExtractionWorkers()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(tasks)
|
||||||
|
for range tasks {
|
||||||
|
require.True(t, ps.GoExtraction(func() {
|
||||||
|
wg.Done()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
require.Fail(t, "submitted extraction tasks did not run")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("never blocks and skips work once the queue is saturated", func(t *testing.T) {
|
||||||
|
// No workers are started, so nothing drains the queue.
|
||||||
|
ps := &PlatformService{
|
||||||
|
extractionQueue: make(chan func(), 2),
|
||||||
|
extractionStop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, ps.GoExtraction(func() {}))
|
||||||
|
require.True(t, ps.GoExtraction(func() {}))
|
||||||
|
// The queue is now full; further submissions must be rejected rather
|
||||||
|
// than block the caller.
|
||||||
|
require.False(t, ps.GoExtraction(func() {}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stop waits for in-flight extraction to finish", func(t *testing.T) {
|
||||||
|
ps := &PlatformService{
|
||||||
|
extractionQueue: make(chan func(), 1),
|
||||||
|
extractionStop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
ps.startExtractionWorkers()
|
||||||
|
|
||||||
|
var finished bool
|
||||||
|
started := make(chan struct{})
|
||||||
|
require.True(t, ps.GoExtraction(func() {
|
||||||
|
close(started)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
finished = true
|
||||||
|
}))
|
||||||
|
|
||||||
|
<-started
|
||||||
|
ps.stopExtractionWorkers()
|
||||||
|
require.True(t, finished, "stopExtractionWorkers should wait for the running task to complete")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -107,6 +107,13 @@ type PlatformService struct {
|
|||||||
goroutineExitSignal chan struct{}
|
goroutineExitSignal chan struct{}
|
||||||
goroutineBuffered chan struct{}
|
goroutineBuffered chan struct{}
|
||||||
|
|
||||||
|
// Document content extraction runs on a dedicated, bounded worker pool so
|
||||||
|
// that expensive extractions cannot saturate the generic worker pool and
|
||||||
|
// block the request goroutines that dispatch them.
|
||||||
|
extractionQueue chan func()
|
||||||
|
extractionStop chan struct{}
|
||||||
|
extractionWG sync.WaitGroup
|
||||||
|
|
||||||
additionalClusterHandlers map[model.ClusterEvent]einterfaces.ClusterMessageHandler
|
additionalClusterHandlers map[model.ClusterEvent]einterfaces.ClusterMessageHandler
|
||||||
|
|
||||||
shareChannelServiceMux sync.RWMutex
|
shareChannelServiceMux sync.RWMutex
|
||||||
@@ -136,6 +143,8 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) {
|
|||||||
hashSeed: maphash.MakeSeed(),
|
hashSeed: maphash.MakeSeed(),
|
||||||
goroutineExitSignal: make(chan struct{}, 1),
|
goroutineExitSignal: make(chan struct{}, 1),
|
||||||
goroutineBuffered: make(chan struct{}, runtime.NumCPU()),
|
goroutineBuffered: make(chan struct{}, runtime.NumCPU()),
|
||||||
|
extractionQueue: make(chan func(), runtime.NumCPU()),
|
||||||
|
extractionStop: make(chan struct{}),
|
||||||
WebSocketRouter: &WebSocketRouter{
|
WebSocketRouter: &WebSocketRouter{
|
||||||
handlers: make(map[string]webSocketHandler),
|
handlers: make(map[string]webSocketHandler),
|
||||||
},
|
},
|
||||||
@@ -401,6 +410,8 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) {
|
|||||||
ps.searchConfigListenerId = searchConfigListenerId
|
ps.searchConfigListenerId = searchConfigListenerId
|
||||||
ps.searchLicenseListenerId = searchLicenseListenerId
|
ps.searchLicenseListenerId = searchLicenseListenerId
|
||||||
|
|
||||||
|
ps.startExtractionWorkers()
|
||||||
|
|
||||||
return ps, nil
|
return ps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +528,10 @@ func (ps *PlatformService) Shutdown() error {
|
|||||||
|
|
||||||
ps.RemoveLicenseListener(ps.licenseListenerId)
|
ps.RemoveLicenseListener(ps.licenseListenerId)
|
||||||
|
|
||||||
|
// Stop the document extraction workers and wait for any in-flight
|
||||||
|
// extraction to finish before closing the store it depends on.
|
||||||
|
ps.stopExtractionWorkers()
|
||||||
|
|
||||||
// we need to wait the goroutines to finish before closing the store
|
// we need to wait the goroutines to finish before closing the store
|
||||||
// and this needs to be called after hub stop because hub generates goroutines
|
// and this needs to be called after hub stop because hub generates goroutines
|
||||||
// when it is active. If we wait first we have no mechanism to prevent adding
|
// when it is active. If we wait first we have no mechanism to prevent adding
|
||||||
|
|||||||
@@ -813,6 +813,14 @@ func (s *Server) GoBuffered(f func()) {
|
|||||||
s.platform.GoBuffered(f)
|
s.platform.GoBuffered(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GoExtraction submits f to the bounded document extraction worker pool without
|
||||||
|
// blocking the caller. It returns false if the pool is saturated and f was not
|
||||||
|
// run; skipped files stay unextracted until an admin runs a content extraction
|
||||||
|
// job (e.g. mmctl extract).
|
||||||
|
func (s *Server) GoExtraction(f func()) bool {
|
||||||
|
return s.platform.GoExtraction(f)
|
||||||
|
}
|
||||||
|
|
||||||
var corsAllowedMethods = []string{
|
var corsAllowedMethods = []string{
|
||||||
"POST",
|
"POST",
|
||||||
"GET",
|
"GET",
|
||||||
|
|||||||
@@ -339,12 +339,14 @@ func (a *App) UploadData(c request.CTX, us *model.UploadSession, rd io.Reader) (
|
|||||||
|
|
||||||
if *a.Config().FileSettings.ExtractContent {
|
if *a.Config().FileSettings.ExtractContent {
|
||||||
infoCopy := *info
|
infoCopy := *info
|
||||||
a.Srv().Go(func() {
|
if !a.Srv().GoExtraction(func() {
|
||||||
err := a.ExtractContentFromFileInfo(c, &infoCopy)
|
err := a.ExtractContentFromFileInfo(c, &infoCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Error("Failed to extract file content", mlog.Err(err), mlog.String("fileInfoId", infoCopy.Id))
|
c.Logger().Error("Failed to extract file content", mlog.Err(err), mlog.String("fileInfoId", infoCopy.Id))
|
||||||
}
|
}
|
||||||
})
|
}) {
|
||||||
|
c.Logger().Warn("Content extraction queue is full, skipping inline extraction; this file's content will not be searchable until an admin runs a content extraction job (e.g. mmctl extract)", mlog.String("fileInfoId", infoCopy.Id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete upload session
|
// delete upload session
|
||||||
|
|||||||
@@ -9452,6 +9452,10 @@
|
|||||||
"id": "model.config.is_valid.export.retention_days_too_low.app_error",
|
"id": "model.config.is_valid.export.retention_days_too_low.app_error",
|
||||||
"translation": "Invalid value for RetentionDays. Value should be greater than 0"
|
"translation": "Invalid value for RetentionDays. Value should be greater than 0"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "model.config.is_valid.extract_content_timeout.app_error",
|
||||||
|
"translation": "Invalid content extraction timeout for file settings. Must be a whole number of seconds greater than or equal to zero."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "model.config.is_valid.file_driver.app_error",
|
"id": "model.config.is_valid.file_driver.app_error",
|
||||||
"translation": "Invalid driver name for file settings. Must be 'local' or 'amazons3'."
|
"translation": "Invalid driver name for file settings. Must be 'local' or 'amazons3'."
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
package docextractor
|
package docextractor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||||
)
|
)
|
||||||
@@ -15,6 +17,23 @@ type ExtractSettings struct {
|
|||||||
MaxFileSize int64
|
MaxFileSize int64
|
||||||
MMPreviewURL string
|
MMPreviewURL string
|
||||||
MMPreviewSecret string
|
MMPreviewSecret string
|
||||||
|
// Timeout bounds how long a caller waits for a single extraction. A value
|
||||||
|
// <= 0 disables it. NOTE: this bounds wall-clock wait time (and thus how
|
||||||
|
// long an extraction occupies its caller's worker slot), NOT CPU work.
|
||||||
|
// The docconv converters are not context-aware, so on timeout the
|
||||||
|
// converter keeps running to completion on a detached goroutine and keeps
|
||||||
|
// consuming CPU until it finishes on its own. Under sustained load,
|
||||||
|
// detached extractions can therefore accumulate and run concurrently. The
|
||||||
|
// primary bound on the work of any single extraction is MaxFileSize, which
|
||||||
|
// limits how much input the converter reads.
|
||||||
|
Timeout time.Duration
|
||||||
|
// ReaderCloser, when set, transfers ownership of closing the input reader
|
||||||
|
// to this package. It is closed only after extraction has actually
|
||||||
|
// finished reading. This matters with Timeout set: on timeout the caller
|
||||||
|
// returns while the converter may still be reading on a detached
|
||||||
|
// goroutine, so the caller must NOT close the reader itself or it would
|
||||||
|
// race with (and close the file out from under) that goroutine.
|
||||||
|
ReaderCloser io.Closer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract extract the text from a document using the system default extractors
|
// Extract extract the text from a document using the system default extractors
|
||||||
@@ -45,7 +64,71 @@ func ExtractWithExtraExtractors(logger mlog.LoggerIFace, filename string, r io.R
|
|||||||
enabledExtractors.Add(&plainExtractor{})
|
enabledExtractors.Add(&plainExtractor{})
|
||||||
|
|
||||||
if enabledExtractors.Match(filename) {
|
if enabledExtractors.Match(filename) {
|
||||||
return enabledExtractors.Extract(filename, r, settings.MaxFileSize)
|
return extractWithTimeout(enabledExtractors, filename, r, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No extractor matched, so nothing will read r; close it here since
|
||||||
|
// extractWithTimeout (which otherwise owns the close) is never reached.
|
||||||
|
if settings.ReaderCloser != nil {
|
||||||
|
settings.ReaderCloser.Close()
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractWithTimeout runs the extraction and stops waiting for it once
|
||||||
|
// settings.Timeout elapses. Because the underlying docconv converters are not
|
||||||
|
// context-aware, the extraction runs on a detached goroutine: on timeout we
|
||||||
|
// stop waiting and return an error, releasing the caller (and its worker slot)
|
||||||
|
// even though the converter keeps running.
|
||||||
|
//
|
||||||
|
// This decouples extraction from the caller, but it does NOT cap CPU: the
|
||||||
|
// detached converter continues to completion in the background, so a sustained
|
||||||
|
// stream of expensive documents can leave several detached extractions running
|
||||||
|
// at once. The per-extraction work is bounded instead by MaxFileSize (input
|
||||||
|
// size). Load-shedding on the number of in-flight detached extractions is a
|
||||||
|
// possible future improvement; it is intentionally not done here so it does
|
||||||
|
// not also throttle the backfill job that re-extracts skipped content.
|
||||||
|
func extractWithTimeout(e Extractor, filename string, r io.ReadSeeker, settings ExtractSettings) (string, error) {
|
||||||
|
if settings.Timeout <= 0 {
|
||||||
|
if settings.ReaderCloser != nil {
|
||||||
|
defer settings.ReaderCloser.Close()
|
||||||
|
}
|
||||||
|
return e.Extract(filename, r, settings.MaxFileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
type extractResult struct {
|
||||||
|
text string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
resultCh := make(chan extractResult, 1)
|
||||||
|
go func() {
|
||||||
|
// This goroutine owns the reader for the lifetime of the extraction.
|
||||||
|
// After the timeout fires the caller returns, but the converter may
|
||||||
|
// still be reading r here, so the reader is closed only once this
|
||||||
|
// goroutine is done with it - never by the caller.
|
||||||
|
if settings.ReaderCloser != nil {
|
||||||
|
defer settings.ReaderCloser.Close()
|
||||||
|
}
|
||||||
|
// This goroutine is detached, so an unrecovered panic in an extractor
|
||||||
|
// would crash the whole server. Convert it into an error instead.
|
||||||
|
// resultCh is buffered (cap 1), so this send never blocks even if the
|
||||||
|
// caller already timed out and stopped receiving.
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
resultCh <- extractResult{err: fmt.Errorf("panic during document text extraction: %v", rec)}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
text, err := e.Extract(filename, r, settings.MaxFileSize)
|
||||||
|
resultCh <- extractResult{text: text, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
timer := time.NewTimer(settings.Timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case res := <-resultCh:
|
||||||
|
return res.text, res.err
|
||||||
|
case <-timer.C:
|
||||||
|
return "", fmt.Errorf("document text extraction timed out after %s", settings.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -213,6 +215,144 @@ func TestExtractWithExtraExtractors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type slowExtractor struct {
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (se *slowExtractor) Name() string { return "slowExtractor" }
|
||||||
|
|
||||||
|
func (se *slowExtractor) Match(filename string) bool { return true }
|
||||||
|
|
||||||
|
func (se *slowExtractor) Extract(filename string, r io.ReadSeeker, _ int64) (string, error) {
|
||||||
|
time.Sleep(se.delay)
|
||||||
|
return "done", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTimeout(t *testing.T) {
|
||||||
|
logger := mlog.CreateConsoleTestLogger(t)
|
||||||
|
data := []byte("hello world")
|
||||||
|
|
||||||
|
t.Run("aborts a slow extraction once the timeout elapses", func(t *testing.T) {
|
||||||
|
start := time.Now()
|
||||||
|
text, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: 50 * time.Millisecond}, []Extractor{&slowExtractor{delay: 10 * time.Second}})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, text)
|
||||||
|
assert.Contains(t, err.Error(), "timed out")
|
||||||
|
assert.Less(t, elapsed, 5*time.Second, "should return shortly after the timeout, not wait for the extraction")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns the result when extraction finishes within the timeout", func(t *testing.T) {
|
||||||
|
text, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: 5 * time.Second}, []Extractor{&slowExtractor{delay: 10 * time.Millisecond}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "done", text)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a zero timeout disables the bound", func(t *testing.T) {
|
||||||
|
text, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: 0}, []Extractor{&slowExtractor{delay: 10 * time.Millisecond}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "done", text)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a panic in the detached extraction is converted to an error", func(t *testing.T) {
|
||||||
|
text, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader(data), ExtractSettings{Timeout: time.Second}, []Extractor{&panickingExtractor{}})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, text)
|
||||||
|
require.Contains(t, err.Error(), "panic")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type panickingExtractor struct{}
|
||||||
|
|
||||||
|
func (pe *panickingExtractor) Name() string { return "panickingExtractor" }
|
||||||
|
|
||||||
|
func (pe *panickingExtractor) Match(filename string) bool { return true }
|
||||||
|
|
||||||
|
func (pe *panickingExtractor) Extract(filename string, r io.ReadSeeker, _ int64) (string, error) {
|
||||||
|
panic("boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingCloser struct {
|
||||||
|
closed atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingCloser) Close() error {
|
||||||
|
c.closed.Store(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockingExtractor blocks inside Extract until release is closed, simulating a
|
||||||
|
// converter that is still using the reader after an extraction timeout fires.
|
||||||
|
type blockingExtractor struct {
|
||||||
|
started chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (be *blockingExtractor) Name() string { return "blockingExtractor" }
|
||||||
|
|
||||||
|
func (be *blockingExtractor) Match(filename string) bool { return true }
|
||||||
|
|
||||||
|
func (be *blockingExtractor) Extract(filename string, r io.ReadSeeker, _ int64) (string, error) {
|
||||||
|
close(be.started)
|
||||||
|
<-be.release
|
||||||
|
return "done", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractReaderCloserOwnership(t *testing.T) {
|
||||||
|
logger := mlog.CreateConsoleTestLogger(t)
|
||||||
|
|
||||||
|
t.Run("reader is closed only after the detached extraction finishes on timeout", func(t *testing.T) {
|
||||||
|
closer := &recordingCloser{}
|
||||||
|
be := &blockingExtractor{started: make(chan struct{}), release: make(chan struct{})}
|
||||||
|
settings := ExtractSettings{Timeout: 50 * time.Millisecond, ReaderCloser: closer}
|
||||||
|
|
||||||
|
_, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader([]byte("hi")), settings, []Extractor{be})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "timed out")
|
||||||
|
|
||||||
|
// Wait (with a deadline) for the detached extraction to start so the
|
||||||
|
// test fails fast instead of hanging if it never runs.
|
||||||
|
select {
|
||||||
|
case <-be.started:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
require.FailNow(t, "extraction did not start within the deadline")
|
||||||
|
}
|
||||||
|
// The extraction goroutine is still running, so closing the reader now
|
||||||
|
// would race with it; it must stay open.
|
||||||
|
require.False(t, closer.closed.Load(), "reader must not be closed while the extraction goroutine is still running")
|
||||||
|
|
||||||
|
close(be.release)
|
||||||
|
require.Eventually(t, closer.closed.Load, 2*time.Second, 5*time.Millisecond, "reader should be closed once the extraction goroutine finishes")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reader is closed on the synchronous path", func(t *testing.T) {
|
||||||
|
closer := &recordingCloser{}
|
||||||
|
_, err := ExtractWithExtraExtractors(logger, "file.txt", bytes.NewReader([]byte("hi")), ExtractSettings{ReaderCloser: closer}, []Extractor{&slowExtractor{delay: 0}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, closer.closed.Load(), "reader should be closed after synchronous extraction")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentMaxFileSize(t *testing.T) {
|
||||||
|
logger := mlog.CreateConsoleTestLogger(t)
|
||||||
|
|
||||||
|
data, err := testutils.ReadTestFile("sample-doc.docx")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("a generous limit extracts the document content", func(t *testing.T) {
|
||||||
|
text, err := Extract(logger, "sample-doc.docx", bytes.NewReader(data), ExtractSettings{MaxFileSize: 10 * 1024 * 1024})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, text, "simple")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a tiny limit prevents the document content from being extracted", func(t *testing.T) {
|
||||||
|
text, err := Extract(logger, "sample-doc.docx", bytes.NewReader(data), ExtractSettings{MaxFileSize: 16})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, text, "simple")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestArchiveMaxFileSize(t *testing.T) {
|
func TestArchiveMaxFileSize(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.sajari.com/docconv/v2"
|
"code.sajari.com/docconv/v2"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type documentExtractor struct{}
|
type documentExtractor struct{}
|
||||||
@@ -36,7 +38,7 @@ func (de *documentExtractor) Match(filename string) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (de *documentExtractor) Extract(filename string, r io.ReadSeeker, _ int64) (out string, outErr error) {
|
func (de *documentExtractor) Extract(filename string, r io.ReadSeeker, maxFileSize int64) (out string, outErr error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
out = ""
|
out = ""
|
||||||
@@ -50,7 +52,14 @@ func (de *documentExtractor) Extract(filename string, r io.ReadSeeker, _ int64)
|
|||||||
return "", errors.New("unknown converter")
|
return "", errors.New("unknown converter")
|
||||||
}
|
}
|
||||||
|
|
||||||
text, _, err := converter(r)
|
// Bound how much data the converter is allowed to read so a small upload
|
||||||
|
// cannot expand into an unbounded amount of in-memory work.
|
||||||
|
var reader io.Reader = r
|
||||||
|
if maxFileSize > 0 {
|
||||||
|
reader = utils.NewLimitedReaderWithError(r, maxFileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
text, _, err := converter(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ledongthuc/pdf"
|
"github.com/ledongthuc/pdf"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type pdfExtractor struct{}
|
type pdfExtractor struct{}
|
||||||
@@ -29,7 +31,7 @@ func (pe *pdfExtractor) Match(filename string) bool {
|
|||||||
return supportedExtensions[extension]
|
return supportedExtensions[extension]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pe *pdfExtractor) Extract(filename string, r io.ReadSeeker, _ int64) (out string, outErr error) {
|
func (pe *pdfExtractor) Extract(filename string, r io.ReadSeeker, maxFileSize int64) (out string, outErr error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
out = ""
|
out = ""
|
||||||
@@ -42,7 +44,14 @@ func (pe *pdfExtractor) Extract(filename string, r io.ReadSeeker, _ int64) (out
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
defer os.Remove(f.Name())
|
defer os.Remove(f.Name())
|
||||||
size, err := io.Copy(f, r)
|
|
||||||
|
// Bound how much data is copied to disk so a small upload cannot expand
|
||||||
|
// into an unbounded amount of temporary storage.
|
||||||
|
var src io.Reader = r
|
||||||
|
if maxFileSize > 0 {
|
||||||
|
src = utils.NewLimitedReaderWithError(r, maxFileSize)
|
||||||
|
}
|
||||||
|
size, err := io.Copy(f, src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("error copying data into temporary file: %v", err)
|
return "", fmt.Errorf("error copying data into temporary file: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,3 +50,30 @@ func TestWrongPdfFile(t *testing.T) {
|
|||||||
_, err = extractor.Extract("sample-doc.pdf", bytes.NewReader(content), 0)
|
_, err = extractor.Extract("sample-doc.pdf", bytes.NewReader(content), 0)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPdfMaxFileSize(t *testing.T) {
|
||||||
|
extractor := pdfExtractor{}
|
||||||
|
content, err := testutils.ReadTestFile("sample-doc.pdf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Greater(t, len(content), 16, "fixture must be larger than the tight limit under test")
|
||||||
|
|
||||||
|
t.Run("a zero limit means unlimited and extracts the content", func(t *testing.T) {
|
||||||
|
text, err := extractor.Extract("sample-doc.pdf", bytes.NewReader(content), 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, text, "simple")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a generous limit extracts the content", func(t *testing.T) {
|
||||||
|
text, err := extractor.Extract("sample-doc.pdf", bytes.NewReader(content), 10*1024*1024)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, text, "simple")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a tight limit prevents extraction", func(t *testing.T) {
|
||||||
|
// The reader errors once it reads past the limit, so io.Copy to the
|
||||||
|
// temp file fails and no text is extracted.
|
||||||
|
text, err := extractor.Extract("sample-doc.pdf", bytes.NewReader(content), 16)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1767,6 +1767,7 @@ type FileSettings struct {
|
|||||||
Directory *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"`
|
Directory *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"`
|
||||||
EnablePublicLink *bool `access:"site_public_links,cloud_restrictable"`
|
EnablePublicLink *bool `access:"site_public_links,cloud_restrictable"`
|
||||||
ExtractContent *bool `access:"environment_file_storage,write_restrictable"`
|
ExtractContent *bool `access:"environment_file_storage,write_restrictable"`
|
||||||
|
ExtractContentTimeout *int `access:"environment_file_storage,write_restrictable"` // In seconds. 0 disables the timeout.
|
||||||
ArchiveRecursion *bool `access:"environment_file_storage,write_restrictable"`
|
ArchiveRecursion *bool `access:"environment_file_storage,write_restrictable"`
|
||||||
PublicLinkSalt *string `access:"site_public_links,cloud_restrictable"` // telemetry: none
|
PublicLinkSalt *string `access:"site_public_links,cloud_restrictable"` // telemetry: none
|
||||||
InitialFont *string `access:"environment_file_storage,cloud_restrictable"` // telemetry: none
|
InitialFont *string `access:"environment_file_storage,cloud_restrictable"` // telemetry: none
|
||||||
@@ -1844,6 +1845,10 @@ func (s *FileSettings) SetDefaults(isUpdate bool) {
|
|||||||
s.ExtractContent = NewPointer(true)
|
s.ExtractContent = NewPointer(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.ExtractContentTimeout == nil {
|
||||||
|
s.ExtractContentTimeout = NewPointer(10)
|
||||||
|
}
|
||||||
|
|
||||||
if s.ArchiveRecursion == nil {
|
if s.ArchiveRecursion == nil {
|
||||||
s.ArchiveRecursion = NewPointer(false)
|
s.ArchiveRecursion = NewPointer(false)
|
||||||
}
|
}
|
||||||
@@ -4230,6 +4235,10 @@ func (s *FileSettings) isValid() *AppError {
|
|||||||
return NewAppError("Config.IsValid", "model.config.is_valid.max_file_size.app_error", nil, "", http.StatusBadRequest)
|
return NewAppError("Config.IsValid", "model.config.is_valid.max_file_size.app_error", nil, "", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *s.ExtractContentTimeout < 0 {
|
||||||
|
return NewAppError("Config.IsValid", "model.config.is_valid.extract_content_timeout.app_error", nil, "", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
if !(*s.DriverName == ImageDriverLocal || *s.DriverName == ImageDriverS3) {
|
if !(*s.DriverName == ImageDriverLocal || *s.DriverName == ImageDriverS3) {
|
||||||
return NewAppError("Config.IsValid", "model.config.is_valid.file_driver.app_error", nil, "", http.StatusBadRequest)
|
return NewAppError("Config.IsValid", "model.config.is_valid.file_driver.app_error", nil, "", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,38 @@ func TestFileSettingsDirectoryWhitespaceValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileSettingsExtractContentTimeout(t *testing.T) {
|
||||||
|
t.Run("default is valid", func(t *testing.T) {
|
||||||
|
cfg := &Config{}
|
||||||
|
cfg.SetDefaults()
|
||||||
|
require.NotNil(t, cfg.FileSettings.ExtractContentTimeout)
|
||||||
|
assert.Equal(t, 10, *cfg.FileSettings.ExtractContentTimeout)
|
||||||
|
assert.Nil(t, cfg.FileSettings.isValid())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zero disables the timeout and is valid", func(t *testing.T) {
|
||||||
|
cfg := &Config{}
|
||||||
|
cfg.SetDefaults()
|
||||||
|
cfg.FileSettings.ExtractContentTimeout = NewPointer(0)
|
||||||
|
assert.Nil(t, cfg.FileSettings.isValid())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a positive value is valid", func(t *testing.T) {
|
||||||
|
cfg := &Config{}
|
||||||
|
cfg.SetDefaults()
|
||||||
|
cfg.FileSettings.ExtractContentTimeout = NewPointer(10)
|
||||||
|
assert.Nil(t, cfg.FileSettings.isValid())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a negative value is rejected", func(t *testing.T) {
|
||||||
|
cfg := &Config{}
|
||||||
|
cfg.SetDefaults()
|
||||||
|
cfg.FileSettings.ExtractContentTimeout = NewPointer(-1)
|
||||||
|
err := cfg.FileSettings.isValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
assert.Equal(t, "model.config.is_valid.extract_content_timeout.app_error", err.Id)
|
||||||
|
})
|
||||||
|
}
|
||||||
func TestConfigDefaultSignatureAlgorithm(t *testing.T) {
|
func TestConfigDefaultSignatureAlgorithm(t *testing.T) {
|
||||||
c1 := Config{}
|
c1 := Config{}
|
||||||
c1.SetDefaults()
|
c1.SetDefaults()
|
||||||
|
|||||||
@@ -1081,6 +1081,18 @@ const AdminDefinition: AdminDefinitionType = {
|
|||||||
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)),
|
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
key: 'FileSettings.ExtractContentTimeout',
|
||||||
|
label: defineMessage({id: 'admin.image.extractContentTimeoutTitle', defaultMessage: 'Document content extraction timeout (seconds):'}),
|
||||||
|
help_text: defineMessage({id: 'admin.image.extractContentTimeoutDescription', defaultMessage: 'Maximum number of seconds spent extracting the searchable content of a single uploaded document. Extractions that exceed this limit are aborted to protect server performance. Set to 0 to disable the timeout.'}),
|
||||||
|
placeholder: defineMessage({id: 'admin.image.extractContentTimeoutExample', defaultMessage: '10'}),
|
||||||
|
validate: validators.minValue(0, defineMessage({id: 'admin.image.extractContentTimeout.minValue', defaultMessage: 'Timeout must be 0 or greater.'})),
|
||||||
|
isDisabled: it.any(
|
||||||
|
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)),
|
||||||
|
it.configIsFalse('FileSettings', 'ExtractContent'),
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
key: 'FileSettings.ArchiveRecursion',
|
key: 'FileSettings.ArchiveRecursion',
|
||||||
|
|||||||
@@ -1349,6 +1349,10 @@
|
|||||||
"admin.image.enableProxyDescription": "When true, enables an image proxy for loading all Markdown images.",
|
"admin.image.enableProxyDescription": "When true, enables an image proxy for loading all Markdown images.",
|
||||||
"admin.image.exportDirectoryDescription": "Directory to which files are written. If blank, defaults to ./data/.",
|
"admin.image.exportDirectoryDescription": "Directory to which files are written. If blank, defaults to ./data/.",
|
||||||
"admin.image.extractContentDescription": "When enabled, supported document types are searchable by their content. Search results for existing documents may be incomplete <link>until a data migration is executed</link>.",
|
"admin.image.extractContentDescription": "When enabled, supported document types are searchable by their content. Search results for existing documents may be incomplete <link>until a data migration is executed</link>.",
|
||||||
|
"admin.image.extractContentTimeout.minValue": "Timeout must be 0 or greater.",
|
||||||
|
"admin.image.extractContentTimeoutDescription": "Maximum number of seconds spent extracting the searchable content of a single uploaded document. Extractions that exceed this limit are aborted to protect server performance. Set to 0 to disable the timeout.",
|
||||||
|
"admin.image.extractContentTimeoutExample": "10",
|
||||||
|
"admin.image.extractContentTimeoutTitle": "Document content extraction timeout (seconds):",
|
||||||
"admin.image.extractContentTitle": "Enable document search by content:",
|
"admin.image.extractContentTitle": "Enable document search by content:",
|
||||||
"admin.image.localDescription": "Directory to which files and images are written. If blank, defaults to ./data/.",
|
"admin.image.localDescription": "Directory to which files and images are written. If blank, defaults to ./data/.",
|
||||||
"admin.image.localExample": "E.g.: \"./data/\"",
|
"admin.image.localExample": "E.g.: \"./data/\"",
|
||||||
|
|||||||
@@ -548,6 +548,7 @@ export type FileSettings = {
|
|||||||
Directory: string;
|
Directory: string;
|
||||||
EnablePublicLink: boolean;
|
EnablePublicLink: boolean;
|
||||||
ExtractContent: boolean;
|
ExtractContent: boolean;
|
||||||
|
ExtractContentTimeout: number;
|
||||||
ArchiveRecursion: boolean;
|
ArchiveRecursion: boolean;
|
||||||
PublicLinkSalt: string;
|
PublicLinkSalt: string;
|
||||||
InitialFont: string;
|
InitialFont: string;
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user