mattermost-plugin-shelfmark/server/shelfmark/client.go
Felipe M. 620d601091
All checks were successful
ci / lint (push) Successful in 4m44s
ci / test (push) Successful in 4m0s
ci / build (push) Successful in 7m17s
release / release (push) Successful in 7m19s
Add System Console button to test Shelfmark connection.
Expose an admin-only endpoint that validates the saved plugin configuration against the Shelfmark server, and surface it in the plugin settings UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 10:39:10 +02:00

461 lines
13 KiB
Go

package shelfmark
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// closeBody closes an HTTP response body and wraps any error.
func closeBody(body io.ReadCloser) error {
if err := body.Close(); err != nil {
return fmt.Errorf("failed to close response body: %w", err)
}
return nil
}
// Client is an HTTP client for interacting with the Shelfmark server API.
type Client struct {
baseURL string
username string
password string
httpClient *http.Client
mu sync.Mutex
authenticated bool
}
// NewClient creates a new Shelfmark API client.
func NewClient(baseURL, username, password string) *Client {
jar, _ := cookiejar.New(nil)
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
password: password,
httpClient: &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
},
}
}
// UpdateCredentials updates the client's base URL and credentials.
// This is called when the plugin configuration changes.
func (c *Client) UpdateCredentials(baseURL, username, password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.baseURL = strings.TrimRight(baseURL, "/")
c.username = username
c.password = password
c.authenticated = false
// Reset cookie jar on credential change.
jar, _ := cookiejar.New(nil)
c.httpClient.Jar = jar
}
// ensureAuthenticated logs in to the Shelfmark server if not already authenticated.
func (c *Client) ensureAuthenticated() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.authenticated {
return nil
}
return c.loginLocked()
}
// loginLocked performs authentication. Must be called with c.mu held.
func (c *Client) loginLocked() (err error) {
// First check if auth is required.
authCheck, err := c.checkAuthLocked()
if err != nil {
return fmt.Errorf("failed to check auth status: %w", err)
}
if !authCheck.AuthRequired {
// No authentication needed; mark as authenticated.
c.authenticated = true
return nil
}
if c.username == "" || c.password == "" {
return fmt.Errorf("shelfmark requires authentication but no credentials are configured")
}
body, err := json.Marshal(map[string]any{
"username": c.username,
"password": c.password,
"remember_me": true,
})
if err != nil {
return fmt.Errorf("failed to marshal login request: %w", err)
}
resp, err := c.httpClient.Post(c.baseURL+"/api/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("login request failed: %w", err)
}
defer func() {
if cerr := resp.Body.Close(); err == nil && cerr != nil {
err = fmt.Errorf("failed to close response body: %w", cerr)
}
}()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("login failed (status %d): %s", resp.StatusCode, string(respBody))
}
c.authenticated = true
return nil
}
// checkAuthLocked checks if the Shelfmark server requires authentication. Must be called with c.mu held.
func (c *Client) checkAuthLocked() (_ *AuthCheckResponse, err error) {
resp, err := c.httpClient.Get(c.baseURL + "/api/auth/check")
if err != nil {
return nil, fmt.Errorf("auth check request failed: %w", err)
}
defer func() {
if cerr := resp.Body.Close(); err == nil && cerr != nil {
err = fmt.Errorf("failed to close response body: %w", cerr)
}
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("auth check returned status %d", resp.StatusCode)
}
var result AuthCheckResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode auth check response: %w", err)
}
return &result, nil
}
// doRequest performs an HTTP request with authentication handling.
// If a 401 is received, it re-authenticates and retries once.
// The body parameter is a byte slice so it can be replayed on retry.
func (c *Client) doRequest(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
if err := c.ensureAuthenticated(); err != nil {
return nil, err
}
// Read baseURL under lock to avoid race with UpdateCredentials.
c.mu.Lock()
baseURL := c.baseURL
c.mu.Unlock()
reqURL := baseURL + path
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
// On 401, attempt re-authentication and retry.
if resp.StatusCode == http.StatusUnauthorized {
if err := resp.Body.Close(); err != nil {
return nil, fmt.Errorf("failed to close response body: %w", err)
}
c.mu.Lock()
c.authenticated = false
err := c.loginLocked()
c.mu.Unlock()
if err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
// Create a fresh body reader for the retry.
var retryBody io.Reader
if body != nil {
retryBody = bytes.NewReader(body)
}
// Retry the request.
req, err = http.NewRequestWithContext(ctx, method, reqURL, retryBody)
if err != nil {
return nil, fmt.Errorf("failed to create retry request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err = c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("retry request failed: %w", err)
}
}
return resp, nil
}
// TestConnection verifies the Shelfmark server is reachable and that configured
// credentials are valid when authentication is required.
func (c *Client) TestConnection() error {
if err := c.Ping(); err != nil {
return err
}
return c.ensureAuthenticated()
}
// Ping checks that the Shelfmark server is reachable by hitting the
// lightweight /api/auth/check endpoint. It does not require authentication.
func (c *Client) Ping() error {
c.mu.Lock()
baseURL := c.baseURL
c.mu.Unlock()
resp, err := c.httpClient.Get(baseURL + "/api/auth/check")
if err != nil {
return fmt.Errorf("shelfmark server unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("shelfmark server returned status %d", resp.StatusCode)
}
return nil
}
// SearchBooks searches for books using the Shelfmark metadata search API.
// It returns at most one result; use SearchBooksWithLimit for multiple results.
func (c *Client) SearchBooks(query string) (*SearchResponse, error) {
return c.SearchBooksWithLimit(query, 1)
}
// SearchBooksWithLimit searches for books with a configurable result limit.
func (c *Client) SearchBooksWithLimit(query string, limit int) (_ *SearchResponse, err error) {
params := url.Values{}
params.Set("query", query)
params.Set("limit", strconv.Itoa(limit))
resp, err := c.doRequest(context.Background(), "GET", "/api/metadata/search?"+params.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("search request failed: %w", err)
}
defer func() { err = errors.Join(err, closeBody(resp.Body)) }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("search failed (status %d): %s", resp.StatusCode, string(respBody))
}
var result SearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode search response: %w", err)
}
return &result, nil
}
// GetReleases gets available releases (downloadable files) for a book.
// If languages is non-empty, it is passed as a filter (e.g., "en" or "en,es").
func (c *Client) GetReleases(provider, bookID, languages string) (_ *ReleasesResponse, err error) {
params := url.Values{}
params.Set("provider", provider)
params.Set("book_id", bookID)
if languages != "" {
params.Set("languages", languages)
}
resp, err := c.doRequest(context.Background(), "GET", "/api/releases?"+params.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("releases request failed: %w", err)
}
defer func() { err = errors.Join(err, closeBody(resp.Body)) }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get releases failed (status %d): %s", resp.StatusCode, string(respBody))
}
var result ReleasesResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode releases response: %w", err)
}
return &result, nil
}
// QueueDownload queues a release for download on the Shelfmark server.
func (c *Client) QueueDownload(release *Release) (_ *QueueResponse, err error) {
body, err := json.Marshal(release)
if err != nil {
return nil, fmt.Errorf("failed to marshal release: %w", err)
}
resp, err := c.doRequest(context.Background(), "POST", "/api/releases/download", body)
if err != nil {
return nil, fmt.Errorf("queue download request failed: %w", err)
}
defer func() { err = errors.Join(err, closeBody(resp.Body)) }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("queue download failed (status %d): %s", resp.StatusCode, string(respBody))
}
var result QueueResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode queue response: %w", err)
}
return &result, nil
}
// StatusResponse represents the Shelfmark /api/status response.
// The response is grouped by status category:
//
// {
// "complete": { "<taskID>": { ...task... }, ... },
// "queued": { "<taskID>": { ...task... }, ... },
// "downloading": { "<taskID>": { ...task... }, ... },
// "error": { "<taskID>": { ...task... }, ... },
// "cancelled": { "<taskID>": { ...task... }, ... },
// "locating": { "<taskID>": { ...task... }, ... },
// "resolving": { "<taskID>": { ...task... }, ... },
// }
type statusResponse map[string]map[string]json.RawMessage
// GetStatus retrieves the current download queue status.
func (c *Client) GetStatus() (_ statusResponse, err error) {
resp, err := c.doRequest(context.Background(), "GET", "/api/status", nil)
if err != nil {
return nil, fmt.Errorf("status request failed: %w", err)
}
defer func() { err = errors.Join(err, closeBody(resp.Body)) }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get status failed (status %d): %s", resp.StatusCode, string(respBody))
}
var result statusResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode status response: %w", err)
}
return result, nil
}
// GetTaskStatus searches for a task by its source_id across all status categories.
// Returns the category name (e.g. "complete", "error", "queued", "downloading")
// and whether the task was found.
func GetTaskStatus(status statusResponse, taskID string) (string, bool) {
for category, tasks := range status {
if _, exists := tasks[taskID]; exists {
return category, true
}
}
return "", false
}
// DownloadFile downloads a completed book file from the Shelfmark server.
// Returns the file data, filename, and any error.
func (c *Client) DownloadFile(taskID string) (_ []byte, _ string, err error) {
params := url.Values{}
params.Set("id", taskID)
// Use a per-request timeout instead of mutating the shared httpClient.Timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
resp, err := c.doRequest(ctx, "GET", "/api/localdownload?"+params.Encode(), nil)
if err != nil {
return nil, "", fmt.Errorf("download request failed: %w", err)
}
defer func() { err = errors.Join(err, closeBody(resp.Body)) }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("download failed (status %d): %s", resp.StatusCode, string(respBody))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("failed to read download response: %w", err)
}
// Extract filename from Content-Disposition header.
filename := ""
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
_, params, err := mime.ParseMediaType(cd)
if err == nil {
filename = params["filename"]
}
}
if filename == "" {
filename = taskID
}
return data, filename, nil
}
// DownloadCover downloads a cover image from the Shelfmark server.
// The coverPath should be the path portion of the cover URL (e.g., /api/covers/...).
func (c *Client) DownloadCover(coverPath string) (_ []byte, _ string, err error) {
// The cover URL from search results is relative to the Shelfmark base URL.
resp, err := c.doRequest(context.Background(), "GET", coverPath, nil)
if err != nil {
return nil, "", fmt.Errorf("cover download failed: %w", err)
}
defer func() { err = errors.Join(err, closeBody(resp.Body)) }()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("cover download returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("failed to read cover data: %w", err)
}
// Determine a filename from the content type.
contentType := resp.Header.Get("Content-Type")
ext := ".jpg"
switch {
case strings.Contains(contentType, "png"):
ext = ".png"
case strings.Contains(contentType, "gif"):
ext = ".gif"
case strings.Contains(contentType, "webp"):
ext = ".webp"
}
return data, "cover" + ext, nil
}