1139 lines
32 KiB
Go
1139 lines
32 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
aiActions "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/opml"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
type Handler struct {
|
|
db *db.DB
|
|
auth *auth.AuthService
|
|
crawler *crawler.Crawler
|
|
parser *parser.Parser
|
|
pool *worker.Pool
|
|
aiClient *ai.Client
|
|
testRateMu sync.Mutex
|
|
testPings []time.Time
|
|
}
|
|
|
|
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler, pool *worker.Pool) *Handler {
|
|
return &Handler{
|
|
db: database,
|
|
auth: authService,
|
|
crawler: cr,
|
|
parser: parser.NewParser(),
|
|
pool: pool,
|
|
aiClient: ai.NewClient(30 * time.Second),
|
|
testPings: []time.Time{},
|
|
}
|
|
}
|
|
|
|
func (h *Handler) writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func (h *Handler) writeError(w http.ResponseWriter, status int, msg string) {
|
|
h.writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// Auth Handlers
|
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|
var req models.LoginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
token, err := h.auth.Authenticate(req.Username, req.Password)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusUnauthorized, "invalid username or password")
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "rsspipe_token",
|
|
Value: token,
|
|
Path: "/",
|
|
Expires: time.Now().Add(30 * 24 * time.Hour),
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
user, _ := h.db.GetUserByUsername(req.Username)
|
|
h.writeJSON(w, http.StatusOK, models.LoginResponse{
|
|
Token: token,
|
|
User: *user,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "rsspipe_token",
|
|
Value: "",
|
|
Path: "/",
|
|
Expires: time.Unix(0, 0),
|
|
HttpOnly: true,
|
|
})
|
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
|
|
}
|
|
|
|
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
|
if !ok {
|
|
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
user, err := h.db.GetUserByUsername(claims.Username)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, user)
|
|
}
|
|
|
|
func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
|
if !ok {
|
|
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
var req models.ChangePasswordRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid request payload")
|
|
return
|
|
}
|
|
|
|
if err := h.auth.UpdatePassword(claims.UserID, req.OldPassword, req.NewPassword); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "password updated successfully"})
|
|
}
|
|
|
|
// User Preferences Handlers
|
|
func (h *Handler) GetUserPreferences(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
|
if !ok {
|
|
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
user, err := h.db.GetUserByUsername(claims.Username)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, user.Preferences)
|
|
}
|
|
|
|
func (h *Handler) UpdateUserPreferences(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
|
if !ok {
|
|
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
user, err := h.db.GetUserByUsername(claims.Username)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
var newPrefs map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&newPrefs); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid request payload")
|
|
return
|
|
}
|
|
if user.Preferences == nil {
|
|
user.Preferences = make(map[string]interface{})
|
|
}
|
|
for k, v := range newPrefs {
|
|
user.Preferences[k] = v
|
|
}
|
|
if err := h.db.UpdateUserPreferences(user.ID, user.Preferences); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, user.Preferences)
|
|
}
|
|
|
|
// Stats Handler
|
|
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
|
stats, err := h.db.GetStats()
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, stats)
|
|
}
|
|
|
|
// Category Handlers
|
|
func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
|
|
cats, err := h.db.ListCategories()
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if cats == nil {
|
|
cats = []models.Category{}
|
|
}
|
|
h.writeJSON(w, http.StatusOK, cats)
|
|
}
|
|
|
|
func (h *Handler) CreateCategory(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Title string `json:"title"`
|
|
ParentID *string `json:"parent_id,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Title) == "" {
|
|
h.writeError(w, http.StatusBadRequest, "valid category title is required")
|
|
return
|
|
}
|
|
|
|
cat, err := h.db.CreateCategory(strings.TrimSpace(body.Title), body.ParentID)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusCreated, cat)
|
|
}
|
|
|
|
func (h *Handler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.db.DeleteCategory(id); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "category deleted"})
|
|
}
|
|
|
|
// Feed Handlers
|
|
func (h *Handler) ListFeeds(w http.ResponseWriter, r *http.Request) {
|
|
feeds, err := h.db.ListFeeds()
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if feeds == nil {
|
|
feeds = []models.Feed{}
|
|
}
|
|
h.writeJSON(w, http.StatusOK, feeds)
|
|
}
|
|
|
|
func (h *Handler) CreateFeed(w http.ResponseWriter, r *http.Request) {
|
|
var req models.CreateFeedRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.FeedURL) == "" {
|
|
h.writeError(w, http.StatusBadRequest, "valid feed_url is required")
|
|
return
|
|
}
|
|
|
|
req.FeedURL = strings.TrimSpace(req.FeedURL)
|
|
|
|
// Check if already subscribed
|
|
if existing, _ := h.db.GetFeedByURL(req.FeedURL); existing != nil {
|
|
h.writeError(w, http.StatusConflict, "feed is already subscribed")
|
|
return
|
|
}
|
|
|
|
// Fetch to test and discover title/site URL
|
|
res, err := h.parser.FetchFeed(r.Context(), req.FeedURL, "", "")
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to fetch feed: %v", err))
|
|
return
|
|
}
|
|
|
|
title := res.Title
|
|
if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
|
|
title = strings.TrimSpace(*req.Title)
|
|
}
|
|
iconURL := ""
|
|
if req.IconURL != nil && strings.TrimSpace(*req.IconURL) != "" {
|
|
iconURL = strings.TrimSpace(*req.IconURL)
|
|
}
|
|
|
|
feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, iconURL, req.CategoryID)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Insert parsed entries
|
|
if len(res.Entries) > 0 {
|
|
_, _ = h.db.UpsertFeedEntries(feed.ID, res.Entries)
|
|
_ = h.db.UpdateFeedFetchStatus(feed.ID, res.ETag, res.LastModified, "")
|
|
}
|
|
|
|
// Retrieve updated feed with counts
|
|
fullFeed, err := h.db.GetFeed(feed.ID)
|
|
if err != nil {
|
|
h.writeJSON(w, http.StatusCreated, feed)
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusCreated, fullFeed)
|
|
}
|
|
|
|
func (h *Handler) UpdateFeed(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var req models.UpdateFeedRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if err := h.db.UpdateFeed(id, req); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
fullFeed, err := h.db.GetFeed(id)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, fullFeed)
|
|
}
|
|
|
|
func (h *Handler) DeleteFeed(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.db.DeleteFeed(id); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "feed deleted"})
|
|
}
|
|
|
|
func (h *Handler) RefreshFeed(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.crawler.RefreshSingle(r.Context(), id); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
f, _ := h.db.GetFeed(id)
|
|
h.writeJSON(w, http.StatusOK, f)
|
|
}
|
|
|
|
func (h *Handler) RefreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
|
go h.crawler.RefreshAll(context.Background())
|
|
h.writeJSON(w, http.StatusAccepted, map[string]string{"message": "refreshing all feeds in background"})
|
|
}
|
|
|
|
// Entry Handlers
|
|
func (h *Handler) ListEntries(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
|
offset, _ := strconv.Atoi(q.Get("offset"))
|
|
|
|
filter := db.EntryFilter{
|
|
View: q.Get("view"),
|
|
FeedID: q.Get("feed_id"),
|
|
CategoryID: q.Get("category_id"),
|
|
Search: q.Get("search"),
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
|
|
entries, total, err := h.db.ListEntries(filter)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if entries == nil {
|
|
entries = []models.FeedEntry{}
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"entries": entries,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) UpdateEntryRead(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var body models.UpdateEntryReadRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid body")
|
|
return
|
|
}
|
|
|
|
if err := h.db.UpdateEntryRead(id, body.IsRead); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_read": body.IsRead})
|
|
}
|
|
|
|
func (h *Handler) UpdateEntryStar(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var body models.UpdateEntryStarRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid body")
|
|
return
|
|
}
|
|
|
|
if err := h.db.UpdateEntryStarred(id, body.IsStarred); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_starred": body.IsStarred})
|
|
}
|
|
|
|
func (h *Handler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
|
var body models.MarkAllReadRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
if err := h.db.MarkAllRead(body.FeedID, body.CategoryID); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "marked all as read"})
|
|
}
|
|
|
|
// OPML Handlers
|
|
func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(10 << 20); err != nil { // 10MB max
|
|
h.writeError(w, http.StatusBadRequest, "failed to parse multipart form")
|
|
return
|
|
}
|
|
|
|
file, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "file field is required")
|
|
return
|
|
}
|
|
defer func() {
|
|
_ = file.Close()
|
|
}()
|
|
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
|
return
|
|
}
|
|
|
|
importedFeeds, err := opml.ParseOPML(data)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid OPML XML: %v", err))
|
|
return
|
|
}
|
|
|
|
categoryCache := make(map[string]string) // title -> id
|
|
cats, _ := h.db.ListCategories()
|
|
for _, c := range cats {
|
|
categoryCache[c.Title] = c.ID
|
|
}
|
|
|
|
createdCount := 0
|
|
for _, item := range importedFeeds {
|
|
if item.FeedURL == "" {
|
|
continue
|
|
}
|
|
if existing, _ := h.db.GetFeedByURL(item.FeedURL); existing != nil {
|
|
continue
|
|
}
|
|
|
|
var catID *string
|
|
if item.CategoryTitle != "" {
|
|
if cid, ok := categoryCache[item.CategoryTitle]; ok {
|
|
catID = &cid
|
|
} else {
|
|
newCat, err := h.db.CreateCategory(item.CategoryTitle, nil)
|
|
if err == nil {
|
|
categoryCache[item.CategoryTitle] = newCat.ID
|
|
catID = &newCat.ID
|
|
}
|
|
}
|
|
}
|
|
|
|
title := item.Title
|
|
if title == "" {
|
|
title = item.FeedURL
|
|
}
|
|
|
|
_, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, "", catID)
|
|
if err == nil {
|
|
createdCount++
|
|
}
|
|
}
|
|
|
|
// Trigger background crawler for newly imported feeds
|
|
go h.crawler.RefreshAll(context.Background())
|
|
|
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"imported_count": createdCount,
|
|
"total_parsed": len(importedFeeds),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) ExportOPML(w http.ResponseWriter, r *http.Request) {
|
|
feeds, err := h.db.ListFeeds()
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
cats, err := h.db.ListCategories()
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
xmlData, err := opml.GenerateOPML(feeds, cats)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="rsspipe-subscriptions.opml"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(xmlData)
|
|
}
|
|
|
|
// Public Share handler (/share/<uuid>.html)
|
|
func (h *Handler) ViewSharedEntry(w http.ResponseWriter, r *http.Request) {
|
|
uuidStr := chi.URLParam(r, "uuid")
|
|
uuidStr = strings.TrimSuffix(uuidStr, ".html")
|
|
|
|
var e models.FeedEntry
|
|
var fTitle, fSiteURL, author, rawContent, metaStr, shareUUID sql.NullString
|
|
query := `
|
|
SELECT
|
|
e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
|
|
e.guid, e.url, e.title, e.author, e.content, e.raw_content,
|
|
e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
|
|
FROM feed_entries e
|
|
JOIN feeds f ON f.id = e.feed_id
|
|
WHERE e.share_uuid = ? OR e.id = ?
|
|
`
|
|
err := h.db.QueryRow(query, uuidStr, uuidStr).Scan(
|
|
&e.ID, &e.FeedID, &fTitle, &fSiteURL,
|
|
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
|
|
&e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "Shared article not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if fTitle.Valid {
|
|
e.FeedTitle = fTitle.String
|
|
}
|
|
if fSiteURL.Valid {
|
|
e.FeedSiteURL = fSiteURL.String
|
|
}
|
|
if author.Valid {
|
|
e.Author = author.String
|
|
}
|
|
|
|
// Render standalone styled HTML
|
|
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>%s - RSSPipe</title>
|
|
<style>
|
|
:root { --bg: #0f172a; --card: #1e293b; --text: #f8fafc; --muted: #94a3b8; --accent: #38bdf8; }
|
|
@media (prefers-color-scheme: light) {
|
|
:root { --bg: #f8fafc; --card: #ffffff; --text: #0f172a; --muted: #64748b; --accent: #0284c7; }
|
|
}
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; margin: 0; padding: 2rem 1rem; display: flex; justify-content: center; }
|
|
.container { max-width: 760px; width: 100%%; background: var(--card); border-radius: 12px; padding: 2.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
|
|
.meta { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; border-bottom: 1px solid rgba(148, 163, 184, 0.2); padding-bottom: 1rem; }
|
|
h1 { margin-top: 0; font-size: 2rem; line-height: 1.3; }
|
|
h1 a { color: inherit; text-decoration: none; }
|
|
h1 a:hover { color: var(--accent); }
|
|
.content img { max-width: 100%%; height: auto; border-radius: 8px; }
|
|
.content pre { background: rgba(0,0,0,0.2); padding: 1rem; border-radius: 8px; overflow-x: auto; }
|
|
.footer { margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(148, 163, 184, 0.2); font-size: 0.85rem; color: var(--muted); text-align: center; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="meta">
|
|
<span><strong>%s</strong></span>
|
|
<span>•</span>
|
|
<span>%s</span>
|
|
%s
|
|
</div>
|
|
<h1><a href="%s" target="_blank" rel="noopener noreferrer">%s ↗</a></h1>
|
|
<div class="content">
|
|
%s
|
|
</div>
|
|
<div class="footer">
|
|
Shared via <a href="/" style="color: var(--accent); text-decoration: none;">RSSPipe Information Gateway</a>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`,
|
|
e.Title,
|
|
e.FeedTitle,
|
|
e.PublishedAt.Format("Jan 02, 2006 15:04"),
|
|
func() string {
|
|
if e.Author != "" {
|
|
return "<span>•</span><span>By " + e.Author + "</span>"
|
|
}
|
|
return ""
|
|
}(),
|
|
e.URL,
|
|
e.Title,
|
|
e.Content,
|
|
)
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(html))
|
|
}
|
|
|
|
// GetEntry returns a single entry by ID with its loaded variants
|
|
func (h *Handler) GetEntry(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
entry, err := h.db.GetEntry(id)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
h.writeError(w, http.StatusNotFound, "entry not found")
|
|
return
|
|
}
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, entry)
|
|
}
|
|
|
|
// ListEntryVariants returns all variants for a given entry ID
|
|
func (h *Handler) ListEntryVariants(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
variants, err := h.db.ListVariantsForEntry(id)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, variants)
|
|
}
|
|
|
|
// GetVariantBlobStream streams the audio/binary file for a variant with HTTP 206 Partial Content support
|
|
func (h *Handler) GetVariantBlobStream(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
key := chi.URLParam(r, "key")
|
|
variant, err := h.db.GetVariant(id, key)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusNotFound, "variant not found")
|
|
return
|
|
}
|
|
if variant.BlobPath == "" {
|
|
h.writeError(w, http.StatusNotFound, "variant has no binary media blob")
|
|
return
|
|
}
|
|
|
|
http.ServeFile(w, r, variant.BlobPath)
|
|
}
|
|
|
|
// ScrapeEntry manually triggers readability full text scraping and saves to entry_variants
|
|
func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
entry, err := h.db.GetEntry(id)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusNotFound, "entry not found")
|
|
return
|
|
}
|
|
|
|
if h.pool == nil {
|
|
h.writeError(w, http.StatusInternalServerError, "worker pool not configured")
|
|
return
|
|
}
|
|
|
|
res, err := h.pool.ExecuteDirect(r.Context(), "readability_scrape", *entry, nil)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("scraping failed: %v", err))
|
|
return
|
|
}
|
|
|
|
if res != nil && res.ResultVariant != nil {
|
|
_ = h.db.SetVariant(*res.ResultVariant)
|
|
}
|
|
|
|
// Reload entry with newly saved variant
|
|
updatedEntry, err := h.db.GetEntry(id)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, updatedEntry)
|
|
}
|
|
|
|
// ToggleShareEntry toggles public sharing for an entry and returns the updated state
|
|
func (h *Handler) ToggleShareEntry(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
shareUUID, err := h.db.ToggleShareEntry(id)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
entry, _ := h.db.GetEntry(id)
|
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"shared": shareUUID != nil,
|
|
"share_uuid": shareUUID,
|
|
"entry": entry,
|
|
})
|
|
}
|
|
|
|
// GenerateAISummary on-demand generates an AI summary for a single article
|
|
func (h *Handler) GenerateAISummary(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
entry, err := h.db.GetEntry(id)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusNotFound, "entry not found")
|
|
return
|
|
}
|
|
|
|
cfg := aiActions.LoadConfig(h.db)
|
|
if !cfg.Enabled {
|
|
h.writeError(w, http.StatusBadRequest, "AI is not enabled in settings")
|
|
return
|
|
}
|
|
if cfg.APIKey == "" && cfg.Provider != "ollama" {
|
|
h.writeError(w, http.StatusBadRequest, "AI API key is not configured in settings")
|
|
return
|
|
}
|
|
|
|
prompt := "Summarize the key points of this article clearly with 3 concise bullet points and a brief takeaway."
|
|
resultText, err := h.aiClient.GenerateEnrichment(r.Context(), cfg, prompt, *entry)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "AI generation failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
variant := models.EntryVariant{
|
|
EntryID: entry.ID,
|
|
VariantKey: "ai_summary",
|
|
ContentType: "text",
|
|
TextValue: resultText,
|
|
Metadata: map[string]interface{}{
|
|
"model": cfg.Model,
|
|
},
|
|
}
|
|
if err := h.db.SetVariant(variant); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to save AI summary: "+err.Error())
|
|
return
|
|
}
|
|
|
|
updated, err := h.db.GetEntry(id)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
// Rules API Handlers
|
|
func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
|
|
rules, err := h.db.ListRules()
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if rules == nil {
|
|
rules = []models.Rule{}
|
|
}
|
|
h.writeJSON(w, http.StatusOK, rules)
|
|
}
|
|
|
|
type CreateRuleRequest struct {
|
|
Name string `json:"name"`
|
|
Trigger string `json:"trigger"`
|
|
MatchMode string `json:"match_mode"`
|
|
Conditions []models.RuleCondition `json:"conditions"`
|
|
Actions []models.RuleAction `json:"actions"`
|
|
}
|
|
|
|
func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
|
var body CreateRuleRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(body.Name) == "" {
|
|
h.writeError(w, http.StatusBadRequest, "rule name is required")
|
|
return
|
|
}
|
|
if strings.TrimSpace(body.Trigger) == "" {
|
|
body.Trigger = "on_post_created"
|
|
}
|
|
if err := rules.ValidateRuleSafety(models.Rule{
|
|
Trigger: body.Trigger,
|
|
Conditions: body.Conditions,
|
|
Actions: body.Actions,
|
|
}); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
rule, err := h.db.CreateRule(body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusCreated, rule)
|
|
}
|
|
|
|
type UpdateRuleRequest struct {
|
|
Name string `json:"name"`
|
|
Trigger string `json:"trigger"`
|
|
MatchMode string `json:"match_mode"`
|
|
Conditions []models.RuleCondition `json:"conditions"`
|
|
Actions []models.RuleAction `json:"actions"`
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
|
|
func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var body UpdateRuleRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if err := rules.ValidateRuleSafety(models.Rule{
|
|
Trigger: body.Trigger,
|
|
Conditions: body.Conditions,
|
|
Actions: body.Actions,
|
|
}); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
rule, err := h.db.UpdateRule(id, body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions, body.IsActive)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, rule)
|
|
}
|
|
|
|
func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.db.DeleteRule(id); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "rule deleted"})
|
|
}
|
|
|
|
func (h *Handler) ApplyRule(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
rule, err := h.db.GetRule(id)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusNotFound, "rule not found")
|
|
return
|
|
}
|
|
|
|
evaluator := rules.NewEvaluator()
|
|
matchedCount := 0
|
|
queuedJobs := 0
|
|
|
|
// Paginate through ALL entries in batches of 500
|
|
batchSize := 500
|
|
offset := 0
|
|
for {
|
|
entries, total, err := h.db.ListEntries(db.EntryFilter{
|
|
View: "all",
|
|
Limit: batchSize,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to query entries: "+err.Error())
|
|
return
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if evaluator.Evaluate(*rule, entry) {
|
|
matchedCount++
|
|
for _, action := range rule.Actions {
|
|
h.pool.Dispatch(r.Context(), action, entry, 0)
|
|
queuedJobs++
|
|
}
|
|
}
|
|
}
|
|
|
|
offset += batchSize
|
|
if offset >= total || len(entries) == 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"message": fmt.Sprintf("Rule applied to %d matching post(s). %d action(s) queued.", matchedCount, queuedJobs),
|
|
"matched_count": matchedCount,
|
|
"queued_jobs": queuedJobs,
|
|
})
|
|
}
|
|
|
|
// AI Settings Handlers
|
|
type aiSettingsResponse struct {
|
|
Enabled bool `json:"enabled"`
|
|
Provider string `json:"provider"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
Model string `json:"model"`
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
Prompts []ai.PromptTemplate `json:"prompts"`
|
|
}
|
|
|
|
func maskAPIKey(key string) string {
|
|
if len(key) == 0 {
|
|
return ""
|
|
}
|
|
if len(key) <= 6 {
|
|
return "••••••"
|
|
}
|
|
return key[:3] + "••••••••" + key[len(key)-4:]
|
|
}
|
|
|
|
func (h *Handler) GetAISettings(w http.ResponseWriter, r *http.Request) {
|
|
settings, err := h.db.GetSettingsMap()
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to load settings: "+err.Error())
|
|
return
|
|
}
|
|
|
|
enabled := settings["ai_enabled"] == "true" || settings["ai_enabled"] == "1"
|
|
provider := settings["ai_provider"]
|
|
if provider == "" {
|
|
provider = "openai"
|
|
}
|
|
baseURL := settings["ai_base_url"]
|
|
if baseURL == "" {
|
|
baseURL = "https://api.openai.com/v1"
|
|
}
|
|
model := settings["ai_model"]
|
|
if model == "" {
|
|
model = "gpt-4o-mini"
|
|
}
|
|
temp, _ := strconv.ParseFloat(settings["ai_temperature"], 64)
|
|
if temp <= 0 {
|
|
temp = 0.3
|
|
}
|
|
maxTokens, _ := strconv.Atoi(settings["ai_max_tokens"])
|
|
if maxTokens <= 0 {
|
|
maxTokens = 1000
|
|
}
|
|
|
|
var prompts []ai.PromptTemplate
|
|
if promptsJSON := settings["ai_prompts"]; promptsJSON != "" {
|
|
_ = json.Unmarshal([]byte(promptsJSON), &prompts)
|
|
}
|
|
if len(prompts) == 0 {
|
|
prompts = ai.DefaultPromptTemplates()
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, aiSettingsResponse{
|
|
Enabled: enabled,
|
|
Provider: provider,
|
|
BaseURL: baseURL,
|
|
APIKey: maskAPIKey(settings["ai_api_key"]),
|
|
Model: model,
|
|
Temperature: temp,
|
|
MaxTokens: maxTokens,
|
|
Prompts: prompts,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) UpdateAISettings(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Provider string `json:"provider"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
Model string `json:"model"`
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
Prompts []ai.PromptTemplate `json:"prompts"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid JSON request")
|
|
return
|
|
}
|
|
|
|
if req.Enabled != nil {
|
|
val := "false"
|
|
if *req.Enabled {
|
|
val = "true"
|
|
}
|
|
_ = h.db.SetSetting("ai_enabled", val)
|
|
}
|
|
if req.Provider != "" {
|
|
_ = h.db.SetSetting("ai_provider", req.Provider)
|
|
}
|
|
if req.BaseURL != "" {
|
|
_ = h.db.SetSetting("ai_base_url", req.BaseURL)
|
|
}
|
|
if req.Model != "" {
|
|
_ = h.db.SetSetting("ai_model", req.Model)
|
|
}
|
|
if req.Temperature > 0 {
|
|
_ = h.db.SetSetting("ai_temperature", fmt.Sprintf("%.2f", req.Temperature))
|
|
}
|
|
if req.MaxTokens > 0 {
|
|
_ = h.db.SetSetting("ai_max_tokens", strconv.Itoa(req.MaxTokens))
|
|
}
|
|
|
|
// Update API key only if not masked with bullets
|
|
if req.APIKey != "" && !strings.Contains(req.APIKey, "••••") {
|
|
_ = h.db.SetSetting("ai_api_key", strings.TrimSpace(req.APIKey))
|
|
}
|
|
|
|
if len(req.Prompts) > 0 {
|
|
promptsBytes, _ := json.Marshal(req.Prompts)
|
|
_ = h.db.SetSetting("ai_prompts", string(promptsBytes))
|
|
}
|
|
|
|
h.GetAISettings(w, r)
|
|
}
|
|
|
|
// TestAIConnection checks LLM connectivity with strict rate limiting & token caps
|
|
func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) {
|
|
// Rate limiting: Max 5 test pings per minute to prevent token exhaustion
|
|
h.testRateMu.Lock()
|
|
now := time.Now()
|
|
var recentPings []time.Time
|
|
for _, t := range h.testPings {
|
|
if now.Sub(t) < time.Minute {
|
|
recentPings = append(recentPings, t)
|
|
}
|
|
}
|
|
if len(recentPings) >= 5 {
|
|
h.testRateMu.Unlock()
|
|
h.writeError(w, http.StatusTooManyRequests, "Rate limit exceeded. Maximum 5 connection tests per minute.")
|
|
return
|
|
}
|
|
h.testPings = append(recentPings, now)
|
|
h.testRateMu.Unlock()
|
|
|
|
var req struct {
|
|
Provider string `json:"provider"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
Model string `json:"model"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
settings, _ := h.db.GetSettingsMap()
|
|
|
|
apiKey := req.APIKey
|
|
if apiKey == "" || strings.Contains(apiKey, "••••") {
|
|
apiKey = settings["ai_api_key"]
|
|
}
|
|
baseURL := req.BaseURL
|
|
if baseURL == "" {
|
|
baseURL = settings["ai_base_url"]
|
|
}
|
|
if baseURL == "" {
|
|
baseURL = "https://api.openai.com/v1"
|
|
}
|
|
model := req.Model
|
|
if model == "" {
|
|
model = settings["ai_model"]
|
|
}
|
|
if model == "" {
|
|
model = "gpt-4o-mini"
|
|
}
|
|
|
|
cfg := ai.Config{
|
|
Enabled: true,
|
|
Provider: req.Provider,
|
|
BaseURL: baseURL,
|
|
APIKey: apiKey,
|
|
Model: model,
|
|
}
|
|
|
|
res, err := h.aiClient.TestConnection(r.Context(), cfg)
|
|
if err != nil {
|
|
h.writeJSON(w, http.StatusOK, res)
|
|
return
|
|
}
|
|
|
|
h.writeJSON(w, http.StatusOK, res)
|
|
}
|
|
|
|
// Search History Handlers
|
|
func (h *Handler) GetSearchHistory(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query().Get("q")
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit := 10
|
|
if limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
history, err := h.db.GetSearchHistory(q, limit)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to get search history: "+err.Error())
|
|
return
|
|
}
|
|
if history == nil {
|
|
history = []models.SearchHistoryItem{}
|
|
}
|
|
h.writeJSON(w, http.StatusOK, history)
|
|
}
|
|
|
|
func (h *Handler) RecordSearch(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Keyword string `json:"keyword"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Keyword) == "" {
|
|
h.writeJSON(w, http.StatusOK, map[string]string{"status": "ignored"})
|
|
return
|
|
}
|
|
|
|
if err := h.db.RecordSearch(req.Keyword); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to record search: "+err.Error())
|
|
return
|
|
}
|
|
h.writeJSON(w, http.StatusOK, map[string]string{"status": "recorded"})
|
|
}
|
|
|
|
func (h *Handler) DeleteSearchHistory(w http.ResponseWriter, r *http.Request) {
|
|
keyword := r.URL.Query().Get("keyword")
|
|
if keyword != "" {
|
|
if err := h.db.DeleteSearchHistory(keyword); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to delete search history: "+err.Error())
|
|
return
|
|
}
|
|
} else {
|
|
if err := h.db.ClearSearchHistory(); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to clear search history: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|