transfer

package
v0.1.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

Documentation

Overview

Package transfer 为 ofd-server 定义输入与输出两侧的抽象:待转换文档从哪里来, 转换结果写到哪里去。

服务端接收两类输入——HTTP 上传的字节流,或由调用方给出的 URL 拉取。URL 拉取 必须经过 allowlist 校验,否则调用方能让服务去请求内网地址或云元数据端点。 输出侧同理,调用方指定的目录与文件名必须限制在配置允许的根目录内。

放在 internal 下是因为它只服务于 ofd-server:Source/Sink 的取舍都按服务端 场景定(例如输出默认只新建、不允许覆盖)。需要把转换库接到对象存储的调用方 可以直接用 pkg/converter 的 []byte 与路径入参,不必依赖本包。

Index

Constants

View Source
const DefaultFTPTimeout = 60 * time.Second

DefaultFTPTimeout 是 FTP 连接与操作的默认超时。

View Source
const DefaultMaxBytes int64 = 64 << 20

DefaultMaxBytes 是单个文件的默认上限。输入与输出共用同一个上限常量,但可以 分别覆盖。

View Source
const DefaultMinioTimeout = 2 * time.Minute

DefaultMinioTimeout 是对象存储单次请求的默认超时。

View Source
const DefaultSFTPTimeout = 60 * time.Second

DefaultSFTPTimeout 是 SFTP 连接与操作的默认超时。

View Source
const DefaultWebDAVTimeout = 2 * time.Minute

DefaultWebDAVTimeout 是 WebDAV 单次请求的默认超时。

Variables

View Source
var ErrExists = errors.New("目标已存在")

ErrExists 表示目标已存在且该目标不允许覆盖。

单列一个哨兵错误而不是靠消息文本判断,是因为调用方需要据此决定要不要重试: 重试一个"文件已存在"永远还是已存在,白白耗掉 worker 与退避时间。而它恰恰是 output.dir 指向共享目录、多个任务写同名文件时必然出现的错误。

Functions

func CopyLimited

func CopyLimited(ctx context.Context, w io.Writer, r io.Reader, limit int64) (int64, error)

CopyLimited 把 r 拷贝到 w,累计超过 limit 字节时中止并报错。分块读取,内存占用 与内容大小无关。

func ReadLimited

func ReadLimited(ctx context.Context, r io.Reader, limit int64) ([]byte, error)

ReadLimited 读取 r 的全部内容,超过 limit 字节即报错。

func ResolveInRoot

func ResolveInRoot(root, name string) (string, error)

ResolveInRoot 把 name 解析到 root 之内,并拒绝任何路径逃逸。

只允许单一段文件名:含分隔符、绝对路径、"." 或 ".." 的名字一律拒绝。这样 name 就无法表达 "../../etc/passwd" 这类意图,也不需要在拼好路径后再做前缀 判断(前缀判断会被 root 为 "/" 之类的边界情况骗到)。

func SanitizeName

func SanitizeName(name string) string

SanitizeName 把外部来源的文件名收敛成一个安全的单段文件名:去掉路径成分与控制 字符。结果为空时返回空串,由调用方补默认名。

Types

type BufferSink

type BufferSink struct {
	Limit int64
	// contains filtered or unexported fields
}

BufferSink 把结果留在内存里,用于把转换结果直接回传响应体。

func NewBufferSink

func NewBufferSink(limit int64) *BufferSink

NewBufferSink 构造内存目标,limit 为 0 时用 DefaultMaxBytes。

func (*BufferSink) Bytes

func (s *BufferSink) Bytes() []byte

Bytes 返回已写入的内容。

func (*BufferSink) MaxBytesLimit

func (s *BufferSink) MaxBytesLimit() int64

func (*BufferSink) Put

func (s *BufferSink) Put(ctx context.Context, name string, r io.Reader) (Location, error)

type BytesSource

type BytesSource struct {
	Data []byte
	File string
	// contains filtered or unexported fields
}

BytesSource 是内存来源,用于测试与小文件。

func NewBytesSource

func NewBytesSource(data []byte, file string) *BytesSource

NewBytesSource 以给定文件名构造内存来源。

func (*BytesSource) Close

func (s *BytesSource) Close() error

func (*BytesSource) Name

func (s *BytesSource) Name() string

func (*BytesSource) Open

type DirSink

