Files
docspell-cli/docspell_client.go
T

330 lines
8.5 KiB
Go

package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path"
"path/filepath"
"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 BasicResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type UploadMeta struct {
Multiple bool `json:"multiple"`
Direction string `json:"direction,omitempty"`
Folder string `json:"folder,omitempty"`
SkipDuplicates bool `json:"skipDuplicates,omitempty"`
Tags []string `json:"tags,omitempty"`
FileFilter string `json:"fileFilter,omitempty"`
Language string `json:"language,omitempty"`
AttachmentsOnly bool `json:"attachmentsOnly,omitempty"`
FlattenArchives bool `json:"flattenArchives,omitempty"`
}
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 {
return all, nil
}
offset += count - limit
}
}
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) FileExists(ctx context.Context, filePath string) (FileExistsResult, error) {
var result FileExistsResult
hash, err := fileSHA256(filePath)
if err != nil {
return result, err
}
if err := c.doJSON(ctx, http.MethodGet, "/api/v1/sec/checkfile/"+url.PathEscape(hash), nil, &result); err != nil {
return result, err
}
if abs, err := filepath.Abs(filePath); err == nil {
result.File = abs
} else {
result.File = filePath
}
return result, nil
}
func (c *DocspellClient) UploadFile(ctx context.Context, filePath string, meta UploadMeta) (BasicResult, error) {
file, err := os.Open(filePath)
if err != nil {
return BasicResult{}, err
}
defer file.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
metaPart, err := writer.CreatePart(textPartHeader("meta", "application/json"))
if err != nil {
return BasicResult{}, err
}
if err := json.NewEncoder(metaPart).Encode(meta); err != nil {
return BasicResult{}, err
}
filePart, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
return BasicResult{}, err
}
if _, err := io.Copy(filePart, file); err != nil {
return BasicResult{}, err
}
if err := writer.Close(); err != nil {
return BasicResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url("/api/v1/sec/upload/item"), &body)
if err != nil {
return BasicResult{}, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
data, err := c.do(req)
if err != nil {
return BasicResult{}, err
}
var out BasicResult
if err := json.Unmarshal(data, &out); err != nil {
return BasicResult{}, err
}
return out, nil
}
func fileSHA256(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
func textPartHeader(name, contentType string) textproto.MIMEHeader {
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"`, name))
header.Set("Content-Type", contentType)
return header
}
func (c *DocspellClient) AttachmentSize(ctx context.Context, id string) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.url("/api/v1/sec/attachment/"+url.PathEscape(id)), nil)
if err != nil {
return 0, err
}
if c.Token != "" {
req.Header.Set(c.AuthHeader, c.Token)
}
httpClient := c.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
resp, err := httpClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return 0, fmt.Errorf("%s %s: %s: %s", req.Method, req.URL, resp.Status, strings.TrimSpace(string(body)))
}
return resp.ContentLength, 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)
}
httpClient := c.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
resp, err := 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)
}