diff options
| author | Fuwn <[email protected]> | 2026-02-08 01:35:41 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-08 01:35:41 -0800 |
| commit | b8a9b40f786554e5a49511ce1b2dd2ea7f3db94c (patch) | |
| tree | 23fcdad5c10a7a269286459b4b666120d92f2af7 | |
| parent | fix: update worker Dockerfile to Go 1.24 to match go.mod requirement (diff) | |
| download | asa.news-b8a9b40f786554e5a49511ce1b2dd2ea7f3db94c.tar.xz asa.news-b8a9b40f786554e5a49511ce1b2dd2ea7f3db94c.zip | |
feat: implement authenticated feed support across worker and web app
Wire up the full authenticated feeds pipeline:
- Worker resolves credentials from Supabase Vault for authenticated feeds
- Worker sets owner_id on entries for per-user dedup
- query_param auth now parses name=value format
- Add-feed dialog shows auth type + credential fields for pro/developer
- Subscribe mutation passes credentials to RPC
- Sidebar and settings show [auth] indicator for authenticated feeds
| -rw-r--r-- | apps/web/app/reader/_components/add-feed-dialog.tsx | 49 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/sidebar-content.tsx | 6 | ||||
| -rw-r--r-- | apps/web/app/reader/settings/_components/subscriptions-settings.tsx | 3 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-subscribe-to-feed.ts | 4 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-subscriptions.ts | 4 | ||||
| -rw-r--r-- | apps/web/lib/types/subscription.ts | 1 | ||||
| -rw-r--r-- | services/worker/internal/fetcher/authentication.go | 9 | ||||
| -rw-r--r-- | services/worker/internal/model/feed.go | 7 | ||||
| -rw-r--r-- | services/worker/internal/scheduler/refresh.go | 23 | ||||
| -rw-r--r-- | services/worker/internal/scheduler/scheduler.go | 58 |
10 files changed, 153 insertions, 11 deletions
diff --git a/apps/web/app/reader/_components/add-feed-dialog.tsx b/apps/web/app/reader/_components/add-feed-dialog.tsx index ff3e916..4ffbd39 100644 --- a/apps/web/app/reader/_components/add-feed-dialog.tsx +++ b/apps/web/app/reader/_components/add-feed-dialog.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react" import { useSubscribeToFeed } from "@/lib/queries/use-subscribe-to-feed" import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUserProfile } from "@/lib/queries/use-user-profile" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" export function AddFeedDialog() { @@ -13,13 +14,21 @@ export function AddFeedDialog() { const [selectedFolderIdentifier, setSelectedFolderIdentifier] = useState< string | null >(null) + const [authenticationType, setAuthenticationType] = useState("none") + const [feedCredential, setFeedCredential] = useState("") const subscribeToFeed = useSubscribeToFeed() const { data: subscriptionsData } = useSubscriptions() + const { data: userProfile } = useUserProfile() + + const supportsAuthenticatedFeeds = + userProfile?.tier === "pro" || userProfile?.tier === "developer" function handleClose() { setFeedUrl("") setCustomTitle("") setSelectedFolderIdentifier(null) + setAuthenticationType("none") + setFeedCredential("") setOpen(false) } @@ -31,6 +40,10 @@ export function AddFeedDialog() { feedUrl, folderIdentifier: selectedFolderIdentifier, customTitle: customTitle || null, + feedCredential: + authenticationType !== "none" ? feedCredential : null, + feedAuthenticationType: + authenticationType !== "none" ? authenticationType : null, }, { onSuccess: () => { @@ -124,6 +137,42 @@ export function AddFeedDialog() { ))} </select> </div> + {supportsAuthenticatedFeeds && ( + <div className="space-y-2"> + <label + htmlFor="auth-type" + className="text-text-secondary" + > + authentication (optional) + </label> + <select + id="auth-type" + value={authenticationType} + onChange={(event) => setAuthenticationType(event.target.value)} + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none" + > + <option value="none">none</option> + <option value="bearer">bearer token</option> + <option value="basic">basic (user:pass)</option> + <option value="query_param">query parameter</option> + </select> + {authenticationType !== "none" && ( + <input + type="password" + value={feedCredential} + onChange={(event) => setFeedCredential(event.target.value)} + placeholder={ + authenticationType === "query_param" + ? "api_key=your_token" + : authenticationType === "basic" + ? "username:password" + : "your token" + } + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + )} + </div> + )} <div className="flex gap-2"> <button type="button" diff --git a/apps/web/app/reader/_components/sidebar-content.tsx b/apps/web/app/reader/_components/sidebar-content.tsx index 401d203..e43451d 100644 --- a/apps/web/app/reader/_components/sidebar-content.tsx +++ b/apps/web/app/reader/_components/sidebar-content.tsx @@ -295,6 +295,9 @@ export function SidebarContent() { <span className={classNames("truncate", showFeedFavicons && "ml-2")}> {displayNameForSubscription(subscription)} </span> + {subscription.feedVisibility === "authenticated" && ( + <span className="ml-1 shrink-0 text-text-dim" title="authenticated feed">🔒</span> + )} {subscription.feedType === "podcast" && ( <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> )} @@ -338,6 +341,9 @@ export function SidebarContent() { <span className={classNames("truncate", showFeedFavicons && "ml-2")}> {displayNameForSubscription(subscription)} </span> + {subscription.feedVisibility === "authenticated" && ( + <span className="ml-1 shrink-0 text-text-dim" title="authenticated feed">🔒</span> + )} {subscription.feedType === "podcast" && ( <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> )} diff --git a/apps/web/app/reader/settings/_components/subscriptions-settings.tsx b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx index 2c79238..e22a005 100644 --- a/apps/web/app/reader/settings/_components/subscriptions-settings.tsx +++ b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx @@ -124,6 +124,9 @@ function SubscriptionRow({ <span className="truncate text-text-primary"> {subscription.customTitle ?? subscription.feedTitle} </span> + {subscription.feedVisibility === "authenticated" && ( + <span className="shrink-0 text-text-dim">[auth]</span> + )} <button onClick={() => { setEditedTitle(subscription.customTitle ?? "") diff --git a/apps/web/lib/queries/use-subscribe-to-feed.ts b/apps/web/lib/queries/use-subscribe-to-feed.ts index 5e585a9..ead1d39 100644 --- a/apps/web/lib/queries/use-subscribe-to-feed.ts +++ b/apps/web/lib/queries/use-subscribe-to-feed.ts @@ -14,11 +14,15 @@ export function useSubscribeToFeed() { feedUrl: string folderIdentifier?: string | null customTitle?: string | null + feedCredential?: string | null + feedAuthenticationType?: string | null }) => { const { data, error } = await supabaseClient.rpc("subscribe_to_feed", { feed_url: parameters.feedUrl, target_folder_id: parameters.folderIdentifier ?? undefined, feed_custom_title: parameters.customTitle ?? undefined, + feed_credential: parameters.feedCredential ?? undefined, + feed_auth_type: parameters.feedAuthenticationType ?? undefined, }) if (error) throw error diff --git a/apps/web/lib/queries/use-subscriptions.ts b/apps/web/lib/queries/use-subscriptions.ts index e6b84ef..2378411 100644 --- a/apps/web/lib/queries/use-subscriptions.ts +++ b/apps/web/lib/queries/use-subscriptions.ts @@ -14,6 +14,7 @@ interface SubscriptionRow { feeds: { title: string | null url: string + visibility: "public" | "authenticated" consecutive_failures: number last_fetch_error: string | null last_fetched_at: string | null @@ -38,7 +39,7 @@ export function useSubscriptions() { const [subscriptionsResult, foldersResult] = await Promise.all([ supabaseClient .from("subscriptions") - .select("id, feed_id, folder_id, custom_title, position, feeds(title, url, consecutive_failures, last_fetch_error, last_fetched_at, fetch_interval_seconds, feed_type)") + .select("id, feed_id, folder_id, custom_title, position, feeds(title, url, visibility, consecutive_failures, last_fetch_error, last_fetched_at, fetch_interval_seconds, feed_type)") .order("position", { ascending: true }), supabaseClient .from("folders") @@ -63,6 +64,7 @@ export function useSubscriptions() { lastFetchedAt: row.feeds?.last_fetched_at ?? null, fetchIntervalSeconds: row.feeds?.fetch_interval_seconds ?? 3600, feedType: row.feeds?.feed_type ?? null, + feedVisibility: row.feeds?.visibility ?? "public", })) const folders: Folder[] = ( diff --git a/apps/web/lib/types/subscription.ts b/apps/web/lib/types/subscription.ts index f2ba995..0dbc8cb 100644 --- a/apps/web/lib/types/subscription.ts +++ b/apps/web/lib/types/subscription.ts @@ -17,4 +17,5 @@ export interface Subscription { lastFetchedAt: string | null fetchIntervalSeconds: number feedType: string | null + feedVisibility: "public" | "authenticated" } diff --git a/services/worker/internal/fetcher/authentication.go b/services/worker/internal/fetcher/authentication.go index ba10196..9ee4539 100644 --- a/services/worker/internal/fetcher/authentication.go +++ b/services/worker/internal/fetcher/authentication.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "strings" ) type AuthenticationConfiguration struct { @@ -21,6 +22,12 @@ func ApplyAuthentication(request *http.Request, authenticationConfig Authenticat request.Header.Set("Authorization", "Basic "+encodedCredentials) case "query_param": + parts := strings.SplitN(authenticationConfig.AuthenticationValue, "=", 2) + + if len(parts) != 2 { + return fmt.Errorf("query_param credential must be in format 'param_name=value', got: %s", authenticationConfig.AuthenticationValue) + } + existingURL, parseError := url.Parse(request.URL.String()) if parseError != nil { @@ -29,7 +36,7 @@ func ApplyAuthentication(request *http.Request, authenticationConfig Authenticat queryParameters := existingURL.Query() - queryParameters.Set("key", authenticationConfig.AuthenticationValue) + queryParameters.Set(parts[0], parts[1]) existingURL.RawQuery = queryParameters.Encode() request.URL = existingURL diff --git a/services/worker/internal/model/feed.go b/services/worker/internal/model/feed.go index 611c820..a903af0 100644 --- a/services/worker/internal/model/feed.go +++ b/services/worker/internal/model/feed.go @@ -18,8 +18,11 @@ type Feed struct { ConsecutiveFailures int NextFetchAt time.Time FetchIntervalSeconds int - CreatedAt time.Time - UpdatedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time + SubscriberUserIdentifier *string + AuthenticationType *string + AuthenticationValue *string } type FeedEntry struct { diff --git a/services/worker/internal/scheduler/refresh.go b/services/worker/internal/scheduler/refresh.go index 229a20f..d1c5d26 100644 --- a/services/worker/internal/scheduler/refresh.go +++ b/services/worker/internal/scheduler/refresh.go @@ -27,16 +27,25 @@ func ProcessRefreshRequest( var ownerIdentifier *string + authenticationConfig := fetcher.AuthenticationConfiguration{} + if feed.Visibility == "authenticated" { - logger.Warn( - "authenticated feed refresh not yet implemented", - "feed_identifier", feed.Identifier, - ) + if feed.AuthenticationType == nil || feed.AuthenticationValue == nil { + logger.Warn( + "authenticated feed missing credentials, skipping", + "feed_identifier", feed.Identifier, + ) - return - } + return + } - authenticationConfig := fetcher.AuthenticationConfiguration{} + authenticationConfig = fetcher.AuthenticationConfiguration{ + AuthenticationType: *feed.AuthenticationType, + AuthenticationValue: *feed.AuthenticationValue, + } + + ownerIdentifier = feed.SubscriberUserIdentifier + } entityTag := "" if feed.EntityTag != nil { diff --git a/services/worker/internal/scheduler/scheduler.go b/services/worker/internal/scheduler/scheduler.go index 19023e1..f6ebacd 100644 --- a/services/worker/internal/scheduler/scheduler.go +++ b/services/worker/internal/scheduler/scheduler.go @@ -170,6 +170,8 @@ func (feedScheduler *Scheduler) executeQueueCycle(cycleContext context.Context) return } + feedScheduler.enrichAuthenticatedFeed(cycleContext, &feed) + capturedMessageIdentifier := queueMessage.MsgID submitted := feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) { @@ -267,6 +269,10 @@ func (feedScheduler *Scheduler) claimDueFeeds(claimContext context.Context) ([]m return nil, fmt.Errorf("error iterating feed rows: %w", rows.Err()) } + for feedIndex := range claimedFeeds { + feedScheduler.enrichAuthenticatedFeed(claimContext, &claimedFeeds[feedIndex]) + } + return claimedFeeds, nil } @@ -310,6 +316,58 @@ func (feedScheduler *Scheduler) lookupFeed(lookupContext context.Context, feedId return feed, nil } +func (feedScheduler *Scheduler) resolveAuthenticationCredentials( + lookupContext context.Context, + feedIdentifier string, +) (subscriberUserIdentifier *string, authenticationType *string, authenticationValue *string, resolveError error) { + credentialQuery := ` + SELECT + s.user_id, + (ds.decrypted_secret::jsonb ->> 'authenticationType'), + (ds.decrypted_secret::jsonb ->> 'authenticationValue') + FROM subscriptions s + JOIN vault.decrypted_secrets ds ON ds.id = s.vault_secret_id + WHERE s.feed_id = $1 + LIMIT 1 + ` + + scanError := feedScheduler.databaseConnectionPool.QueryRow( + lookupContext, credentialQuery, feedIdentifier, + ).Scan(&subscriberUserIdentifier, &authenticationType, &authenticationValue) + + if scanError != nil { + return nil, nil, nil, fmt.Errorf("failed to resolve credentials for feed %s: %w", feedIdentifier, scanError) + } + + return subscriberUserIdentifier, authenticationType, authenticationValue, nil +} + +func (feedScheduler *Scheduler) enrichAuthenticatedFeed( + enrichContext context.Context, + feed *model.Feed, +) { + if feed.Visibility != "authenticated" { + return + } + + subscriberUserIdentifier, authenticationType, authenticationValue, resolveError := + feedScheduler.resolveAuthenticationCredentials(enrichContext, feed.Identifier) + + if resolveError != nil { + feedScheduler.logger.Error( + "failed to resolve authentication credentials", + "feed_identifier", feed.Identifier, + "error", resolveError, + ) + + return + } + + feed.SubscriberUserIdentifier = subscriberUserIdentifier + feed.AuthenticationType = authenticationType + feed.AuthenticationValue = authenticationValue +} + func (feedScheduler *Scheduler) archivePoisonedMessage(archiveContext context.Context, messageIdentifier int64) { _, archiveError := pgmq.Archive(archiveContext, feedScheduler.databaseConnectionPool, "feed_refresh", messageIdentifier) |