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>
234 lines
6.9 KiB
Go
234 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/plugin"
|
|
|
|
"git.nakama.town/fmartingr/mattermost-plugin-shelfmark/server/shelfmark"
|
|
)
|
|
|
|
// ServeHTTP handles HTTP requests to the plugin's API.
|
|
func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
userID := r.Header.Get("Mattermost-User-Id")
|
|
if userID == "" {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
path := r.URL.Path
|
|
switch {
|
|
case r.Method == http.MethodGet && path == "/api/v1/config":
|
|
p.handleGetConfig(w, r)
|
|
case r.Method == http.MethodGet && path == "/api/v1/books/search":
|
|
p.handleSearchBooks(w, r)
|
|
case r.Method == http.MethodGet && path == "/api/v1/books/releases":
|
|
p.handleGetReleases(w, r)
|
|
case r.Method == http.MethodPost && path == "/api/v1/books/request":
|
|
p.handleRequestBookAPI(w, r)
|
|
case r.Method == http.MethodGet && path == "/api/v1/cover":
|
|
p.handleCoverProxy(w, r)
|
|
case r.Method == http.MethodPost && path == "/api/v1/config/test-connection":
|
|
p.handleTestConnection(w, r, userID)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
type testConnectionResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func (p *Plugin) handleTestConnection(w http.ResponseWriter, _ *http.Request, userID string) {
|
|
if !p.API.HasPermissionTo(userID, model.PermissionManageSystem) {
|
|
http.Error(w, `{"error": "forbidden"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
config := p.getConfiguration()
|
|
if err := config.testShelfmarkConnection(); err != nil {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
apiWriteJSON(w, testConnectionResponse{
|
|
Success: false,
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
apiWriteJSON(w, testConnectionResponse{
|
|
Success: true,
|
|
Message: "Successfully connected to the Shelfmark server.",
|
|
})
|
|
}
|
|
|
|
func (p *Plugin) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
|
|
config := p.getConfiguration()
|
|
apiWriteJSON(w, map[string]string{
|
|
"default_language": config.getDefaultLanguage(),
|
|
"team_id": config.getTeamID(),
|
|
})
|
|
}
|
|
|
|
func (p *Plugin) handleSearchBooks(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query().Get("query")
|
|
if query == "" {
|
|
http.Error(w, `{"error": "query parameter is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
limit := 10
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
if limit > 25 {
|
|
limit = 25
|
|
}
|
|
|
|
result, err := p.shelfmarkClient.SearchBooksWithLimit(query, limit)
|
|
if err != nil {
|
|
p.API.LogError("API: search books failed", "query", query, "error", err.Error())
|
|
http.Error(w, `{"error": "search failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
apiWriteJSON(w, result)
|
|
}
|
|
|
|
func (p *Plugin) handleGetReleases(w http.ResponseWriter, r *http.Request) {
|
|
provider := r.URL.Query().Get("provider")
|
|
bookID := r.URL.Query().Get("book_id")
|
|
if provider == "" || bookID == "" {
|
|
http.Error(w, `{"error": "provider and book_id are required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
language := r.URL.Query().Get("language")
|
|
|
|
result, err := p.shelfmarkClient.GetReleases(provider, bookID, language)
|
|
if err != nil {
|
|
p.API.LogError("API: get releases failed", "provider", provider, "book_id", bookID, "error", err.Error())
|
|
http.Error(w, `{"error": "failed to get releases"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
apiWriteJSON(w, result)
|
|
}
|
|
|
|
type requestBookParams struct {
|
|
Provider string `json:"provider"`
|
|
ProviderID string `json:"provider_id"`
|
|
Title string `json:"title"`
|
|
CoverURL string `json:"cover_url"`
|
|
Authors []string `json:"authors"`
|
|
Language string `json:"language"`
|
|
Release *shelfmark.Release `json:"release"`
|
|
}
|
|
|
|
func (p *Plugin) handleRequestBookAPI(w http.ResponseWriter, r *http.Request) {
|
|
var params requestBookParams
|
|
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
|
http.Error(w, `{"error": "invalid request body"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if params.Provider == "" || params.ProviderID == "" || params.Release == nil {
|
|
http.Error(w, `{"error": "provider, provider_id, and release are required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
config := p.getConfiguration()
|
|
if err := config.IsValid(); err != nil {
|
|
http.Error(w, `{"error": "plugin not configured"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
userID := r.Header.Get("Mattermost-User-Id")
|
|
|
|
if teamID := config.getTeamID(); teamID != "" {
|
|
if _, appErr := p.API.GetTeamMember(teamID, userID); appErr != nil {
|
|
http.Error(w, `{"error": "plugin is not available on this team"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
locale := p.getUserLocale(userID)
|
|
|
|
taskID := fmt.Sprintf("%s_%s_%d", params.Provider, params.ProviderID, time.Now().UnixMilli())
|
|
task := &DownloadTask{
|
|
ID: taskID,
|
|
ChannelID: config.ChannelID,
|
|
BookTitle: params.Title,
|
|
BookProvider: params.Provider,
|
|
BookProviderID: params.ProviderID,
|
|
BookCoverURL: params.CoverURL,
|
|
BookAuthors: params.Authors,
|
|
Language: params.Language,
|
|
SelectedRelease: params.Release,
|
|
Status: TaskStatusPending,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
RequestedBy: userID,
|
|
RequesterLocale: locale,
|
|
}
|
|
|
|
if err := p.taskStore.SaveTask(task); err != nil {
|
|
p.API.LogError("API: failed to save download task", "error", err.Error())
|
|
http.Error(w, `{"error": "failed to create task"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
apiWriteJSON(w, map[string]string{
|
|
"task_id": taskID,
|
|
"status": string(TaskStatusPending),
|
|
})
|
|
}
|
|
|
|
func (p *Plugin) handleCoverProxy(w http.ResponseWriter, r *http.Request) {
|
|
coverURL := r.URL.Query().Get("url")
|
|
if coverURL == "" {
|
|
http.Error(w, `{"error": "url parameter is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Ensure the path is relative (starts with /) to prevent SSRF.
|
|
if !strings.HasPrefix(coverURL, "/") {
|
|
http.Error(w, `{"error": "invalid cover url"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
data, filename, err := p.shelfmarkClient.DownloadCover(coverURL)
|
|
if err != nil {
|
|
p.API.LogError("API: cover proxy failed", "url", coverURL, "error", err.Error())
|
|
http.Error(w, `{"error": "failed to fetch cover"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Determine content type from filename extension.
|
|
contentType := "image/jpeg"
|
|
switch {
|
|
case strings.HasSuffix(filename, ".png"):
|
|
contentType = "image/png"
|
|
case strings.HasSuffix(filename, ".gif"):
|
|
contentType = "image/gif"
|
|
case strings.HasSuffix(filename, ".webp"):
|
|
contentType = "image/webp"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
func apiWriteJSON(w http.ResponseWriter, data any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(data)
|
|
}
|