siyuan-note/siyuan · error

asset path must be a file

Error message

asset path must be a file

What it means

Thrown by CreateAssetHistory when the resolved assetAbsPath exists but is a directory (info.IsDir() returns true). The function is designed to create history snapshots for individual asset files, not directories. A directory cannot be meaningfully snapshotted as a single asset, so the operation is rejected with "asset path must be a file".

Source

Thrown at kernel/model/history.go:882

// CreateAssetHistory 为指定资源文件创建历史快照。
func CreateAssetHistory(assetPath string) (err error) {
	assetPath = strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(assetPath))), "/")
	if !strings.HasPrefix(assetPath, "assets/") {
		return errors.New("asset path must be under assets")
	}

	assetAbsPath := filepath.Join(util.DataDir, filepath.FromSlash(assetPath))
	assetsDir := filepath.Join(util.DataDir, "assets")
	if !gulu.File.IsSubPath(assetsDir, assetAbsPath) {
		return errors.New("asset path must be under assets")
	}
	info, statErr := os.Stat(assetAbsPath)
	if statErr != nil {
		return statErr
	}
	if info.IsDir() {
		return errors.New("asset path must be a file")
	}
	return createAssetsHistory([]string{assetAbsPath})
}

func createAssetsHistory(assets []string) (err error) {
	historyDir, err := getHistoryDir(HistoryOpUpdate)
	if err != nil {
		return fmt.Errorf("get history directory failed: %w", err)
	}

	for _, file := range assets {
		assetRelPath, relErr := filepath.Rel(filepath.Join(util.DataDir, "assets"), file)
		if relErr != nil || assetRelPath == "." || strings.HasPrefix(assetRelPath, ".."+string(filepath.Separator)) {
			return errors.New("asset path must be under assets")
		}
		historyPath := filepath.Join(historyDir, "assets", assetRelPath)
		if err = os.MkdirAll(filepath.Dir(historyPath), 0755); err != nil {
			return fmt.Errorf("create history directory [%s] failed: %w", filepath.Dir(historyPath), err)

View on GitHub (pinned to 251596fc0d)

Solutions

  1. Pass the path to a specific file within assets/, not a directory path.
  2. If you need to snapshot all files in a directory, iterate the directory and call CreateAssetHistory for each file individually.
  3. As an API client, verify the path resolves to a regular file (not a directory) via os.Stat before calling the API.

Example fix

// before
await post('/api/history/createAssetHistory', { assetPath: 'assets/screenshots/' })

// after — pass a file path, or iterate files in the directory
await post('/api/history/createAssetHistory', { assetPath: 'assets/screenshots/capture.png' })
Defensive patterns

Strategy: validation

Validate before calling

// Verify the asset path resolves to a file, not a directory
const stat = await fs.stat(path.join(dataDir, assetPath))
if (stat.isFile()) {
  await post('/api/history/createAssetHistory', { assetPath })
}

Prevention

When it happens

Trigger: Calling POST /api/history/createAssetHistory with a path that resolves to a directory under assets/ (e.g. "assets/subfolder/" where subfolder is a directory). The path passes both the prefix check and the subpath check, but os.Stat reveals it is a directory.

Common situations: User or API client passes a directory path instead of a file path; the assets directory contains subdirectories (e.g. for organizing assets) and the caller references a subdirectory rather than a file within it; a trailing slash causes the path to resolve to a directory.

Related errors


AI-assisted analysis of siyuan-note/siyuan@251596fc0d (2026-08-12). Data as JSON: /api/errors/9c717eba0881b5ef. Report an issue: GitHub.