type DirSink struct {
	// Root 是允许写入的根目录。Put 会拒绝任何逃出该目录的 name。
	Root string
	// MaxBytes 单文件上限,0 时用 DefaultMaxBytes。
	MaxBytes int64
	// Overwrite 为真时允许覆盖已存在的普通文件。默认只新建:既避免误覆盖,也
	// 顺带杜绝了"目标是指向目录外的符号链接"这一类写入劫持。
	Overwrite bool
}

DirSink 把结果写入本地目录。

func NewDirSink

func NewDirSink(root string) *DirSink

NewDirSink 构造目录目标。

func (*DirSink) MaxBytesLimit

func (s *DirSink) MaxBytesLimit() int64

func (*DirSink) Put

func (s *DirSink) Put(ctx context.Context, name string, r io.Reader) (Location, error)

type FTPSink

type FTPSink struct {
	// Addr 是服务器地址,形如 "ftp.example.com:21"。
	Addr string
	// User 与 Password 是登录凭据。不会出现在任何日志里。
	User     string
	Password string
	// BaseDir 是允许写入的远端根目录。留空表示远端用户登录后的家目录。
	BaseDir string
	// TLSConfig 非 nil 时使用隐式 FTPS(连上直接握手)。默认使用显式 FTPS
	// (先读 220,再用 AUTH TLS 升级),这是更常见地部署形态。
	TLSConfig *tls.Config
	// Implicit 为真时使用隐式 FTPS;为假时使用显式 FTPS。
	Implicit bool
	// Insecure 允许明文 FTP。留空 TLSConfig 且置真时使用。生产环境不应开启。
	Insecure bool
	// Timeout 是连接、登录与单个操作的超时,0 时取 DefaultFTPTimeout。
	Timeout time.Duration
	// MaxBytes 单文件上限,0 时用 DefaultMaxBytes。
	MaxBytes int64
	// Overwrite 为真时允许覆盖远端同名文件。默认拒绝,避免误覆盖别人的文件。
	Overwrite bool
	// DisableEPSV 强制只用 PASV。少数服务器没实现 EPSV,而客户端默认优先
	// 试它,失败后能否回落到 PASV 取决于服务器实现。
	DisableEPSV bool

	// OwnsPool 区分注册目标与 WithBaseDir 派生的实例。只有前者关池。
	//
	// 外部(cmd/ofd-server)从配置构造注册目标时置 true;派生实例不带它。
	OwnsPool bool

	// MaxIdle 与 IdleTTL 覆盖池的默认参数,0 表示用默认值。
	//
	// 这个结构体不参与序列化(配置类型在 cmd/ofd-server),所以不带 json 标签。
	MaxIdle int
	IdleTTL time.Duration
	// contains filtered or unexported fields
}

FTPSink 把转换结果上传到 FTP/FTPS 服务器。

三个安全取舍,都是有代价的选择:

  1. **默认要求 TLS**。FTP 的控制通道和文件内容都是明文,账号密码会直接暴露在 链路上。确实要在内网用明文时把 TLS 置空并在配置里显式声明,服务启动时会 记一条 WARN——让这个决定是自觉做的,而不是默认忽略的。

  2. **不信任 PASV 返回的地址**。被动模式下服务器会告诉客户端"把数据连到这个 地址"。如果照着连,一个被攻陷或恶意的 FTP 服务器就能把服务的数据流引到 内网任意地址。jlaffaye/ftp 默认不信任,这里也刻意不打开 DialWithTrustPasvIP,代价是少数配置了 PASV 外部地址的服务器用不了。

  3. **限制单文件大小**。读多少传多少,不先把内容读进内存,也拒绝超限的上传, 避免一个失控的转换把远端磁盘写满。

func (*FTPSink) Close

func (s *FTPSink) Close() error

Close 关闭连接。

Sink 接口没有 Close,调用方用可选的 io.Closer 断言来收尾;不实现也可以, 只是每次 PUT 都要重新握手。

func (*FTPSink) MaxBytesLimit

func (s *FTPSink) MaxBytesLimit() int64

func (*FTPSink) Put

func (s *FTPSink) Put(ctx context.Context, name string, r io.Reader) (Location, error)

Put 上传一个文件。name 只允许单一段文件名,父目录按 BaseDir 逐级创建。

func (*FTPSink) SetLogger

func (s *FTPSink) SetLogger(log *slog.Logger)

SetLogger 注入日志器。

func (*FTPSink) WithBaseDir

func (s *FTPSink) WithBaseDir(sub string) (*FTPSink, error)

WithBaseDir 派生一份把 BaseDir 换成 sub 的副本。

