Help us shape the future of Go! Take the 2026 Go Developer Survey

transfer

package
v0.1.5 Latest Latest
Warning

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

Go to latest
Published: Oct 21, 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 指向共享目录、多个任务写同名文件时必然出现的错误。

View Source
var ErrRollback = errors.New("输出回滚失败")

ErrRollback 表示一组输出未能完整发布且部分产物无法回滚。 这种状态不能自动重试,否则下一次会撞上本次残留文件。

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)

func (*BufferSink) Rollback

func (s *BufferSink) Rollback(_ context.Context, location 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)

func (*DirSink) Rollback

func (s *DirSink) Rollback(ctx context.Context, location 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) Rollback

func (s *FTPSink) Rollback(ctx context.Context, location Location) error

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"`
	// contains filtered or unexported fields
}

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) Rollback

func (s *MinioSink) Rollback(ctx context.Context, location Location) error

func (*MinioSink) SetLogger

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

SetLogger 注入日志器。

func (*MinioSink) WithBasePrefix

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

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

type RollbackSink

type RollbackSink interface {
	Sink
	Rollback(context.Context, Location) error
}

RollbackSink 可以删除本次调用成功创建的产物,用于多文件提交失败时回滚。 实现不得删除调用前已存在的目标。

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) Rollback

func (s *SFTPSink) Rollback(ctx context.Context, location Location) error

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) Rollback

func (s *WebDAVSink) Rollback(ctx context.Context, location Location) error

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