Add read-only Docspell FUSE mount
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultDocspellAuthHeader = "X-Docspell-Auth"
|
||||
|
||||
type DocspellClient struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
AuthHeader string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Token string `json:"token"`
|
||||
RequireSecondFactor bool `json:"requireSecondFactor"`
|
||||
}
|
||||
|
||||
type searchResponse struct {
|
||||
Groups []struct {
|
||||
Items []SearchItem `json:"items"`
|
||||
} `json:"groups"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
LimitCapped bool `json:"limitCapped"`
|
||||
}
|
||||
|
||||
type SearchItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Date int64 `json:"date"`
|
||||
ItemDate int64 `json:"itemDate"`
|
||||
Attachments []ItemAttachment `json:"attachments"`
|
||||
Folder *DocspellEntity `json:"folder"`
|
||||
CorrOrg *DocspellEntity `json:"corrOrg"`
|
||||
CorrPerson *DocspellEntity `json:"corrPerson"`
|
||||
Tags []DocspellTag `json:"tags"`
|
||||
}
|
||||
|
||||
type ItemAttachment struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType"`
|
||||
Converted bool `json:"converted"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type DocspellTag struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
func NewDocspellClient(baseURL, authHeader string) *DocspellClient {
|
||||
if authHeader == "" {
|
||||
authHeader = defaultDocspellAuthHeader
|
||||
}
|
||||
return &DocspellClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
AuthHeader: authHeader,
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DocspellClient) Login(ctx context.Context, account, password string) error {
|
||||
var out loginResponse
|
||||
err := c.doJSON(ctx, http.MethodPost, "/api/v1/open/auth/login", map[string]any{
|
||||
"account": account,
|
||||
"password": password,
|
||||
}, &out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !out.Success {
|
||||
return fmt.Errorf("login failed: %s", out.Message)
|
||||
}
|
||||
if out.RequireSecondFactor {
|
||||
return fmt.Errorf("login requires a second factor; token-based mounting is not implemented")
|
||||
}
|
||||
if out.Token == "" {
|
||||
return fmt.Errorf("login response did not include a token")
|
||||
}
|
||||
c.Token = out.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DocspellClient) SearchItems(ctx context.Context, query string, limit int) ([]SearchItem, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
var all []SearchItem
|
||||
for offset := 0; ; offset += limit {
|
||||
var out searchResponse
|
||||
err := c.doJSON(ctx, http.MethodPost, "/api/v1/sec/item/search", map[string]any{
|
||||
"query": query,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"withDetails": true,
|
||||
"searchMode": "normal",
|
||||
}, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := 0
|
||||
for _, group := range out.Groups {
|
||||
all = append(all, group.Items...)
|
||||
count += len(group.Items)
|
||||
}
|
||||
if count == 0 || count < limit {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DocspellClient) GetItem(ctx context.Context, id string) (*SearchItem, error) {
|
||||
var out SearchItem
|
||||
if err := c.doJSON(ctx, http.MethodGet, "/api/v1/sec/item/"+url.PathEscape(id), nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *DocspellClient) DownloadAttachment(ctx context.Context, id string) ([]byte, error) {
|
||||
return c.doBytes(ctx, http.MethodGet, "/api/v1/sec/attachment/"+url.PathEscape(id), nil)
|
||||
}
|
||||
|
||||
func (c *DocspellClient) doJSON(ctx context.Context, method, apiPath string, in any, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
buf, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.url(apiPath), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
data, err := c.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
func (c *DocspellClient) doBytes(ctx context.Context, method, apiPath string, body io.Reader) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.url(apiPath), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
func (c *DocspellClient) do(req *http.Request) ([]byte, error) {
|
||||
if c.Token != "" {
|
||||
req.Header.Set(c.AuthHeader, c.Token)
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%s %s: %s: %s", req.Method, req.URL, resp.Status, strings.TrimSpace(string(data)))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *DocspellClient) url(apiPath string) string {
|
||||
return c.BaseURL + path.Clean("/"+apiPath)
|
||||
}
|
||||
Reference in New Issue
Block a user