mattermost-plugin-shelfmark/server/shelfmark/client.go
Felipe M. a4ee9379eb
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
ci / plugin-ci (push) Has been cancelled
Validate channel existence and Shelfmark reachability on config change
Add early validation in OnConfigurationChange to verify the configured
channel exists in Mattermost and the Shelfmark server is reachable,
giving admins immediate feedback instead of discovering issues at runtime.

- Add Ping() method to shelfmark.Client for lightweight reachability check
- Add validateConfiguration() on Plugin that checks channel + Shelfmark
- Add i18n messages for channel not found and Shelfmark unreachable
- Add unit tests for Ping and validateConfiguration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:55:08 +01:00

451 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
}
// 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
}