必须是副本而不是就地改:FTP 目标在配置里是长期存在的单例,多个任务并发使用 时就地改 BaseDir 会互相踩。连接也随之独立,避免一个任务 Close 掉另一个任务 正在用的连接。

sub 含 ".." 时报错,不做静默规整。

type FileSource

type FileSource struct {
	Path string
	// Keep 为真时 Close 不删除文件,用于复用已存在的样本文件。
	Keep bool
}

FileSource 是磁盘文件来源。Close 会删除该文件,因此路径必须由服务端创建、 而不是调用方指定。

func NewFileSource

func NewFileSource(path string, keep bool) *FileSource

NewFileSource 构造磁盘来源,keep 为真表示不删除。

func (*FileSource) Close

func (s *FileSource) Close() error

func (*FileSource) Name

func (s *FileSource) Name() string

func (*FileSource) Open

type HTTPAudit

type HTTPAudit struct {
	// Requested 是调用方给出的原始地址。
	Requested string
	// Fetched 是最终请求的地址(跟随重定向后会变)。
	Fetched string
	// Resolved 是拨号时校验通过的 IP 列表。
	Resolved []string
	// StatusCode 是响应状态。
	StatusCode int
	// Bytes 是读取到的字节数。
	Bytes int64
}

HTTPAudit 记录一次 URL 拉取的实际去向,出问题时用来追溯。

type HTTPSource

type HTTPSource struct {
	// URL 是要拉取的地址。
	URL string
	// Allowlist 限定允许访问的主机与网段。空白名单下 Open 恒返回错误。
	Allowlist *allowlist.List
	// MaxBytes 响应体上限,0 时用 DefaultMaxBytes。
	MaxBytes int64
	// Timeout 整体超时,0 时用 30s。
	Timeout time.Duration
	// AllowRedirect 为真时跟随重定向。每跳仍会经过拨号时的白名单校验,因此不会
	// 绕过限制;但重定向会把原始地址泄露给第三方,默认关闭。
	AllowRedirect bool
	// FileName 覆盖推导出的文件名。为空时从 Content-Disposition 与 URL 路径取。
	FileName string
	// UserAgent 供部分站点识别客户端。
	UserAgent string
	// Log 记录实际拉取的地址与解析结果,便于审计。
	Log *slog.Logger
	// contains filtered or unexported fields
}

HTTPSource 按 URL 拉取待转换文档。拉取方向由调用方给出地址,因此必须经过 allowlist 校验,否则服务会被当作跳板去请求内网地址或云元数据端点。

func (*HTTPSource) Audit

func (s *HTTPSource) Audit() *HTTPAudit

Audit 返回最近一次 Open 的审计信息,Open 之前调用返回 nil。

func (*HTTPSource) Close

func (s *HTTPSource) Close() error

func (*HTTPSource) Name

func (s *HTTPSource) Name() string

Name 返回推导出的文件名。URL 无法推导时返回空,调用方需要显式指定格式。

func (*HTTPSource) Open

func (s *HTTPSource) Open(ctx context.Context) (io.ReadCloser, error)

Open 拉取内容。响应体被包装成受上限约束的 ReadCloser,由调用方关闭。

type LimitedSink

type LimitedSink interface {
	Sink
	MaxBytesLimit() int64
}

LimitedSink 表示对单个输出文件有明确大小上限的 Sink。 上限由转换服务在写入阶段执行,避免完整结果先缓存到内存后才失败。

type Location

type Location struct {
	// Kind 为 "stream"、"dir" 或后续实现的 "s3"、"ftp"。
	Kind string `json:"kind"`
	// Path 是本地绝对路径(Kind 为 "dir" 时)。
	Path string `json:"path,omitempty"`
	// Bucket 与 Key 用于对象存储。
	Bucket string `json:"bucket,omitempty"`
	Key    string `json:"key,omitempty"`
	// URL 是可直接访问的地址,由具体后端可选地填充。
	URL string `json:"url,omitempty"`
	// Size 是写入的字节数。
	Size int64 `json:"size"`
}

Location 描述转换结果的存放位置,出现在任务状态与通知回调里。

type MinioSink

