Files
docspell-cli/mount.go
T

569 lines
15 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"bazil.org/fuse"
"bazil.org/fuse/fs"
"bazil.org/fuse/fuseutil"
"github.com/integrii/flaggy"
)
type docspellFS struct {
root *dirNode
}
type dirNode struct {
name string
children map[string]fs.Node
}
type fileNode struct {
name string
attachmentID string
size uint64
knownSize bool
modTime time.Time
client *DocspellClient
data []byte
}
type staticFileNode struct {
name string
data []byte
modTime time.Time
}
type mountMetadata struct {
Items map[string]mountedItemMetadata `json:"items"`
}
type mountedItemMetadata struct {
FriendlyPath string `json:"friendlyPath"`
ByIDPath string `json:"byIDPath"`
}
const mountMetadataFileName = ".docspell-cli-metadata.json"
func runMountCommand(args []string, cfg config, password string) error {
options, err := parseMountOptions(args, cfg)
if err != nil {
return err
}
if options.mountPoint == "" {
return fmt.Errorf("mount point missing: pass one or set MountPath in config")
}
if os.Getenv("DOCSPELL_CLI_MOUNT_FOREGROUND") != "1" {
return startMountInBackground(args, options.mountPoint, false)
}
if err := os.MkdirAll(options.mountPoint, 0755); err != nil {
return err
}
client := NewDocspellClient(cfg.DocspellURL, options.authHeader)
if err := client.Login(context.Background(), cfg.User, password); err != nil {
return err
}
items, err := client.SearchItems(context.Background(), options.query, options.limit)
if err != nil {
return fmt.Errorf("search items: %w", err)
}
root := buildMountTree(context.Background(), client, items)
conn, err := fuse.Mount(
options.mountPoint,
fuse.FSName("docspell"),
fuse.Subtype("docspell-cli"),
fuse.ReadOnly(),
)
if err != nil {
return err
}
defer conn.Close()
fmt.Printf("Mounted %d items read-only at %s. Unmount with: fusermount -u %s\n", len(items), options.mountPoint, options.mountPoint)
return fs.Serve(conn, &docspellFS{root: root})
}
type mountOptions struct {
query string
limit int
authHeader string
mountPoint string
}
func parseMountOptions(args []string, cfg config) (mountOptions, error) {
options := mountOptions{
query: cfg.MountQuery,
limit: 1000,
authHeader: defaultDocspellAuthHeader,
mountPoint: cfg.MountPath,
}
parser := flaggy.NewParser("mount")
parser.Description = "Expose Docspell search results as a read-only FUSE filesystem"
parser.DisableShowVersionWithVersion()
parser.ShowHelpOnUnexpected = true
parser.String(&options.query, "", "query", "Docspell item query to expose")
parser.Int(&options.limit, "", "limit", "number of items to fetch per API request")
parser.String(&options.authHeader, "", "auth-header", "HTTP header used for the Docspell auth token")
parser.AddPositionalValue(&options.mountPoint, "mountpoint", 1, false, "mount point, overriding mountPath from config")
return options, parser.ParseArgs(args)
}
func startMountInBackground(args []string, mountPoint string, quiet bool) error {
mountPoint = cleanMountPath(mountPoint)
if isMounted(mountPoint) {
if !quiet {
fmt.Printf("Already mounted at %s\n", mountPoint)
}
return nil
}
cmd := exec.Command(os.Args[0], append([]string{"mount"}, args...)...)
cmd.Env = append(os.Environ(), "DOCSPELL_CLI_MOUNT_FOREGROUND=1")
logFile, err := mountLogFile()
if err != nil {
return err
}
defer logFile.Close()
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
return err
}
if err := cmd.Process.Release(); err != nil {
return err
}
if !quiet {
fmt.Printf("Mounting %s in background. Unmount with: fusermount -u %s\n", mountPoint, mountPoint)
}
return nil
}
func runGetPathCommand(args []string, cfg config, password string) error {
itemIDOrURL, err := parseSingleArgumentCommand("get-path", "item-id-or-url", "Docspell item ID or web URL", args)
if err != nil {
return err
}
mountPoint, err := ensureMounted(cfg, password)
if err != nil {
return err
}
id := itemIDFromURLOrID(itemIDOrURL)
if path, err := friendlyLocalPathForItemID(mountPoint, id); err == nil && path != "" {
fmt.Println(path)
return nil
}
matches, err := filepath.Glob(filepath.Join(mountPoint, "by-id", idShard(id), id+".*"))
if err != nil {
return err
}
if len(matches) == 0 {
return fmt.Errorf("entry %s not found under mounted by-id tree", id)
}
fmt.Println(matches[0])
return nil
}
func friendlyLocalPathForItemID(mountPoint, id string) (string, error) {
metadata, err := readMountMetadata(mountPoint)
if err != nil {
return "", err
}
item, ok := metadata.Items[id]
if !ok || item.FriendlyPath == "" {
return "", nil
}
return filepath.Join(mountPoint, filepath.FromSlash(item.FriendlyPath)), nil
}
func readMountMetadata(mountPoint string) (mountMetadata, error) {
var metadata mountMetadata
data, err := os.ReadFile(filepath.Join(mountPoint, mountMetadataFileName))
if err != nil {
return metadata, err
}
err = json.Unmarshal(data, &metadata)
return metadata, err
}
func runGetURLCommand(args []string, cfg config, password string) error {
id, err := parseSingleArgumentCommand("get-url", "local-path-or-item-id", "local mounted path, item ID, or item URL", args)
if err != nil {
return err
}
if strings.Contains(id, string(os.PathSeparator)) || strings.HasPrefix(id, ".") {
id, err = itemIDFromLocalPath(id, cfg)
if err != nil {
return err
}
}
fmt.Printf("%s/app/item/%s\n", strings.TrimRight(cfg.DocspellURL, "/"), itemIDFromURLOrID(id))
return nil
}
func parseSingleArgumentCommand(commandName, argName, argDescription string, args []string) (string, error) {
var value string
parser := flaggy.NewParser(commandName)
parser.DisableShowVersionWithVersion()
parser.ShowHelpOnUnexpected = true
parser.AddPositionalValue(&value, argName, 1, true, argDescription)
return value, parser.ParseArgs(args)
}
func ensureMounted(cfg config, password string) (string, error) {
mountPoint := cleanMountPath(cfg.MountPath)
if mountPoint == "" {
return "", fmt.Errorf("MountPath missing in config")
}
if isMounted(mountPoint) {
if isStaleMount(mountPoint) {
_ = unmount(mountPoint)
} else {
return mountPoint, nil
}
}
if isMounted(mountPoint) {
return mountPoint, nil
}
if err := startMountInBackground([]string{mountPoint}, mountPoint, true); err != nil {
return "", err
}
for i := 0; i < 300; i++ {
if isMounted(mountPoint) {
return mountPoint, nil
}
time.Sleep(100 * time.Millisecond)
}
return "", fmt.Errorf("mount did not become ready at %s; see %s", mountPoint, mountLogPath())
}
func isStaleMount(mountPoint string) bool {
_, err := os.ReadDir(mountPoint)
return err != nil && strings.Contains(err.Error(), "transport endpoint is not connected")
}
func unmount(mountPoint string) error {
if err := exec.Command("fusermount", "-u", mountPoint).Run(); err == nil {
return nil
}
return exec.Command("fusermount3", "-u", mountPoint).Run()
}
func isMounted(mountPoint string) bool {
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return false
}
mountPoint = cleanMountPath(mountPoint)
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && cleanMountPath(fields[1]) == mountPoint {
return true
}
}
return false
}
func cleanMountPath(path string) string {
if path == "" {
return ""
}
abs, err := filepath.Abs(path)
if err != nil {
return filepath.Clean(path)
}
return filepath.Clean(abs)
}
func mountLogPath() string {
cacheDir, err := os.UserCacheDir()
if err != nil || cacheDir == "" {
return filepath.Join(os.TempDir(), "docspell-cli-mount.log")
}
return filepath.Join(cacheDir, "docspell-cli", "mount.log")
}
func mountLogFile() (*os.File, error) {
path := mountLogPath()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
}
func itemIDFromURLOrID(s string) string {
s = strings.TrimSpace(s)
if idx := strings.LastIndex(s, "/app/item/"); idx >= 0 {
return strings.Trim(strings.TrimPrefix(s[idx:], "/app/item/"), "/")
}
return strings.Trim(s, "/")
}
func itemIDFromLocalPath(path string, cfg config) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
mountPoint, err := filepath.Abs(cfg.MountPath)
if err != nil {
return "", err
}
rel, err := filepath.Rel(mountPoint, abs)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("path is not under MountPath %s", cfg.MountPath)
}
parts := strings.Split(rel, string(os.PathSeparator))
if len(parts) >= 3 && parts[0] == "by-id" {
return strings.TrimSuffix(parts[2], filepath.Ext(parts[2])), nil
}
metadata, err := readMountMetadata(mountPoint)
if err != nil {
return "", err
}
rel = filepath.ToSlash(filepath.Clean(rel))
for id, item := range metadata.Items {
if filepath.ToSlash(filepath.Clean(filepath.FromSlash(item.FriendlyPath))) == rel {
return id, nil
}
}
return "", fmt.Errorf("could not map mounted path to item id")
}
func buildMountTree(ctx context.Context, client *DocspellClient, items []SearchItem) *dirNode {
root := &dirNode{name: "", children: map[string]fs.Node{}}
byID := &dirNode{name: "by-id", children: map[string]fs.Node{}}
root.children["by-id"] = byID
metadata := mountMetadata{Items: map[string]mountedItemMetadata{}}
metadataModTime := time.Now()
for _, item := range items {
folderName := "No Folder"
if item.Folder != nil && item.Folder.Name != "" {
folderName = item.Folder.Name
}
date := item.Date
if date == 0 {
date = item.ItemDate
}
yearMonth := "No Date"
modTime := time.Now()
if date > 0 {
modTime = time.UnixMilli(date)
yearMonth = modTime.Format("2006/01")
}
dirParts := append([]string{folderName}, strings.Split(yearMonth, "/")...)
dir := ensureDir(root, dirParts[0], dirParts[1:]...)
base := sanitizeName(item.Name)
if base == "" {
base = item.ID
}
for i, att := range item.Attachments {
ext := filepath.Ext(att.Name)
if ext == "" {
ext = ".pdf"
}
name := base + ext
if len(item.Attachments) > 1 {
name = fmt.Sprintf("%s - %02d%s", base, i+1, ext)
}
size := att.Size
if size <= 0 {
if headSize, err := client.AttachmentSize(ctx, att.ID); err == nil && headSize > 0 {
size = headSize
}
}
name = uniqueName(dir.children, sanitizeName(name))
node := &fileNode{name: name, attachmentID: att.ID, size: uint64(size), knownSize: size > 0, modTime: modTime, client: client}
dir.children[name] = node
friendlyRelPath := filepath.ToSlash(filepath.Join(append(sanitizePathParts(dirParts), name)...))
byIDName := item.ID + ext
if len(item.Attachments) > 1 {
if i > 0 {
byIDName = fmt.Sprintf("%s-%d%s", item.ID, i+1, ext)
}
}
shard := idShard(item.ID)
shardDir := ensureDir(byID, shard)
byIDName = uniqueName(shardDir.children, sanitizeName(byIDName))
shardDir.children[byIDName] = node
if i == 0 {
metadata.Items[item.ID] = mountedItemMetadata{
FriendlyPath: friendlyRelPath,
ByIDPath: filepath.ToSlash(filepath.Join("by-id", shard, byIDName)),
}
}
}
}
metadataData, err := json.MarshalIndent(metadata, "", " ")
if err == nil {
root.children[mountMetadataFileName] = &staticFileNode{name: mountMetadataFileName, data: append(metadataData, '\n'), modTime: metadataModTime}
}
return root
}
func sanitizePathParts(parts []string) []string {
sanitized := make([]string, len(parts))
for i, part := range parts {
part = sanitizeName(part)
if part == "" {
part = "_"
}
sanitized[i] = part
}
return sanitized
}
func idShard(id string) string {
id = sanitizeName(id)
if len(id) >= 2 {
return id[:2]
}
if id != "" {
return id
}
return "__"
}
func ensureDir(root *dirNode, first string, rest ...string) *dirNode {
cur := root
parts := append([]string{first}, rest...)
for _, part := range parts {
part = sanitizeName(part)
if part == "" {
part = "_"
}
next, ok := cur.children[part].(*dirNode)
if !ok {
next = &dirNode{name: part, children: map[string]fs.Node{}}
cur.children[part] = next
}
cur = next
}
return cur
}
func sanitizeName(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "/", "_")
s = strings.ReplaceAll(s, "\x00", "_")
return s
}
func uniqueName(children map[string]fs.Node, name string) string {
if _, ok := children[name]; !ok {
return name
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s (%d)%s", base, i, ext)
if _, ok := children[candidate]; !ok {
return candidate
}
}
}
func (f *docspellFS) Root() (fs.Node, error) { return f.root, nil }
func (d *dirNode) Attr(ctx context.Context, a *fuse.Attr) error {
a.Mode = os.ModeDir | 0555
return nil
}
func (d *dirNode) Lookup(ctx context.Context, name string) (fs.Node, error) {
child, ok := d.children[name]
if !ok {
return nil, fuse.ENOENT
}
return child, nil
}
func (d *dirNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
names := make([]string, 0, len(d.children))
for name := range d.children {
names = append(names, name)
}
sort.Strings(names)
entries := make([]fuse.Dirent, 0, len(names))
for _, name := range names {
typ := fuse.DT_File
if _, ok := d.children[name].(*dirNode); ok {
typ = fuse.DT_Dir
}
entries = append(entries, fuse.Dirent{Name: name, Type: typ})
}
return entries, nil
}
func (f *fileNode) Attr(ctx context.Context, a *fuse.Attr) error {
a.Mode = 0444
if f.knownSize {
a.Size = f.size
}
a.Mtime = f.modTime
a.Ctime = f.modTime
return nil
}
func (f *fileNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) error {
resp.Flags |= fuse.OpenDirectIO
return nil
}
func (f *fileNode) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
data, err := f.load(ctx)
if err != nil {
return err
}
fuseutil.HandleRead(req, resp, data)
return nil
}
func (f *fileNode) ReadAll(ctx context.Context) ([]byte, error) {
return f.load(ctx)
}
func (f *staticFileNode) Attr(ctx context.Context, a *fuse.Attr) error {
a.Mode = 0444
a.Size = uint64(len(f.data))
a.Mtime = f.modTime
a.Ctime = f.modTime
return nil
}
func (f *staticFileNode) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) error {
resp.Flags |= fuse.OpenDirectIO
return nil
}
func (f *staticFileNode) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
fuseutil.HandleRead(req, resp, f.data)
return nil
}
func (f *staticFileNode) ReadAll(ctx context.Context) ([]byte, error) {
return f.data, nil
}
func (f *fileNode) load(ctx context.Context) ([]byte, error) {
if f.data != nil {
return f.data, nil
}
data, err := f.client.DownloadAttachment(ctx, f.attachmentID)
if err != nil {
return nil, err
}
f.data = data
f.size = uint64(len(data))
f.knownSize = true
return data, nil
}