Repository navigation
feat(payments): let clients see their subscription tier #926
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c9c8ebb
0aa7c8c
b7fbf8d
e34f566
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "nostream": minor | ||
| --- | ||
|
|
||
| feat(payments): let clients see their subscription tier |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,127 @@ | ||||||
| import { path } from 'ramda' | ||||||
|
|
||||||
| import { Request, Response } from 'express' | ||||||
|
|
||||||
| import { IController } from '../../@types/controllers' | ||||||
| import { IRateLimiter } from '../../@types/utils' | ||||||
| import { IUserSubscriptionRepository } from '../../@types/repositories' | ||||||
| import { Settings, SubscriptionPlan } from '../../@types/settings' | ||||||
| import { SubscriptionStatus, UserSubscription } from '../../@types/user-subscription' | ||||||
|
|
||||||
| import { createLogger } from '../../factories/logger-factory' | ||||||
|
|
||||||
| import { | ||||||
| areSubscriptionsEnabled, | ||||||
| findSubscriptionPlan, | ||||||
| getAccessEndsAt, | ||||||
| hasSubscriptionAccess, | ||||||
| } from '../../utils/subscription' | ||||||
| import { fromNpub } from '../../utils/transform' | ||||||
| import { getRemoteAddress } from '../../utils/http' | ||||||
|
|
||||||
| const logger = createLogger('get-subscription-status-controller') | ||||||
|
|
||||||
| export class GetSubscriptionStatusController implements IController { | ||||||
| public constructor( | ||||||
| private readonly userSubscriptionRepository: IUserSubscriptionRepository, | ||||||
| private readonly settings: () => Settings, | ||||||
| private readonly rateLimiter: () => IRateLimiter, | ||||||
| ) {} | ||||||
|
|
||||||
| public async handleRequest(request: Request, response: Response): Promise<void> { | ||||||
| const currentSettings = this.settings() | ||||||
|
|
||||||
| if (await this.isRateLimited(request, currentSettings)) { | ||||||
| response.status(429).setHeader('content-type', 'text/plain; charset=utf8').send('Too many requests') | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| if (!areSubscriptionsEnabled(currentSettings)) { | ||||||
| response.status(404).setHeader('content-type', 'text/plain; charset=utf8').send('Subscriptions are not enabled') | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| const pubkey = this.parsePubkey(request.query?.pubkey) | ||||||
| if (!pubkey) { | ||||||
| response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Invalid pubkey') | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| const subscription = await this.userSubscriptionRepository.findByPubkey(pubkey) | ||||||
|
|
||||||
| // Without a row the user has no plan to report: they were never subscribed, or were admitted before plans. | ||||||
| const body = subscription | ||||||
| ? { | ||||||
| pubkey, | ||||||
| planId: subscription.planId, | ||||||
| status: subscription.status, | ||||||
| active: hasSubscriptionAccess(subscription, new Date()), | ||||||
| currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null, | ||||||
| accessEndsAt: this.getAccessEndedOrEndsAt(subscription, findSubscriptionPlan(currentSettings, subscription.planId)) | ||||||
| ?.toISOString() ?? null, | ||||||
| } | ||||||
| : { pubkey, planId: null, status: null, active: false, currentPeriodEnd: null, accessEndsAt: null } | ||||||
|
|
||||||
| response.status(200).setHeader('content-type', 'application/json; charset=utf8').send(body) | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * When access ends, or ended. For an expired subscription `getAccessEndsAt` gives a placeholder in the past to block | ||||||
| * writes; the date to report is the period end plus the grace the plan allowed. | ||||||
| */ | ||||||
| private getAccessEndedOrEndsAt(subscription: UserSubscription, plan: SubscriptionPlan | undefined): Date | undefined { | ||||||
| if (subscription.status !== SubscriptionStatus.EXPIRED) { | ||||||
| return getAccessEndsAt(subscription) | ||||||
| } | ||||||
|
|
||||||
| if (!subscription.currentPeriodEnd) { | ||||||
| return undefined | ||||||
| } | ||||||
|
|
||||||
| return new Date(subscription.currentPeriodEnd.getTime() + (plan?.graceSeconds ?? 0) * 1000) | ||||||
| } | ||||||
|
|
||||||
| private parsePubkey(raw: unknown): string | undefined { | ||||||
| if (typeof raw !== 'string') { | ||||||
| return undefined | ||||||
| } | ||||||
|
|
||||||
| if (/^[0-9a-f]{64}$/.test(raw)) { | ||||||
| return raw | ||||||
| } | ||||||
|
|
||||||
| if (/^npub1/.test(raw)) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A valid all-uppercase
Suggested change
|
||||||
| try { | ||||||
| return fromNpub(raw) | ||||||
| } catch (_error) { | ||||||
| return undefined | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| return undefined | ||||||
| } | ||||||
|
|
||||||
| /** Shares the invoice limits, since this endpoint sits beside them. */ | ||||||
| private async isRateLimited(request: Request, settings: Settings): Promise<boolean> { | ||||||
| const rateLimits = path(['limits', 'invoice', 'rateLimits'], settings) | ||||||
| if (!Array.isArray(rateLimits) || !rateLimits.length) { | ||||||
| return false | ||||||
| } | ||||||
|
|
||||||
| const ipWhitelist = path(['limits', 'invoice', 'ipWhitelist'], settings) | ||||||
| const remoteAddress = getRemoteAddress(request, settings) | ||||||
|
|
||||||
| let limited = false | ||||||
| if (Array.isArray(ipWhitelist) && !ipWhitelist.includes(remoteAddress)) { | ||||||
| const rateLimiter = this.rateLimiter() | ||||||
| for (const { rate, period } of rateLimits) { | ||||||
| if (await rateLimiter.hit(`${remoteAddress}:subscription-status:${period}`, 1, { period, rate })) { | ||||||
| logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period) | ||||||
| limited = true | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| return limited | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { createSettings } from '../settings-factory' | ||
| import { getMasterDbClient } from '../../database/client' | ||
| import { GetSubscriptionStatusController } from '../../controllers/subscriptions/get-subscription-status-controller' | ||
| import { rateLimiterFactory } from '../rate-limiter-factory' | ||
| import { UserSubscriptionRepository } from '../../repositories/user-subscription-repository' | ||
|
|
||
| export const createGetSubscriptionStatusController = () => | ||
| new GetSubscriptionStatusController( | ||
| new UserSubscriptionRepository(getMasterDbClient()), | ||
| createSettings, | ||
| rateLimiterFactory, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { createGetSubscriptionStatusController } from '../../factories/controllers/get-subscription-status-controller-factory' | ||
| import { Router } from 'express' | ||
| import { withController } from '../../handlers/request-handlers/with-controller-request-handler' | ||
|
|
||
| const subscriptionRouter: Router = Router() | ||
|
|
||
| subscriptionRouter.get('/status', withController(createGetSubscriptionStatusController)) | ||
|
|
||
| export default subscriptionRouter |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/subscriptions/statusreturns noAccess-Control-Allow-Originheader, and neither the app nor the router adds one. Browser clients hosted on another site therefore cannot read the response or show the user's subscription tier, even when the request succeeds. Add CORS headers to this public endpoint, including its error responses.