type MinioSink struct {
	// Endpoint 是 S3 端点的主机名(不含协议),如 "minio.internal:9000"。
	Endpoint string
	// Secure 为真时用 https。为假时用 http——S3 的签名本身防篡改,但
	// 流量与凭据仍是明文,仅适用于内网。
	Secure bool
	// Region 是区域标识。多数自建部署可留空。
	Region string
	// AccessKey 与 SecretKey 是访问凭据。
	AccessKey string
	SecretKey string
	// SessionToken 是临时凭据的令牌,永久凭据留空。
	SessionToken string
	// Bucket 是目标桶。
	Bucket string
	// BasePrefix 是桶内的前缀,等价于 FTP 的 BaseDir。
	BasePrefix string
	// Timeout 是单次请求超时,0 时取 DefaultMinioTimeout。
	Timeout time.Duration
	// MaxBytes 单对象上限,0 时用 DefaultMaxBytes。
	MaxBytes int64
	// Overwrite 为真时允许覆盖同名对象。默认拒绝。
	Overwrite bool
	// contains filtered or unexported fields
}

MinioSink 把转换结果写入 S3 兼容的对象存储(MinIO、AWS S3、Ceph RGW 等)。

三点与 FTP sink 的共同考量:

  1. **凭据不上线**。地址、bucket、access key 都只存在于服务端配置里,调用方 只能给一个目标名。

  2. **先探测再写**。对象存储没有"目录"概念,父目录是靠前缀隐含的,写入不会 失败于"目录不存在"。但重复覆盖会静默成功,因此默认仍拒绝覆盖同名对象。

  3. **限制单对象大小**,与 FTP sink 同理。

func (*MinioSink) Close

func (s *MinioSink) Close() error

Close 释放客户端持有的连接。

func (*MinioSink) EndpointURL

func (s *MinioSink) EndpointURL() string

EndpointURL 拼出对象的可访问地址,用于任务状态展示。

func (*MinioSink) MaxBytesLimit

func (s *MinioSink) MaxBytesLimit() int64

func (*MinioSink) Put

func (s *MinioSink) Put(ctx context.Context, name string, r io.Reader) (Location, error)

Put 写入一个对象。

func (*MinioSink) SetLogger

func (s *MinioSink) SetLogger(log *slog.Logger)

SetLogger 注入日志器。

func (*MinioSink) WithBasePrefix

func (s *MinioSink) WithBasePrefix(prefix string) (*MinioSink, error)

WithBasePrefix 派生一份换掉前缀的副本,并拒绝含 ".." 的前缀。

type SFTPAuth

type SFTPAuth struct {
	// PrivateKeyFile 是私钥文件路径(PEM 或 OpenSSH 新格式)。
	PrivateKeyFile string
	// PrivateKeyPassphrase 是私钥口令。留空表示私钥未加密。
	// 加密私钥且这里留空时连接会失败——这是有意的:不想把口令写进配置文件,
	// 就该走 AgentSocket。
	PrivateKeyPassphrase string
	// AgentSocket 是 ssh-agent 的 socket 路径。配置里只有这个路径,
	// 私钥与已解密的口令都由 agent 持有,是最干净的一种。
	AgentSocket string
	// Password 是口令认证的密码。
	Password string
}

SFTPAuth 描述 SFTP 的登录方式。

私钥文件与 agent 都不需要把密钥材料放进服务配置;口令是最后手段,配置文件 里的口令应当等同于明文存储。

type SFTPSink

type SFTPSink struct {
	// Addr 是服务器地址,形如 "sftp.example.com:22"。
	Addr string
	// User 是登录用户名。
	User string
	// Auth 是认证方式。三种可叠加,客户端会按顺序尝试。
	Auth SFTPAuth
	// BaseDir 是允许写入的远端目录,空表示登录用户的家目录。
	BaseDir string
	// HostKeySHA256 是预期的主机密钥指纹(SHA256:...),与 OpenSSH 一致。
	HostKeySHA256 string
	// HostKeyFile 是 known_hosts 文件路径。
	HostKeyFile string
	// InsecureIgnoreHostKey 跳过主机密钥校验。仅供一次性排查。
	InsecureIgnoreHostKey bool
	// Timeout 是连接、认证与单次操作的超时,0 时取 DefaultSFTPTimeout。
	Timeout time.Duration
	// MaxBytes 单文件上限,0 时用 DefaultMaxBytes。
	MaxBytes int64
	// Overwrite 为真时允许覆盖远端同名文件。默认拒绝。
	Overwrite bool

	// OwnsPool 区分注册目标与 WithBaseDir 派生的实例,只有前者关池。
	OwnsPool bool

	// MaxIdle 与 IdleTTL 覆盖池的默认参数,0 表示用默认值。
	MaxIdle int
	IdleTTL time.Duration
	// contains filtered or unexported fields
}

SFTPSink 把转换结果通过 SFTP 写入远端。

