package files import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "os" "path/filepath" "sort" "strings" "sync" "time" "unicode/utf8" "zlh-agent/internal/mods" "zlh-agent/internal/state" ) const ( MaxListEntries = 200 MaxReadSize = 2 * 1024 * 1024 MaxWriteSize = 1 * 1024 * 1024 binaryProbeLen = 8192 shadowDirName = ".zlh-shadow" metadataFileName = ".zlh_metadata.json" shadowCleanupEvery = 6 * time.Hour shadowRetention = 7 * 24 * time.Hour shadowOriginalName = "original" shadowMetadataName = "metadata.json" MaxModUploadSize = 250 * 1024 * 1024 MaxDataPackSize = 100 * 1024 * 1024 ) var ( ErrInvalidPath = errors.New("invalid path") ErrPathEscape = errors.New("path outside runtime root") ErrForbiddenPath = errors.New("path not allowed") ErrNotFile = errors.New("path is not a file") ErrNotDir = errors.New("path is not a directory") ErrTooLarge = errors.New("file exceeds 2MB limit") ErrWriteTooLarge = errors.New("file exceeds 1MB limit") ErrBinaryFile = errors.New("binary file") ErrDeleteDenied = errors.New("delete not allowed for this path") ErrWriteDenied = errors.New("write not allowed for this path") ErrUploadDenied = errors.New("upload denied") ErrAlreadyExists = errors.New("file already exists") shadowCleanupOnce sync.Once ) type Entry struct { Name string `json:"name"` Path string `json:"path"` Type string `json:"type"` Size int64 `json:"size"` Modified string `json:"modified"` } type StatResponse struct { Name string `json:"name"` Path string `json:"path"` Type string `json:"type"` Size int64 `json:"size"` Modified string `json:"modified"` IsWritable bool `json:"isWritable"` HasBackup bool `json:"hasBackup"` Source *string `json:"source"` } type ListResponse struct { Path string `json:"path"` Entries []Entry `json:"entries"` Limit int `json:"limit"` Truncated bool `json:"truncated"` } type ReadResponse struct { Path string `json:"path"` Size int64 `json:"size"` Content string `json:"content"` Truncated bool `json:"truncated"` } type shadowMetadata struct { Path string `json:"path"` CreatedAt string `json:"created_at"` LastModifiedAt string `json:"last_modified_at"` } type Meta struct { Source string `json:"source"` UploadedAt time.Time `json:"uploaded_at"` } func RuntimeRoot(cfg *state.Config) string { return mods.ResolveServerRoot(cfg) } func NormalizeVisiblePath(rel string) (string, error) { return normalizeAndCheckVisibleRel(rel) } func StartShadowCleanup() { shadowCleanupOnce.Do(func() { go func() { ticker := time.NewTicker(shadowCleanupEvery) defer ticker.Stop() cleanupShadowRootFromConfig() for range ticker.C { cleanupShadowRootFromConfig() } }() }) } func ResolveWorldPath(root, rel string) (string, string, error) { resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) if err != nil { return "", "", err } normalizedRel, err := normalizeRelativePath(rel) if err != nil { return "", "", err } if isHiddenInternalPath(normalizedRel) { return "", "", ErrForbiddenPath } joined := filepath.Join(resolvedRoot, filepath.FromSlash(normalizedRel)) resolvedPath, err := filepath.EvalSymlinks(joined) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", normalizedRel, os.ErrNotExist } return "", "", err } resolvedPath = filepath.Clean(resolvedPath) if !withinRoot(resolvedRoot, resolvedPath) { return "", "", ErrPathEscape } finalRel, err := filepath.Rel(resolvedRoot, resolvedPath) if err != nil { return "", "", err } if finalRel == "." { finalRel = "" } finalRel = filepath.ToSlash(finalRel) if isHiddenInternalPath(finalRel) { return "", "", ErrForbiddenPath } return resolvedPath, finalRel, nil } func List(root, rel string) (ListResponse, error) { resolvedPath, responsePath, err := ResolveWorldPath(root, rel) if err != nil { return ListResponse{}, err } info, err := os.Stat(resolvedPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return ListResponse{}, os.ErrNotExist } return ListResponse{}, err } if !info.IsDir() { return ListResponse{}, ErrNotDir } entries, err := os.ReadDir(resolvedPath) if err != nil { return ListResponse{}, err } sort.Slice(entries, func(i, j int) bool { leftIsDir := dirEntryIsDir(entries[i]) rightIsDir := dirEntryIsDir(entries[j]) if leftIsDir != rightIsDir { return leftIsDir } return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) }) respEntries := make([]Entry, 0, min(len(entries), MaxListEntries)) truncated := len(entries) > MaxListEntries for _, entry := range entries { if len(respEntries) >= MaxListEntries { break } if isHiddenInternalName(entry.Name()) { continue } childRel := entry.Name() if responsePath != "" { childRel = filepath.ToSlash(filepath.Join(responsePath, entry.Name())) } childPath, childRespPath, err := ResolveWorldPath(root, childRel) if err != nil { continue } childInfo, err := os.Stat(childPath) if err != nil { continue } entryType := "file" if childInfo.IsDir() { entryType = "dir" } respEntries = append(respEntries, Entry{ Name: entry.Name(), Path: childRespPath, Type: entryType, Size: childInfo.Size(), Modified: childInfo.ModTime().UTC().Format(time.RFC3339), }) } return ListResponse{ Path: responsePath, Entries: respEntries, Limit: MaxListEntries, Truncated: truncated, }, nil } func Stat(root, rel string) (StatResponse, error) { resolvedPath, responsePath, err := ResolveWorldPath(root, rel) if err != nil { return StatResponse{}, err } info, err := os.Stat(resolvedPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return StatResponse{}, os.ErrNotExist } return StatResponse{}, err } entryType := "file" if info.IsDir() { entryType = "dir" } name := filepath.Base(resolvedPath) if responsePath == "" { name = filepath.Base(filepath.Clean(root)) } return StatResponse{ Name: name, Path: responsePath, Type: entryType, Size: info.Size(), Modified: info.ModTime().UTC().Format(time.RFC3339), IsWritable: isWritablePath(root, responsePath), HasBackup: hasBackup(root, responsePath), Source: sourceForPath(root, responsePath), }, nil } func Read(root, rel string) (ReadResponse, error) { resolvedPath, responsePath, err := ResolveWorldPath(root, rel) if err != nil { return ReadResponse{}, err } info, err := os.Stat(resolvedPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return ReadResponse{}, os.ErrNotExist } return ReadResponse{}, err } if info.IsDir() { return ReadResponse{}, ErrNotFile } if info.Size() > MaxReadSize { return ReadResponse{}, ErrTooLarge } data, err := os.ReadFile(resolvedPath) if err != nil { return ReadResponse{}, err } if isBinary(data) { return ReadResponse{}, ErrBinaryFile } return ReadResponse{ Path: responsePath, Size: int64(len(data)), Content: string(data), Truncated: false, }, nil } func OpenDownload(root, rel string) (*os.File, os.FileInfo, string, error) { resolvedPath, _, err := ResolveWorldPath(root, rel) if err != nil { return nil, nil, "", err } info, err := os.Stat(resolvedPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil, "", os.ErrNotExist } return nil, nil, "", err } if info.IsDir() { return nil, nil, "", ErrNotFile } file, err := os.Open(resolvedPath) if err != nil { return nil, nil, "", err } return file, info, filepath.Base(resolvedPath), nil } func Delete(root, rel string) (string, error) { resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) if err != nil { return "", err } normalizedRel, err := normalizeAndCheckVisibleRel(rel) if err != nil { return "", err } if !isAllowedDelete(normalizedRel) { return "", ErrDeleteDenied } resolvedPath, err := resolveExistingNoFollowFinal(resolvedRoot, normalizedRel) if err != nil { return "", err } info, err := os.Lstat(resolvedPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", os.ErrNotExist } return "", err } if info.IsDir() { return "", ErrDeleteDenied } if info.Mode()&os.ModeSymlink != 0 { targetPath, err := filepath.EvalSymlinks(resolvedPath) if err != nil && !errors.Is(err, os.ErrNotExist) { return "", err } if err == nil && !withinRoot(resolvedRoot, filepath.Clean(targetPath)) { return "", ErrPathEscape } } if err := os.Remove(resolvedPath); err != nil { if errors.Is(err, os.ErrNotExist) { return "", os.ErrNotExist } return "", err } return normalizedRel, nil } func Write(root, rel string, data []byte) (bool, error) { resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) if err != nil { return false, err } normalizedRel, err := normalizeAndCheckVisibleRel(rel) if err != nil { return false, err } if !isAllowedWrite(normalizedRel) { return false, ErrWriteDenied } if len(data) > MaxWriteSize { return false, ErrWriteTooLarge } if isBinary(data) { return false, ErrBinaryFile } resolvedPath, err := resolveExistingNoFollowFinal(resolvedRoot, normalizedRel) if err != nil { return false, err } info, err := os.Lstat(resolvedPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return false, os.ErrNotExist } return false, err } if info.IsDir() { return false, ErrWriteDenied } if info.Mode()&os.ModeSymlink != 0 { return false, ErrWriteDenied } backupCreated, err := ensureShadow(resolvedRoot, normalizedRel, resolvedPath) if err != nil { return false, err } if err := writeAtomic(resolvedPath, data, info.Mode().Perm()); err != nil { return false, err } if err := updateShadowMetadata(resolvedRoot, normalizedRel, backupCreated); err != nil { return false, err } return backupCreated, nil } func Revert(root, rel string) (string, error) { resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) if err != nil { return "", err } normalizedRel, err := normalizeAndCheckVisibleRel(rel) if err != nil { return "", err } if !isAllowedWrite(normalizedRel) { return "", ErrWriteDenied } resolvedPath, err := resolveForRestore(resolvedRoot, normalizedRel) if err != nil { return "", err } if info, err := os.Lstat(resolvedPath); err == nil { if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return "", ErrWriteDenied } } else if !errors.Is(err, os.ErrNotExist) { return "", err } shadowDir := shadowPath(resolvedRoot, normalizedRel) originalPath := filepath.Join(shadowDir, shadowOriginalName) originalInfo, err := os.Stat(originalPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", os.ErrNotExist } return "", err } if originalInfo.IsDir() { return "", os.ErrNotExist } data, err := os.ReadFile(originalPath) if err != nil { return "", err } if err := writeAtomic(resolvedPath, data, originalInfo.Mode().Perm()); err != nil { return "", err } if err := os.RemoveAll(shadowDir); err != nil { return "", err } return normalizedRel, nil } func Upload(root, rel string, r io.Reader, sizeLimit int64, overwrite bool) (int64, bool, error) { resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) if err != nil { return 0, false, err } normalizedRel, err := normalizeAndCheckVisibleRel(rel) if err != nil { return 0, false, err } allowedLimit, ok := uploadLimitForPath(normalizedRel) if !ok { return 0, false, ErrUploadDenied } if sizeLimit <= 0 || sizeLimit > allowedLimit { sizeLimit = allowedLimit } resolvedPath, err := resolveUploadTarget(resolvedRoot, normalizedRel) if err != nil { return 0, false, err } overwritten := false if info, err := os.Lstat(resolvedPath); err == nil { if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return 0, false, ErrUploadDenied } if !overwrite { return 0, false, ErrAlreadyExists } overwritten = true } else if !errors.Is(err, os.ErrNotExist) { return 0, false, err } dir := filepath.Dir(resolvedPath) tmp, err := os.CreateTemp(dir, ".zlh-upload-*") if err != nil { return 0, false, err } tmpPath := tmp.Name() defer func() { _ = os.Remove(tmpPath) }() limited := io.LimitReader(r, sizeLimit+1) written, err := io.Copy(tmp, limited) if closeErr := tmp.Close(); err == nil && closeErr != nil { err = closeErr } if err != nil { return 0, false, err } if written > sizeLimit { return 0, false, ErrWriteTooLarge } if err := os.Chmod(tmpPath, 0o644); err != nil { return 0, false, err } if err := os.Rename(tmpPath, resolvedPath); err != nil { return 0, false, err } if err := updateMetadata(resolvedRoot, normalizedRel); err != nil { return 0, false, err } return written, overwritten, nil } func shadowPath(root, rel string) string { sum := sha256.Sum256([]byte(rel)) return filepath.Join(root, shadowDirName, hex.EncodeToString(sum[:])) } func normalizeRelativePath(rel string) (string, error) { rel = strings.TrimSpace(rel) if rel == "" || rel == "." { return "", nil } if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") { return "", ErrInvalidPath } for _, r := range rel { if r == 0 || r < 32 || r == 127 { return "", ErrInvalidPath } } cleaned := filepath.Clean(rel) if cleaned == "." { return "", nil } if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { return "", ErrInvalidPath } return filepath.ToSlash(cleaned), nil } func withinRoot(root, target string) bool { rel, err := filepath.Rel(root, target) if err != nil { return false } return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) } func dirEntryIsDir(entry os.DirEntry) bool { if entry.IsDir() { return true } if entry.Type()&os.ModeSymlink != 0 { if info, err := entry.Info(); err == nil { return info.IsDir() } } return false } func isBinary(data []byte) bool { if len(data) == 0 { return false } probe := data if len(probe) > binaryProbeLen { probe = probe[:binaryProbeLen] } if bytes.IndexByte(probe, 0) >= 0 { return true } if !utf8.Valid(probe) { return true } return false } func isAllowedDelete(rel string) bool { parts := strings.Split(rel, "/") if len(parts) != 2 { return false } switch parts[0] { case "mods-removed", "mods-uploaded": return parts[1] != "" case "logs": return strings.HasSuffix(parts[1], ".log") || strings.HasSuffix(parts[1], ".log.gz") default: return false } } func isAllowedWrite(rel string) bool { parts := strings.Split(rel, "/") if len(parts) == 1 { return parts[0] == "server.properties" } if len(parts) != 2 || parts[0] != "config" { return false } ext := strings.ToLower(filepath.Ext(parts[1])) switch ext { case ".toml", ".json", ".properties": return true default: return false } } func uploadLimitForPath(rel string) (int64, bool) { parts := strings.Split(rel, "/") switch { case len(parts) == 2 && parts[0] == "mods" && strings.HasSuffix(strings.ToLower(parts[1]), ".jar"): return MaxModUploadSize, true case len(parts) == 3 && parts[0] == "world" && parts[1] == "datapacks" && strings.HasSuffix(strings.ToLower(parts[2]), ".zip"): return MaxDataPackSize, true default: return 0, false } } func isWritablePath(root, rel string) bool { if !isAllowedWrite(rel) { return false } resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) if err != nil { return false } resolvedPath, err := resolveExistingNoFollowFinal(resolvedRoot, rel) if err != nil { return false } info, err := os.Lstat(resolvedPath) if err != nil { return false } if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return false } return true } func hasBackup(root, rel string) bool { if rel == "" { return false } resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) if err != nil { return false } originalPath := filepath.Join(shadowPath(resolvedRoot, rel), shadowOriginalName) info, err := os.Stat(originalPath) if err != nil { return false } return !info.IsDir() } func sourceForPath(root, rel string) *string { if rel == "" { return nil } resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) if err != nil { return nil } meta, err := loadMetadata(resolvedRoot) if err != nil { return nil } entry, ok := meta[rel] if !ok || strings.TrimSpace(entry.Source) == "" { return nil } source := entry.Source return &source } func min(a, b int) int { if a < b { return a } return b } func normalizeAndCheckVisibleRel(rel string) (string, error) { normalizedRel, err := normalizeRelativePath(rel) if err != nil { return "", err } if isHiddenInternalPath(normalizedRel) { return "", ErrForbiddenPath } return normalizedRel, nil } func isShadowPath(rel string) bool { if rel == "" { return false } return rel == shadowDirName || strings.HasPrefix(rel, shadowDirName+"/") } func isHiddenInternalPath(rel string) bool { if rel == "" { return false } return isShadowPath(rel) || rel == metadataFileName } func isHiddenInternalName(name string) bool { return name == shadowDirName || name == metadataFileName } func resolveExistingNoFollowFinal(resolvedRoot, rel string) (string, error) { candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel)) parentPath := filepath.Dir(candidate) resolvedParent, err := filepath.EvalSymlinks(parentPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", os.ErrNotExist } return "", err } resolvedParent = filepath.Clean(resolvedParent) if !withinRoot(resolvedRoot, resolvedParent) { return "", ErrPathEscape } resolvedPath := filepath.Clean(filepath.Join(resolvedParent, filepath.Base(candidate))) if !withinRoot(resolvedRoot, resolvedPath) { return "", ErrPathEscape } return resolvedPath, nil } func resolveForRestore(resolvedRoot, rel string) (string, error) { candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel)) parentPath := filepath.Dir(candidate) resolvedParent, err := filepath.EvalSymlinks(parentPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", os.ErrNotExist } return "", err } resolvedParent = filepath.Clean(resolvedParent) if !withinRoot(resolvedRoot, resolvedParent) { return "", ErrPathEscape } resolvedPath := filepath.Clean(filepath.Join(resolvedParent, filepath.Base(candidate))) if !withinRoot(resolvedRoot, resolvedPath) { return "", ErrPathEscape } return resolvedPath, nil } func resolveUploadTarget(resolvedRoot, rel string) (string, error) { candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel)) parentPath := filepath.Dir(candidate) resolvedParent, err := filepath.EvalSymlinks(parentPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", os.ErrNotExist } return "", err } resolvedParent = filepath.Clean(resolvedParent) if !withinRoot(resolvedRoot, resolvedParent) { return "", ErrPathEscape } parentInfo, err := os.Stat(resolvedParent) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", os.ErrNotExist } return "", err } if !parentInfo.IsDir() { return "", ErrUploadDenied } resolvedPath := filepath.Clean(filepath.Join(resolvedParent, filepath.Base(candidate))) if !withinRoot(resolvedRoot, resolvedPath) { return "", ErrPathEscape } return resolvedPath, nil } func ensureShadow(resolvedRoot, rel, resolvedPath string) (bool, error) { shadowDir := shadowPath(resolvedRoot, rel) originalPath := filepath.Join(shadowDir, shadowOriginalName) if _, err := os.Stat(originalPath); err == nil { return false, nil } else if !errors.Is(err, os.ErrNotExist) { return false, err } if err := os.MkdirAll(shadowDir, 0o755); err != nil { return false, err } data, err := os.ReadFile(resolvedPath) if err != nil { return false, err } info, err := os.Stat(resolvedPath) if err != nil { return false, err } if err := os.WriteFile(originalPath, data, info.Mode().Perm()); err != nil { return false, err } now := time.Now().UTC() meta := shadowMetadata{ Path: rel, CreatedAt: now.Format(time.RFC3339), LastModifiedAt: now.Format(time.RFC3339), } if err := writeShadowMetadataFile(filepath.Join(shadowDir, shadowMetadataName), meta); err != nil { return false, err } return true, nil } func updateShadowMetadata(resolvedRoot, rel string, backupCreated bool) error { shadowDir := shadowPath(resolvedRoot, rel) metaPath := filepath.Join(shadowDir, shadowMetadataName) now := time.Now().UTC() meta := shadowMetadata{ Path: rel, CreatedAt: now.Format(time.RFC3339), LastModifiedAt: now.Format(time.RFC3339), } if !backupCreated { if existing, err := readShadowMetadata(metaPath); err == nil { meta = existing if strings.TrimSpace(meta.Path) == "" { meta.Path = rel } if strings.TrimSpace(meta.CreatedAt) == "" { meta.CreatedAt = now.Format(time.RFC3339) } meta.LastModifiedAt = now.Format(time.RFC3339) } } return writeShadowMetadataFile(metaPath, meta) } func writeShadowMetadataFile(path string, meta shadowMetadata) error { data, err := json.Marshal(meta) if err != nil { return err } data = append(data, '\n') return os.WriteFile(path, data, 0o644) } func readShadowMetadata(path string) (shadowMetadata, error) { data, err := os.ReadFile(path) if err != nil { return shadowMetadata{}, err } var meta shadowMetadata if err := json.Unmarshal(data, &meta); err != nil { return shadowMetadata{}, err } return meta, nil } func writeAtomic(dest string, data []byte, mode os.FileMode) error { dir := filepath.Dir(dest) tmp, err := os.CreateTemp(dir, ".zlh-write-*") if err != nil { return err } tmpPath := tmp.Name() defer func() { _ = os.Remove(tmpPath) }() if _, err := tmp.Write(data); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Chmod(tmpPath, mode); err != nil { return err } return os.Rename(tmpPath, dest) } func cleanupShadowRootFromConfig() { cfg, err := state.LoadConfig() if err != nil { return } cleanupShadowRoot(RuntimeRoot(cfg)) } func cleanupShadowRoot(root string) { resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) if err != nil { return } base := filepath.Join(resolvedRoot, shadowDirName) entries, err := os.ReadDir(base) if err != nil { return } now := time.Now().UTC() for _, entry := range entries { if !entry.IsDir() { continue } shadowDir := filepath.Join(base, entry.Name()) meta, err := readShadowMetadata(filepath.Join(shadowDir, shadowMetadataName)) if err != nil { continue } ts, err := time.Parse(time.RFC3339, strings.TrimSpace(meta.LastModifiedAt)) if err != nil { continue } if now.Sub(ts) > shadowRetention { _ = os.RemoveAll(shadowDir) } } } func loadMetadata(root string) (map[string]Meta, error) { path := filepath.Join(root, metadataFileName) data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return map[string]Meta{}, nil } return nil, err } var meta map[string]Meta if err := json.Unmarshal(data, &meta); err != nil { return nil, err } if meta == nil { meta = map[string]Meta{} } return meta, nil } func writeMetadata(root string, data map[string]Meta) error { payload, err := json.Marshal(data) if err != nil { return err } payload = append(payload, '\n') target := filepath.Join(root, metadataFileName) tmp, err := os.CreateTemp(root, ".zlh-metadata-*") if err != nil { return err } tmpPath := tmp.Name() defer func() { _ = os.Remove(tmpPath) }() if _, err := tmp.Write(payload); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Chmod(tmpPath, 0o644); err != nil { return err } return os.Rename(tmpPath, target) } func updateMetadata(root, rel string) error { meta, err := loadMetadata(root) if err != nil { return err } meta[rel] = Meta{ Source: "user", UploadedAt: time.Now().UTC(), } return writeMetadata(root, meta) }