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>
451 lines
13 KiB
Go
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
|
|
}
|