forked from Mirrors/fusionx
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:
@@ -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
|
||||
32
api/api.go
32
api/api.go
@@ -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
40
api/config.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
48
conf/conf.go
48
conf/conf.go
@@ -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
|
||||
}
|
||||
|
||||
21
frontend/src/lib/api/config.ts
Normal file
21
frontend/src/lib/api/config.ts
Normal 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 });
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
76
frontend/src/routes/(authed)/settings/SystemSection.svelte
Normal file
76
frontend/src/routes/(authed)/settings/SystemSection.svelte
Normal 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
14
model/config.go
Normal 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
76
repo/config.go
Normal 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())
|
||||
}
|
||||
@@ -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
56
server/config.go
Normal 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)
|
||||
}
|
||||
@@ -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
101
service/demo/seeder.go
Normal 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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user