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) }