SFTP 跑在 SSH 上,因此天然加密,这点和明文 FTP 有本质区别。但也带来一个 FTP/WebDAV/MinIO 都没有的开关:**主机密钥校验**。

SSH 靠 host key 识别对方。只连不管的话,攻击者可以在中间人位置冒充服务器, 拿到私钥或口令、读到全部文件内容——而连接照样"成功"。所以这里默认必须校验, 三种方式按严格程度排:

  1. HostKeySHA256:配置里写死目标主机的指纹(ssh-keygen 打印的那个)。
  2. HostKeyFile:读 OpenSSH 的 known_hosts 文件。
  3. InsecureIgnoreHostKey:跳过校验。必须显式声明,配置校验也会拦一次。

三种都不配时报错,而不是退回"跳过校验"——那正是最容易出事的情况。

func (*SFTPSink) Close

func (s *SFTPSink) Close() error

Close 关闭连接。

Sink 接口没有 Close,调用方用可选的 io.Closer 断言来收尾;不实现也可以, 只是每次 Put 都要重新握手。

func (*SFTPSink) MaxBytesLimit

func (s *SFTPSink) MaxBytesLimit() int64

func (*SFTPSink) Put

func (s *SFTPSink) Put(ctx context.Context, name string, r io.Reader) (Location, error)

Put 上传一个文件,父目录按需创建。

func (*SFTPSink) SetLogger

func (s *SFTPSink) SetLogger(log *slog.Logger)

SetLogger 注入日志器。

func (*SFTPSink) WithBaseDir

func (s *SFTPSink) WithBaseDir(dir string) (*SFTPSink, error)

WithBaseDir 派生一份换掉远端目录的副本,并拒绝含 ".." 的目录。

type Sink

type Sink interface {
	// Put 把 r 的内容写入名为 name 的目标,返回可定位的结果。
	Put(ctx context.Context, name string, r io.Reader) (Location, error)
}

Sink 是转换结果的写入目标。

type Source

type Source interface {
	// Name 返回带扩展名的文件名。转换库按扩展名识别输入格式(docx/xlsx/html 等
	// 无法靠魔数区分),因此这里的扩展名必须是可信的、由服务端按已解析的格式
	// 派生,而不是直接采信调用方的原始文件名。
	Name() string
	// Open 打开内容供读取。调用方负责关闭返回的 ReadCloser。
	Open(ctx context.Context) (io.ReadCloser, error)
	// Close 释放来源占用的资源,对内存来源是空操作。
	Close() error
}

Source 是待转换文档的来源。每次任务独占一个 Source,用完应当调用 Close 释放 其占用的临时文件。

type WebDAVSink

type WebDAVSink struct {
	// Endpoint 是服务地址,如 "https://dav.example.com/remote.php/dav/files/user"。
	Endpoint string
	// User 与 Password 是凭据。不会出现在任何日志里。
	User     string
	Password string
	// BaseDir 是允许写入的远端目录(相对 endpoint 根)。
	BaseDir string

	// Timeout 是单次请求超时,0 时取 DefaultWebDAVTimeout。
	Timeout time.Duration
	// MaxBytes 单文件上限,0 时用 DefaultMaxBytes。
	MaxBytes int64
	// Overwrite 为真时允许覆盖同名文件。默认拒绝。
	Overwrite bool
	// contains filtered or unexported fields
}

WebDAVSink 把转换结果写入 WebDAV 共享。

与 FTP 的差别:WebDAV 跑在 HTTP 上,天然有 TLS、有状态码、有中间件生态, 因此不需要像 FTP 那样处理明文与 PASV 地址。但它同样需要凭据,同样只接受 已注册的目标名,同样限制单文件大小。

func (*WebDAVSink) Close

func (s *WebDAVSink) Close() error

Close 释放客户端。

func (*WebDAVSink) MaxBytesLimit

func (s *WebDAVSink) MaxBytesLimit() int64

func (*WebDAVSink) Put

func (s *WebDAVSink) Put(ctx context.Context, name string, r io.Reader) (Location, error)

Put 写入一个文件,父目录按需创建。

func (*WebDAVSink) SetLogger

func (s *WebDAVSink) SetLogger(log *slog.Logger)

SetLogger 注入日志器。

func (*WebDAVSink) WithBaseDir

func (s *WebDAVSink) WithBaseDir(dir string) (*WebDAVSink, error)

WithBaseDir 派生一份换掉远端目录的副本,并拒绝含 ".." 的目录。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL