feat: introduce demo mode functionality

- Added support for a read-only demo mode, allowing users to access the application without authentication while blocking all write operations.
- Implemented configuration options for demo mode in the .env.example file, including a list of RSS feeds to auto-populate.
- Enhanced API to handle demo mode logic, ensuring that write operations are forbidden when demo mode is active.
- Updated frontend components to reflect demo mode status, disabling actions that modify data and displaying a notification to users.
- Introduced a new System Settings section for configuring feed refresh intervals, with appropriate restrictions in demo mode.
This commit is contained in:
2025-09-15 01:09:54 -05:00
parent 2907b8822b
commit 6978a12cef
27 changed files with 597 additions and 71 deletions

View File

@@ -20,3 +20,11 @@ SECURE_COOKIE=false
# If you are using a reverse proxy like Nginx to handle HTTPS, please leave these empty.
TLS_CERT=""
TLS_KEY=""
# Demo Mode - Set to true for read-only public demo
# When enabled: no authentication required, all write operations blocked
DEMO_MODE=true
# Demo Mode Feeds - Comma-separated list of RSS feed URLs to auto-populate in demo mode
# Only used when DEMO_MODE=true. Leave empty to start with no feeds.
DEMO_MODE_FEEDS=https://feeds.bbci.co.uk/news/rss.xml,https://rss.cnn.com/rss/edition.rss,https://feeds.feedburner.com/TechCrunch,https://www.theverge.com/rss/index.xml,https://hnrss.org/frontpage,https://feeds.arstechnica.com/arstechnica/index,https://www.reddit.com/r/programming/.rss,https://dev.to/feed,https://feeds.simplecast.com/54nAGcIl,https://changelog.com/master/feed

View File

