mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-23 08:56:42 +08:00
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
459 lines
13 KiB
Go
459 lines
13 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package utility
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
type FileType string
|
|
|
|
const (
|
|
FileTypePDF FileType = "pdf"
|
|
FileTypeDOC FileType = "doc"
|
|
FileTypeDOCX FileType = "docx"
|
|
FileTypePPT FileType = "ppt"
|
|
FileTypePPTX FileType = "pptx"
|
|
FileTypeXLS FileType = "xls"
|
|
FileTypeXLSX FileType = "xlsx"
|
|
FileTypeCSV FileType = "csv"
|
|
FileTypeHTML FileType = "html"
|
|
FileTypeMarkdown FileType = "md"
|
|
FileTypeTXT FileType = "txt"
|
|
FileTypeEPUB FileType = "epub"
|
|
FileTypeJSON FileType = "json"
|
|
FileTypeVIDEO FileType = "video"
|
|
FileTypeEMAIL FileType = "email"
|
|
FileTypeVISUAL FileType = "visual"
|
|
FileTypeAURAL FileType = "aural"
|
|
FileTypeFOLDER FileType = "folder"
|
|
FileTypeOTHER FileType = "other"
|
|
)
|
|
|
|
var (
|
|
filenameLenLimit = 255
|
|
)
|
|
|
|
func init() {
|
|
}
|
|
|
|
func normalizeFilename(filename string) (string, bool) {
|
|
if filename == "" {
|
|
return "", false
|
|
}
|
|
base := filepath.Base(filename)
|
|
base = strings.TrimSpace(base)
|
|
if base == "" || len(base) > filenameLenLimit {
|
|
return "", false
|
|
}
|
|
return strings.ToLower(base), true
|
|
}
|
|
|
|
func GetFileType(filename string) FileType {
|
|
|
|
ext := filepath.Ext(filename)
|
|
var suffix string
|
|
if len(ext) > 0 && ext[0] == '.' {
|
|
suffix = strings.ToLower(ext[1:])
|
|
} else {
|
|
suffix = strings.ToLower(ext)
|
|
}
|
|
|
|
switch suffix {
|
|
case "pdf":
|
|
return FileTypePDF
|
|
case "xls":
|
|
return FileTypeXLS
|
|
case "xlsx":
|
|
return FileTypeXLSX
|
|
case "csv":
|
|
return FileTypeCSV
|
|
case "doc":
|
|
return FileTypeDOC
|
|
case "docx":
|
|
return FileTypeDOCX
|
|
case "ppt":
|
|
return FileTypePPT
|
|
case "pptx":
|
|
return FileTypePPTX
|
|
case "html", "htm":
|
|
return FileTypeHTML
|
|
case "md", "markdown", "mdx":
|
|
return FileTypeMarkdown
|
|
case "txt", "py", "js", "java", "c", "cpp", "h", "php",
|
|
"go", "ts", "sh", "cs", "kt", "sql":
|
|
return FileTypeTXT
|
|
case "epub":
|
|
return FileTypeEPUB
|
|
case "json", "jsonl", "ldjson":
|
|
return FileTypeJSON
|
|
case "eml", "msg":
|
|
return FileTypeEMAIL
|
|
case "da", "wave", "wav", "mp3", "aac", "flac", "ogg",
|
|
"aiff", "au", "midi", "wma", "ape", "alac", "wv", "opus":
|
|
return FileTypeAURAL
|
|
case "mp4", "avi", "mkv", "mov", "webm", "flv", "mpeg",
|
|
"mpg", "wmv", "3gp", "3gpp":
|
|
return FileTypeVIDEO
|
|
case "png", "jpg", "jpeg", "gif", "bmp":
|
|
return FileTypeVISUAL
|
|
case "tiff", "tif", "webp", "svg", "ico":
|
|
return FileTypeVISUAL
|
|
case "avif", "heic", "apng":
|
|
return FileTypeVISUAL
|
|
default:
|
|
return FileTypeOTHER
|
|
}
|
|
}
|
|
|
|
func FilenameType(filename string) FileType {
|
|
normalized, ok := normalizeFilename(filename)
|
|
if !ok {
|
|
return FileTypeOTHER
|
|
}
|
|
|
|
if matched, _ := regexp.MatchString(`.*\.pdf$`, normalized); matched {
|
|
return FileTypePDF
|
|
}
|
|
|
|
docExtensions := []string{
|
|
"msg", "eml", "doc", "docx", "ppt", "pptx", "yml", "xml", "htm", "json", "jsonl", "ldjson",
|
|
"csv", "txt", "ini", "xls", "xlsx", "wps", "rtf", "hlp", "pages", "numbers", "key",
|
|
"md", "mdx", "py", "js", "java", "c", "cpp", "h", "php", "go", "ts", "sh", "cs", "kt",
|
|
"html", "sql", "epub",
|
|
}
|
|
for _, ext := range docExtensions {
|
|
if strings.HasSuffix(normalized, "."+ext) {
|
|
return FileTypeDOC
|
|
}
|
|
}
|
|
|
|
audioExtensions := []string{
|
|
"wav", "flac", "ape", "alac", "wv", "mp3", "aac", "ogg", "vorbis", "opus",
|
|
}
|
|
for _, ext := range audioExtensions {
|
|
if strings.HasSuffix(normalized, "."+ext) {
|
|
return FileTypeAURAL
|
|
}
|
|
}
|
|
|
|
visualExtensions := []string{
|
|
"jpg", "jpeg", "png", "tif", "gif", "pcx", "tga", "exif", "fpx", "svg", "psd", "cdr",
|
|
"pcd", "dxf", "ufo", "eps", "ai", "raw", "WMF", "webp", "avif", "apng", "icon", "ico",
|
|
"mpg", "mpeg", "avi", "rm", "rmvb", "mov", "wmv", "asf", "dat", "asx", "wvx", "mpe",
|
|
"mpa", "mp4", "mkv",
|
|
}
|
|
for _, ext := range visualExtensions {
|
|
if strings.HasSuffix(normalized, "."+ext) {
|
|
return FileTypeVISUAL
|
|
}
|
|
}
|
|
|
|
return FileTypeOTHER
|
|
}
|
|
|
|
func SanitizeFilename(filename string) string {
|
|
if filename == "" {
|
|
return ""
|
|
}
|
|
filename = strings.TrimSpace(filename)
|
|
if filename == "" {
|
|
return ""
|
|
}
|
|
|
|
filename = strings.ReplaceAll(filename, "\\", "/")
|
|
filename = strings.Trim(filename, "/")
|
|
|
|
parts := strings.Split(filename, "/")
|
|
var sanitizedParts []string
|
|
for _, part := range parts {
|
|
if part != "" && part != "." && part != ".." {
|
|
sanitizedParts = append(sanitizedParts, part)
|
|
}
|
|
}
|
|
|
|
unsafeRegex := regexp.MustCompile(`[^A-Za-z0-9_\-/]`)
|
|
for i, part := range sanitizedParts {
|
|
sanitizedParts[i] = unsafeRegex.ReplaceAllString(part, "")
|
|
}
|
|
|
|
result := strings.Join(sanitizedParts, "/")
|
|
return result
|
|
}
|
|
|
|
func GetFileExtension(filename string) string {
|
|
ext := filepath.Ext(filename)
|
|
if len(ext) > 0 && ext[0] == '.' {
|
|
return strings.ToLower(ext[1:])
|
|
}
|
|
return strings.ToLower(ext)
|
|
}
|
|
|
|
// CONTENT_TYPE_MAP maps file extensions to MIME content types
|
|
var CONTENT_TYPE_MAP = map[string]string{
|
|
// Office
|
|
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"doc": "application/msword",
|
|
"pdf": "application/pdf",
|
|
"csv": "text/csv",
|
|
"xls": "application/vnd.ms-excel",
|
|
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
// Text/code
|
|
"txt": "text/plain",
|
|
"py": "text/plain",
|
|
"js": "text/plain",
|
|
"java": "text/plain",
|
|
"c": "text/plain",
|
|
"cpp": "text/plain",
|
|
"h": "text/plain",
|
|
"php": "text/plain",
|
|
"go": "text/plain",
|
|
"ts": "text/plain",
|
|
"sh": "text/plain",
|
|
"cs": "text/plain",
|
|
"kt": "text/plain",
|
|
"sql": "text/plain",
|
|
// Web
|
|
"md": "text/markdown",
|
|
"markdown": "text/markdown",
|
|
"mdx": "text/markdown",
|
|
"htm": "text/html",
|
|
"html": "text/html",
|
|
"json": "application/json",
|
|
// Image formats
|
|
"png": "image/png",
|
|
"jpg": "image/jpeg",
|
|
"jpeg": "image/jpeg",
|
|
"gif": "image/gif",
|
|
"bmp": "image/bmp",
|
|
"tiff": "image/tiff",
|
|
"tif": "image/tiff",
|
|
"webp": "image/webp",
|
|
"svg": "image/svg+xml",
|
|
"ico": "image/x-icon",
|
|
"avif": "image/avif",
|
|
"heic": "image/heic",
|
|
// PPTX
|
|
"ppt": "application/vnd.ms-powerpoint",
|
|
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
// Video formats
|
|
"mp4": "video/mp4",
|
|
"mov": "video/quicktime",
|
|
"avi": "video/x-msvideo",
|
|
"mpg": "video/mpeg",
|
|
"mpeg": "video/mpeg",
|
|
"mkv": "video/x-matroska",
|
|
"wmv": "video/x-ms-wmv",
|
|
"webm": "video/webm",
|
|
"rm": "application/vnd.rn-realmedia",
|
|
"rmvb": "application/vnd.rn-realmedia",
|
|
"dat": "video/mpeg",
|
|
"asx": "video/x-ms-asf",
|
|
"wvx": "video/x-ms-wvx",
|
|
"mpe": "video/mpeg",
|
|
"mpa": "video/mpeg",
|
|
}
|
|
|
|
// FORCE_ATTACHMENT_EXTENSIONS are extensions that should always be downloaded as attachments
|
|
var FORCE_ATTACHMENT_EXTENSIONS = map[string]bool{
|
|
"htm": true,
|
|
"html": true,
|
|
"shtml": true,
|
|
"xht": true,
|
|
"xhtml": true,
|
|
"xml": true,
|
|
"mhtml": true,
|
|
"svg": true,
|
|
}
|
|
|
|
// FORCE_ATTACHMENT_CONTENT_TYPES are content types that should always be downloaded as attachments
|
|
var FORCE_ATTACHMENT_CONTENT_TYPES = map[string]bool{
|
|
"text/html": true,
|
|
"image/svg+xml": true,
|
|
"application/xhtml+xml": true,
|
|
"text/xml": true,
|
|
"application/xml": true,
|
|
"multipart/related": true,
|
|
}
|
|
|
|
// stripContentTypeParams strips "; charset=..." and similar parameters
|
|
// from a content type string. Mirrors Python's .split(";")[0].strip().
|
|
func stripContentTypeParams(ct string) string {
|
|
if before, _, found := strings.Cut(ct, ";"); found {
|
|
return strings.TrimSpace(before)
|
|
}
|
|
return strings.TrimSpace(ct)
|
|
}
|
|
|
|
// ShouldForceAttachment determines if the file should be forced as attachment
|
|
func ShouldForceAttachment(ext string, contentType string) bool {
|
|
normalizedExt := strings.ToLower(strings.TrimPrefix(ext, "."))
|
|
if normalizedExt != "" && FORCE_ATTACHMENT_EXTENSIONS[normalizedExt] {
|
|
return true
|
|
}
|
|
normalizedType := strings.ToLower(stripContentTypeParams(contentType))
|
|
return FORCE_ATTACHMENT_CONTENT_TYPES[normalizedType]
|
|
}
|
|
|
|
// GetContentType determines the content type based on extension and file type
|
|
// fallbackPrefix is "image" for visual files, "application" for others
|
|
func GetContentType(ext string, fileType string) string {
|
|
if ext == "" {
|
|
return ""
|
|
}
|
|
normalizedExt := strings.ToLower(strings.TrimPrefix(ext, "."))
|
|
if contentType, ok := CONTENT_TYPE_MAP[normalizedExt]; ok {
|
|
return contentType
|
|
}
|
|
fallbackPrefix := "application"
|
|
if fileType == string(FileTypeVISUAL) {
|
|
fallbackPrefix = "image"
|
|
}
|
|
return fallbackPrefix + "/" + normalizedExt
|
|
}
|
|
|
|
// SanitizeContentDispositionFilename sanitizes a filename for use in
|
|
// Content-Disposition headers. Strips non-ASCII, path separators,
|
|
// control characters, and quotes/percent signs. Falls back to "file"
|
|
// when the result is empty. Mirrors Python file_response.py:
|
|
// sanitize_content_disposition_filename().
|
|
func SanitizeContentDispositionFilename(filename string) string {
|
|
if filename == "" {
|
|
return "file"
|
|
}
|
|
// Strip non-ASCII.
|
|
var asciiOnly strings.Builder
|
|
for _, r := range filename {
|
|
if r < 0x80 {
|
|
asciiOnly.WriteRune(r)
|
|
}
|
|
}
|
|
sanitized := asciiOnly.String()
|
|
|
|
// Replace path separators, special chars.
|
|
sanitized = strings.ReplaceAll(sanitized, "/", "_")
|
|
sanitized = strings.ReplaceAll(sanitized, "\\", "_")
|
|
sanitized = strings.ReplaceAll(sanitized, ":", "_")
|
|
sanitized = strings.ReplaceAll(sanitized, "\"", "")
|
|
sanitized = strings.ReplaceAll(sanitized, "'", "")
|
|
sanitized = strings.ReplaceAll(sanitized, "%", "")
|
|
|
|
// Strip remaining control characters.
|
|
ctrlRe := regexp.MustCompile(`[\x00-\x1f\x7f]`)
|
|
sanitized = ctrlRe.ReplaceAllString(sanitized, "")
|
|
|
|
sanitized = strings.TrimSpace(sanitized)
|
|
if sanitized == "" {
|
|
return "file"
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
// ResolveAttachmentContentType resolves a content type and extension from
|
|
// query-parameter values. When mimeType is non-empty it is preferred and
|
|
// reverse-looked up in CONTENT_TYPE_MAP to also resolve the extension;
|
|
// otherwise the extension is looked up in CONTENT_TYPE_MAP, falling back
|
|
// to "application/<ext>". Returns (contentType, ext). Mirrors Python
|
|
// file_response.py: resolve_attachment_content_type().
|
|
func ResolveAttachmentContentType(ext string, mimeType string) (string, string) {
|
|
ext = strings.ToLower(strings.TrimSpace(ext))
|
|
ext = strings.TrimPrefix(ext, ".")
|
|
mimeType = strings.TrimSpace(mimeType)
|
|
|
|
contentType := ""
|
|
if mimeType != "" {
|
|
normalizedType := strings.ToLower(stripContentTypeParams(mimeType))
|
|
contentType = normalizedType
|
|
// Reverse-lookup extension from CONTENT_TYPE_MAP only when
|
|
// no explicit ext was provided. Never overwrite a caller-
|
|
// supplied extension (e.g. ext=svg&mime_type=image/png must
|
|
// stay ext=svg for the force-attachment check).
|
|
if ext == "" {
|
|
for knownExt, knownType := range CONTENT_TYPE_MAP {
|
|
if knownType == normalizedType {
|
|
ext = knownExt
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} else if ext != "" {
|
|
if ct, ok := CONTENT_TYPE_MAP[ext]; ok {
|
|
contentType = ct
|
|
} else {
|
|
contentType = "application/" + ext
|
|
}
|
|
}
|
|
return contentType, ext
|
|
}
|
|
|
|
// SetPreviewFileResponseHeaders sets response headers for inline file
|
|
// preview. For force-attachment types (HTML, SVG, XML) it falls back to
|
|
// attachment disposition with nosniff. Mirrors Python file_response.py:
|
|
// apply_preview_file_response_headers().
|
|
func SetPreviewFileResponseHeaders(h http.Header, contentType, ext, filename string) {
|
|
if contentType != "" {
|
|
h.Set("Content-Type", contentType)
|
|
}
|
|
if ShouldForceAttachment(ext, contentType) {
|
|
h.Set("Content-Disposition", "attachment")
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
} else {
|
|
safe := SanitizeContentDispositionFilename(filename)
|
|
h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, safe))
|
|
}
|
|
}
|
|
|
|
// SetDownloadFileResponseHeaders sets response headers for file download
|
|
// (always attachment disposition). Mirrors Python file_response.py:
|
|
// apply_download_file_response_headers().
|
|
func SetDownloadFileResponseHeaders(h http.Header, contentType, ext, filename string) {
|
|
if contentType != "" {
|
|
h.Set("Content-Type", contentType)
|
|
}
|
|
if ShouldForceAttachment(ext, contentType) {
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("Content-Disposition", "attachment")
|
|
return
|
|
}
|
|
safe := SanitizeContentDispositionFilename(filename)
|
|
h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe))
|
|
}
|
|
|
|
// AgentAttachmentPreviewPath builds the preview URL path for an agent
|
|
// attachment. Query parameters ext and mime_type are URL-encoded when
|
|
// provided. Mirrors Python file_response.py:
|
|
// agent_attachment_preview_path().
|
|
func AgentAttachmentPreviewPath(attachmentID, ext, mimeType string) string {
|
|
path := "/api/v1/agents/attachments/" + attachmentID + "/preview"
|
|
params := url.Values{}
|
|
if ext != "" {
|
|
params.Set("ext", ext)
|
|
}
|
|
if mimeType != "" {
|
|
params.Set("mime_type", mimeType)
|
|
}
|
|
if qs := params.Encode(); qs != "" {
|
|
return path + "?" + qs
|
|
}
|
|
return path
|
|
}
|