package main import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "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) default: http.NotFound(w, r) } } 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) }