@@ -32,6 +32,7 @@ type Params struct {
TLSCert string
TLSKey string
DBPath string
DemoMode bool
}
func Run(params Params) {
@@ -92,7 +93,7 @@ func Run(params Params) {
authed := r.Group("/api")
if params.PasswordHash != nil {
if params.PasswordHash != nil && !params.DemoMode {
loginAPI := Session{
PasswordHash: *params.PasswordHash,
UseSecureCookie: params.UseSecureCookie,
@@ -111,6 +112,25 @@ func Run(params Params) {
authed.DELETE("/sessions", loginAPI.Delete)
}
if params.DemoMode {
authed.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
method := c.Request().Method
path := c.Request().URL.Path
// Allow feed refresh in demo mode (read-only operation)
if method == "POST" && path == "/api/feeds/refresh" {
return next(c)
}
if method == "POST" || method == "PATCH" || method == "DELETE" {
return echo.NewHTTPError(http.StatusForbidden, "Demo mode: write operations not allowed")
}
return next(c)
}
})
}
feeds := authed.Group("/feeds")
feedAPIHandler := newFeedAPI(server.NewFeed(repo.NewFeed(repo.DB)))
feeds.GET("", feedAPIHandler.List)
@@ -139,6 +159,16 @@ func Run(params Params) {
statsAPIHandler := newStatsAPI(server.NewStats(repo.NewStats(repo.DB), params.DBPath))
authed.GET("/stats", statsAPIHandler.Get)
configAPIHandler := newConfigAPI(server.NewConfig(repo.NewConfig(repo.DB)))
authed.GET("/config", configAPIHandler.Get)
authed.PATCH("/config", configAPIHandler.Update)
r.GET("/api/config", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"demo_mode": params.DemoMode,
})
})
var err error
addr := fmt.Sprintf("%s:%d", params.Host, params.Port)
if params.TLSCert != "" {

40
api/config.go Normal file
View File

@@ -0,0 +1,40 @@
package api
import (
"net/http"
"github.com/0x2e/fusion/server"
"github.com/labstack/echo/v4"
)
type configAPI struct {
srv *server.Config
}
func newConfigAPI(srv *server.Config) *configAPI {
return &configAPI{
srv: srv,
}
}
func (c configAPI) Get(ctx echo.Context) error {
resp, err := c.srv.Get(ctx.Request().Context())
if err != nil {
return err
}
return ctx.JSON(http.StatusOK, resp)
}
func (c configAPI) Update(ctx echo.Context) error {
var req server.ReqConfigUpdate
if err := bindAndValidate(&req, ctx); err != nil {
return err
}
if err := c.srv.Update(ctx.Request().Context(), &req); err != nil {
return err
}
return ctx.NoContent(http.StatusNoContent)
}

View File

@@ -10,6 +10,8 @@ import (
"github.com/0x2e/fusion/api"
"github.com/0x2e/fusion/conf"
"github.com/0x2e/fusion/repo"
"github.com/0x2e/fusion/server"
"github.com/0x2e/fusion/service/demo"
"github.com/0x2e/fusion/service/pull"
)
@@ -41,7 +43,14 @@ func main() {
}
repo.Init(config.DB)
go pull.NewPuller(repo.NewFeed(repo.DB), repo.NewItem(repo.DB)).Run()
if config.DemoMode && config.DemoModeFeeds != "" {
seeder := demo.NewFeedSeeder(repo.NewFeed(repo.DB), repo.NewGroup(repo.DB))
if err := seeder.SeedFeeds(config.DemoModeFeeds); err != nil {
slog.Error("Failed to seed demo feeds", "error", err)
}
}
go pull.NewPuller(repo.NewFeed(repo.DB), repo.NewItem(repo.DB), server.NewConfig(repo.NewConfig(repo.DB))).Run()
api.Run(api.Params{
Host: config.Host,
@@ -51,5 +60,6 @@ func main() {
TLSCert: config.TLSCert,
TLSKey: config.TLSKey,
DBPath: config.DB,
DemoMode: config.DemoMode,
})
}

View File

@@ -18,13 +18,15 @@ const (
)
type Conf struct {
Host string
Port int
PasswordHash *auth.HashedPassword
DB string
SecureCookie bool
TLSCert string
TLSKey string
Host string
Port int
PasswordHash *auth.HashedPassword
DB string
SecureCookie bool
TLSCert string
TLSKey string
DemoMode bool
DemoModeFeeds string
}
func Load() (Conf, error) {
@@ -37,13 +39,15 @@ func Load() (Conf, error) {
slog.Info(fmt.Sprintf("load configuration from %s", dotEnvFilename))
}
var conf struct {
Host string `env:"HOST" envDefault:"0.0.0.0"`
Port int `env:"PORT" envDefault:"8080"`
Password string `env:"PASSWORD"`
DB string `env:"DB" envDefault:"fusion.db"`
SecureCookie bool `env:"SECURE_COOKIE" envDefault:"false"`
TLSCert string `env:"TLS_CERT"`
TLSKey string `env:"TLS_KEY"`
Host string `env:"HOST" envDefault:"0.0.0.0"`
Port int `env:"PORT" envDefault:"8080"`
Password string `env:"PASSWORD"`
DB string `env:"DB" envDefault:"fusion.db"`
SecureCookie bool `env:"SECURE_COOKIE" envDefault:"false"`
TLSCert string `env:"TLS_CERT"`
TLSKey string `env:"TLS_KEY"`
DemoMode bool `env:"DEMO_MODE" envDefault:"false"`
DemoModeFeeds string `env:"DEMO_MODE_FEEDS"`
}
if err := env.Parse(&conf); err != nil {
return Conf{}, err
@@ -67,12 +71,14 @@ func Load() (Conf, error) {
}
return Conf{
Host: conf.Host,
Port: conf.Port,
PasswordHash: pwHash,
DB: conf.DB,
SecureCookie: conf.SecureCookie,
TLSCert: conf.TLSCert,
TLSKey: conf.TLSKey,
Host: conf.Host,
Port: conf.Port,
PasswordHash: pwHash,
DB: conf.DB,
SecureCookie: conf.SecureCookie,
TLSCert: conf.TLSCert,
TLSKey: conf.TLSKey,
DemoMode: conf.DemoMode,
DemoModeFeeds: conf.DemoModeFeeds,
}, nil
}

View File

@@ -0,0 +1,21 @@
import { api } from './api';
export type Config = {
demo_mode: boolean;
};
export type AppConfig = {
feed_refresh_interval_minutes: number;
};
export async function getConfig(): Promise<Config> {
return await api.get('config').json<Config>();
}
export async function getAppConfig(): Promise<AppConfig> {
return await api.get('config').json<AppConfig>();
}
export async function updateAppConfig(config: { feed_refresh_interval_minutes: number }): Promise<void> {
await api.patch('config', { json: config });
}

View File

@@ -112,8 +112,8 @@
});
</script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
bind:this={container}
class="pull-to-refresh-container"

