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