View File

@@ -157,19 +157,21 @@
<ThemeController />
</div>
<ul class="menu mt-4 w-full font-medium">
<li>
<button
onclick={() => {
toggleShowFeedImport();
}}
class="btn btn-sm btn-ghost bg-base-100"
>
<CirclePlus class="size-4" />
<span>{t('feed.import.title')}</span>
</button>
</li>
</ul>
{#if !globalState.demoMode}
<ul class="menu mt-4 w-full font-medium">
<li>
<button
onclick={() => {
toggleShowFeedImport();
}}
class="btn btn-sm btn-ghost bg-base-100"
>
<CirclePlus class="size-4" />
<span>{t('feed.import.title')}</span>
</button>
</li>
</ul>
{/if}
<ul class="menu w-full font-medium">
{#each systemLinks as v}

View File

@@ -2,7 +2,8 @@ import { type Feed, type Group } from './api/model';
export const globalState = $state({
groups: [] as Group[],
feeds: [] as Feed[]
feeds: [] as Feed[],
demoMode: false
});
export function setGlobalFeeds(feeds: Feed[]) {
@@ -13,6 +14,10 @@ export function setGlobalGroups(groups: Group[]) {
globalState.groups = groups;
}
export function setDemoMode(demoMode: boolean) {
globalState.demoMode = demoMode;
}
export function updateUnreadCount(feedId: number, change: number) {
const feed = globalState.feeds.find((f) => f.id === feedId);
if (feed) {

View File

@@ -3,6 +3,7 @@
import FeedActionImport from '$lib/components/FeedActionImport.svelte';
import ShortcutHelpModal from '$lib/components/ShortcutHelpModal.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import { globalState } from '$lib/state.svelte';
let { children } = $props();
let showSidebar = $state(false);
@@ -14,6 +15,12 @@
<div class="drawer lg:drawer-open">
<input id="sidebar-toggle" type="checkbox" bind:checked={showSidebar} class="drawer-toggle" />
<div class="drawer-content bg-base-100 relative z-10 min-h-screen overflow-x-clip">
{#if globalState.demoMode}
<div class="alert alert-info shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span><strong>Demo Mode:</strong> This is a read-only demonstration. You cannot add, edit, or delete feeds.</span>
</div>
{/if}
<div class="mx-auto flex h-full max-w-6xl flex-col pb-4">
<svelte:boundary>
{@render children()}

View File

@@ -1,12 +1,16 @@
import { getConfig } from '$lib/api/config';
import { listFeeds } from '$lib/api/feed';
import { allGroups } from '$lib/api/group';
import { setGlobalFeeds, setGlobalGroups } from '$lib/state.svelte';
import { setDemoMode, setGlobalFeeds, setGlobalGroups } from '$lib/state.svelte';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ depends }) => {
depends('app:feeds', 'app:groups');
depends('app:feeds', 'app:groups', 'app:config');
await Promise.all([
getConfig().then((config) => {
setDemoMode(config.demo_mode);
}),
allGroups().then((groups) => {
groups.sort((a, b) => a.id - b.id);
setGlobalGroups(groups);

View File

@@ -6,6 +6,7 @@
import GlobalActionSection from './GlobalActionSection.svelte';
import GroupSection from './GroupSection.svelte';
import AppearanceSection from './AppearanceSection.svelte';
import SystemSection from './SystemSection.svelte';
import StatsSection from './StatsSection.svelte';
import ErrorsSection from './ErrorsSection.svelte';
import { t } from '$lib/i18n';
@@ -17,6 +18,7 @@
{ label: t('settings.global_actions'), hash: '#global-actions' },
{ label: t('settings.appearance'), hash: '#appearance' },
{ label: t('common.groups'), hash: '#groups' },
{ label: 'System', hash: '#system' },
{ label: 'Statistics', hash: '#stats' },
{ label: 'Errors', hash: '#errors' }
];
@@ -56,6 +58,7 @@
<GlobalActionSection />
<AppearanceSection />
<GroupSection />
<SystemSection />
<StatsSection />
<ErrorsSection />
</div>

View File

@@ -2,6 +2,7 @@
import { getFeedErrors, type FeedError } from '$lib/api/errors';
import { refreshFeeds, deleteFeed } from '$lib/api/feed';
import { getFavicon } from '$lib/api/favicon';
import { globalState } from '$lib/state.svelte';
import { t } from '$lib/i18n';
import { AlertTriangle, ExternalLink, RefreshCw, Clock, Trash2 } from 'lucide-svelte';
import { onMount } from 'svelte';
@@ -122,12 +123,20 @@
{errors.length} feed{errors.length === 1 ? '' : 's'} with errors
</p>
<div class="flex gap-2">
<button onclick={retryAllFailed} class="btn btn-sm btn-outline">
<button
onclick={retryAllFailed}
disabled={globalState.demoMode}
class="btn btn-sm btn-outline"
>
<RefreshCw class="size-4" />
Retry All
</button>
{#if errors.some(e => e.consecutive_failures >= 3)}
<button onclick={removeAllProblemFeeds} class="btn btn-sm btn-outline btn-error">
<button
onclick={removeAllProblemFeeds}
disabled={globalState.demoMode}
class="btn btn-sm btn-outline btn-error"
>
<Trash2 class="size-4" />
Remove Problem Feeds
</button>
@@ -175,11 +184,19 @@
<a href="/feeds/{feedError.feed.id}" class="btn btn-xs btn-ghost">
View
</a>
<button onclick={() => retryFeed(feedError.feed.id)} class="btn btn-xs btn-outline">
<button
onclick={() => retryFeed(feedError.feed.id)}
disabled={globalState.demoMode}
class="btn btn-xs btn-outline"
>
<RefreshCw class="size-3" />
Retry
</button>
<button onclick={() => removeFeed(feedError.feed.id, feedError.feed.name)} class="btn btn-xs btn-outline btn-error">
<button
onclick={() => removeFeed(feedError.feed.id, feedError.feed.name)}
disabled={globalState.demoMode}
class="btn btn-xs btn-outline btn-error"
>
<Trash2 class="size-3" />
Remove
</button>

View File

@@ -3,6 +3,7 @@
import { allGroups } from '$lib/api/group';
import { t } from '$lib/i18n';
import { dump } from '$lib/opml';
import { globalState } from '$lib/state.svelte';
import { toast } from 'svelte-sonner';
import Section from './Section.svelte';
@@ -44,7 +45,10 @@
<Section id="global-actions" title={t('settings.global_actions')}>
<div class="flex flex-wrap gap-2">
<button onclick={() => handleRefreshAllFeeds()} class="btn btn-wide"
<button
onclick={() => handleRefreshAllFeeds()}
disabled={globalState.demoMode}
class="btn btn-wide"
>{t('settings.global_actions.refresh_all_feeds')}</button
>
<button onclick={() => handleExportAllFeeds()} class="btn btn-wide"

View File

@@ -51,20 +51,35 @@
<div class="flex flex-col space-y-4">
{#each existingGroups as g}
<div class="flex flex-col items-center space-x-2 md:flex-row">
<input type="text" class="input w-full md:w-56" bind:value={g.name} />
<input
type="text"
class="input w-full md:w-56"
bind:value={g.name}
disabled={globalState.demoMode}
/>
<div class="flex gap-2">
<button onclick={() => handleUpdate(g.id)} class="btn btn-ghost">
<button
onclick={() => handleUpdate(g.id)}
disabled={globalState.demoMode}
class="btn btn-ghost"
>
{t('common.save')}
</button>
<button onclick={() => handleDelete(g.id)} class="btn btn-ghost text-error">
<button
onclick={() => handleDelete(g.id)}
disabled={globalState.demoMode}
class="btn btn-ghost text-error"
>
{t('common.delete')}
</button>
</div>
</div>
{/each}
<div class="flex items-center space-x-2">
<input type="text" class="input w-full md:w-56" bind:value={newGroup} />
<button onclick={() => handleAddNew()} class="btn btn-ghost"> {t('common.add')} </button>
</div>
{#if !globalState.demoMode}
<div class="flex items-center space-x-2">
<input type="text" class="input w-full md:w-56" bind:value={newGroup} />
<button onclick={() => handleAddNew()} class="btn btn-ghost"> {t('common.add')} </button>
</div>
{/if}
</div>
</Section>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { getAppConfig, updateAppConfig } from '$lib/api/config';
import { globalState } from '$lib/state.svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import Section from './Section.svelte';
import { t } from '$lib/i18n';
let feedRefreshInterval = $state(30);
let loading = $state(false);
onMount(async () => {
try {
const config = await getAppConfig();
feedRefreshInterval = config.feed_refresh_interval_minutes;
} catch (e) {
console.error('Failed to load app config:', e);
feedRefreshInterval = 30;
}
});
async function handleSave() {
if (loading || globalState.demoMode) return;
loading = true;
try {
await updateAppConfig({
feed_refresh_interval_minutes: feedRefreshInterval
});
toast.success(t('state.success'));
} catch (e) {
toast.error((e as Error).message);
} finally {
loading = false;
}
}
function handleIntervalChange(event: Event) {
const input = event.target as HTMLInputElement;
feedRefreshInterval = parseInt(input.value) || 30;
}
</script>
<Section
id="system"
title="System Settings"
description="Configure system-wide settings for feed management"
>
<div class="flex flex-col space-y-4">
<fieldset class="fieldset">
<legend class="fieldset-legend">Feed Refresh Interval</legend>
<div class="flex items-center gap-4">
<input
type="number"
min="1"
max="10080"
value={feedRefreshInterval}
onchange={handleIntervalChange}
disabled={globalState.demoMode || loading}
class="input w-24"
/>
<span class="text-sm text-gray-600">minutes</span>
<button
onclick={handleSave}
disabled={globalState.demoMode || loading}
class="btn btn-primary btn-sm"
>
{loading ? 'Saving...' : t('common.save')}
</button>
</div>
<div class="text-xs text-gray-500 mt-2">
How often feeds are checked for updates. Minimum: 1 minute, Maximum: 1 week (10080 minutes)
</div>
</fieldset>
</div>
</Section>

14
model/config.go Normal file
View File

@@ -0,0 +1,14 @@
package model
import (
"time"
)
type Config struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
Key string `gorm:"key;uniqueIndex;not null"`
Value string `gorm:"value;not null"`
}

76
repo/config.go Normal file
View File

@@ -0,0 +1,76 @@
package repo
import (
"strconv"
"time"
"github.com/0x2e/fusion/model"
"gorm.io/gorm"
)
func NewConfig(db *gorm.DB) *Config {
return &Config{
db: db,
}
}
type Config struct {
db *gorm.DB
}
func (c *Config) Get(key string) (string, error) {
var config model.Config
err := c.db.Where("key = ?", key).First(&config).Error
if err != nil {
return "", err
}
return config.Value, nil
}
func (c *Config) Set(key, value string) error {
config := model.Config{
Key: key,
Value: value,
}
return c.db.Save(&config).Error
}
func (c *Config) GetInt(key string, defaultValue int) (int, error) {
value, err := c.Get(key)
if err != nil {
if err == ErrNotFound {
return defaultValue, nil
}
return 0, err
}
result, err := strconv.Atoi(value)
if err != nil {
return defaultValue, nil
}
return result, nil
}
func (c *Config) SetInt(key string, value int) error {
return c.Set(key, strconv.Itoa(value))
}
func (c *Config) GetDuration(key string, defaultValue time.Duration) (time.Duration, error) {
value, err := c.Get(key)
if err != nil {
if err == ErrNotFound {
return defaultValue, nil
}
return 0, err
}
result, err := time.ParseDuration(value)
if err != nil {
return defaultValue, nil
}
return result, nil
}
func (c *Config) SetDuration(key string, value time.Duration) error {
return c.Set(key, value.String())
}

View File

@@ -75,7 +75,7 @@ func migrage() {
}
// FIX: gorm not auto drop index and change 'not null'
if err := DB.AutoMigrate(&model.Feed{}, &model.Group{}, &model.Item{}); err != nil {
if err := DB.AutoMigrate(&model.Feed{}, &model.Group{}, &model.Item{}, &model.Config{}); err != nil {
panic(err)
}

56
server/config.go Normal file
View File

@@ -0,0 +1,56 @@
package server
import (
"context"
"time"
)
const (
ConfigKeyFeedRefreshInterval = "feed_refresh_interval"
DefaultFeedRefreshInterval = 30 * time.Minute
)
type ConfigRepo interface {
Get(key string) (string, error)
Set(key, value string) error
GetDuration(key string, defaultValue time.Duration) (time.Duration, error)
SetDuration(key string, value time.Duration) error
}
type Config struct {
repo ConfigRepo
}
func NewConfig(repo ConfigRepo) *Config {
return &Config{
repo: repo,
}
}
type ReqConfigUpdate struct {
FeedRefreshIntervalMinutes int `json:"feed_refresh_interval_minutes" validate:"min=1,max=10080"`
}
type RespConfig struct {
FeedRefreshIntervalMinutes int `json:"feed_refresh_interval_minutes"`
}
func (c *Config) Get(ctx context.Context) (*RespConfig, error) {
interval, err := c.repo.GetDuration(ConfigKeyFeedRefreshInterval, DefaultFeedRefreshInterval)
if err != nil {
return nil, err
}
return &RespConfig{
FeedRefreshIntervalMinutes: int(interval.Minutes()),
}, nil
}
func (c *Config) Update(ctx context.Context, req *ReqConfigUpdate) error {
interval := time.Duration(req.FeedRefreshIntervalMinutes) * time.Minute
return c.repo.SetDuration(ConfigKeyFeedRefreshInterval, interval)
}
func (c *Config) GetFeedRefreshInterval() (time.Duration, error) {
return c.repo.GetDuration(ConfigKeyFeedRefreshInterval, DefaultFeedRefreshInterval)
}

View File

@@ -108,7 +108,7 @@ func (f Feed) Create(ctx context.Context, req *ReqFeedCreate) (*RespFeedCreate,
IDs: ids,
}
puller := pull.NewPuller(repo.NewFeed(repo.DB), repo.NewItem(repo.DB))
puller := pull.NewPuller(repo.NewFeed(repo.DB), repo.NewItem(repo.DB), nil)
if len(feeds) > 1 {
go func() {
routinePool := make(chan struct{}, 10)
@@ -191,7 +191,7 @@ func (f Feed) Delete(ctx context.Context, req *ReqFeedDelete) error {
}
func (f Feed) Refresh(ctx context.Context, req *ReqFeedRefresh) error {
pull := pull.NewPuller(repo.NewFeed(repo.DB), repo.NewItem(repo.DB))
pull := pull.NewPuller(repo.NewFeed(repo.DB), repo.NewItem(repo.DB), nil)
if req.ID != nil {
return pull.PullOne(ctx, *req.ID)
}

101
service/demo/seeder.go Normal file
View File

@@ -0,0 +1,101 @@
package demo
import (
"log/slog"
"strings"
"github.com/0x2e/fusion/model"
"github.com/0x2e/fusion/repo"
)
type FeedSeeder struct {
feedRepo *repo.Feed
groupRepo *repo.Group
}
func NewFeedSeeder(feedRepo *repo.Feed, groupRepo *repo.Group) *FeedSeeder {
return &FeedSeeder{
feedRepo: feedRepo,
groupRepo: groupRepo,
}
}
func (s *FeedSeeder) SeedFeeds(feedUrls string) error {
if feedUrls == "" {
return nil
}
slog.Info("Seeding demo feeds", "feeds", feedUrls)
urls := strings.Split(feedUrls, ",")
defaultGroup, err := s.groupRepo.Get(1)
if err != nil {
slog.Error("Failed to get default group", "error", err)
return err
}
existing, err := s.feedRepo.List(nil)
if err != nil {
slog.Error("Failed to list existing feeds", "error", err)
return err
}
existingUrls := make(map[string]bool)
for _, feed := range existing {
if feed.Link != nil {
existingUrls[*feed.Link] = true
}
}
var newFeeds []*model.Feed
for i, url := range urls {
url = strings.TrimSpace(url)
if url == "" {
continue
}
if existingUrls[url] {
slog.Debug("Feed already exists, skipping", "url", url)
continue
}
feedName := s.generateFeedName(url, i+1)
feed := &model.Feed{
Name: &feedName,
Link: &url,
GroupID: defaultGroup.ID,
}
newFeeds = append(newFeeds, feed)
slog.Info("Prepared demo feed", "name", feedName, "url", url)
}
if len(newFeeds) > 0 {
err = s.feedRepo.Create(newFeeds)
if err != nil {
slog.Error("Failed to create demo feeds", "error", err)
return err
}
slog.Info("Successfully created demo feeds", "count", len(newFeeds))
} else {
slog.Info("No new demo feeds to create")
}
return nil
}
func (s *FeedSeeder) generateFeedName(url string, index int) string {
url = strings.TrimPrefix(url, "https://")
url = strings.TrimPrefix(url, "http://")
parts := strings.Split(url, "/")
if len(parts) > 0 {
domain := parts[0]
if domain != "" {
return domain
}
}
return "Demo Feed " + string(rune('A' + index - 1))
}

View File

@@ -12,13 +12,13 @@ const maxBackoff = 7 * 24 * time.Hour
// CalculateBackoffTime calculates the exponential backoff time based on the
// number of consecutive failures.
// The formula is: interval * (1.8 ^ consecutiveFailures), capped at maxBackoff.
func CalculateBackoffTime(consecutiveFailures uint) time.Duration {
func CalculateBackoffTime(consecutiveFailures uint, currentInterval time.Duration) time.Duration {
// If no failures, no backoff needed
if consecutiveFailures == 0 {
return 0
}
intervalMinutes := float64(interval.Minutes())
intervalMinutes := float64(currentInterval.Minutes())
backoffMinutes := intervalMinutes * math.Pow(1.8, float64(consecutiveFailures))
// floats go to Inf if the number is too large to represent in a float type,

View File

@@ -48,7 +48,7 @@ func TestCalculateBackoffTime(t *testing.T) {
},
} {
t.Run(tt.name, func(t *testing.T) {
backoff := pull.CalculateBackoffTime(tt.consecutiveFailures)
backoff := pull.CalculateBackoffTime(tt.consecutiveFailures, 30*time.Minute)
assert.Equal(t, tt.expectedBackoff, backoff)
})
}

View File

@@ -16,7 +16,8 @@ func (p *Puller) do(ctx context.Context, f *model.Feed, force bool) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
updateAction, skipReason := DecideFeedUpdateAction(f, time.Now())
currentInterval := p.getCurrentInterval()
updateAction, skipReason := DecideFeedUpdateAction(f, time.Now(), currentInterval)
if skipReason == &SkipReasonSuspended {
logger.Info(fmt.Sprintf("skip: %s", skipReason))
return nil
@@ -65,17 +66,17 @@ var (
SkipReasonTooSoon = FeedSkipReason{"feed was updated too recently"}
)
func DecideFeedUpdateAction(f *model.Feed, now time.Time) (FeedUpdateAction, *FeedSkipReason) {
func DecideFeedUpdateAction(f *model.Feed, now time.Time, currentInterval time.Duration) (FeedUpdateAction, *FeedSkipReason) {
if f.IsSuspended() {
return ActionSkipUpdate, &SkipReasonSuspended
} else if f.ConsecutiveFailures > 0 {
backoffTime := CalculateBackoffTime(f.ConsecutiveFailures)
backoffTime := CalculateBackoffTime(f.ConsecutiveFailures, currentInterval)
timeSinceUpdate := now.Sub(f.UpdatedAt)
if timeSinceUpdate < backoffTime {
slog.Info(fmt.Sprintf("%d consecutive feed update failures, so next attempt is after %v", f.ConsecutiveFailures, f.UpdatedAt.Add(backoffTime).Format(time.RFC3339)), "feed_id", f.ID, "feed_link", ptr.From(f.Link))
return ActionSkipUpdate, &SkipReasonCoolingOff
}
} else if now.Sub(f.UpdatedAt) < interval {
} else if now.Sub(f.UpdatedAt) < currentInterval {
return ActionSkipUpdate, &SkipReasonTooSoon
}
return ActionFetchUpdate, nil

View File

@@ -146,7 +146,7 @@ func TestDecideFeedUpdateAction(t *testing.T) {
},
} {
t.Run(tt.description, func(t *testing.T) {
action, skipReason := pull.DecideFeedUpdateAction(&tt.feed, tt.currentTime)
action, skipReason := pull.DecideFeedUpdateAction(&tt.feed, tt.currentTime, 30*time.Minute)
assert.Equal(t, tt.expectedAction, action)
assert.Equal(t, tt.expectedSkipReason, skipReason)
})

View File

@@ -26,22 +26,30 @@ type ItemRepo interface {
Insert(items []*model.Item) error
}
type ConfigRepo interface {
GetFeedRefreshInterval() (time.Duration, error)
}
type Puller struct {
feedRepo FeedRepo
itemRepo ItemRepo
feedRepo FeedRepo
itemRepo ItemRepo
configRepo ConfigRepo
}
// TODO: cache favicon
func NewPuller(feedRepo FeedRepo, itemRepo ItemRepo) *Puller {
func NewPuller(feedRepo FeedRepo, itemRepo ItemRepo, configRepo ConfigRepo) *Puller {
return &Puller{
feedRepo: feedRepo,
itemRepo: itemRepo,
feedRepo: feedRepo,
itemRepo: itemRepo,
configRepo: configRepo,
}
}
func (p *Puller) Run() {
ticker := time.NewTicker(interval)
// Get initial interval
currentInterval := p.getCurrentInterval()
ticker := time.NewTicker(currentInterval)
defer ticker.Stop()
for {
@@ -49,11 +57,33 @@ func (p *Puller) Run() {
p.PullAll(context.Background(), false)
<-ticker.C
// Check if interval has changed and update ticker if needed
newInterval := p.getCurrentInterval()
if newInterval != currentInterval {
currentInterval = newInterval
ticker.Stop()
ticker = time.NewTicker(currentInterval)
}
}
}
func (p *Puller) getCurrentInterval() time.Duration {
if p.configRepo == nil {
return interval
}
configInterval, err := p.configRepo.GetFeedRefreshInterval()
if err != nil {
slog.Warn("failed to get feed refresh interval from config, using default", "error", err)
return interval
}
return configInterval
}
func (p *Puller) PullAll(ctx context.Context, force bool) error {
ctx, cancel := context.WithTimeout(ctx, interval/2)
currentInterval := p.getCurrentInterval()
ctx, cancel := context.WithTimeout(ctx, currentInterval/2)
defer cancel()
feeds, err := p.feedRepo.List(nil)