fp/services/next/app/lib/patreon.ts

254 lines
7.7 KiB
TypeScript

import { postgrestLocalUrl, patreonVideoAccessBenefitId, giteaUrl } from './constants'
import { type IPatron } from '@futureporn/types'
import { PublicPatron } from '@futureporn/utils/patron.ts';
import { Session } from 'next-auth';
import { JWT } from 'next-auth/jwt';
import { type UMAToken, getKeycloakIdFromTierId, getUMAToken, syncronizeKeycloakRoles } from './keycloak';
import { patreonApiIdentityUrl } from './constants';
export type TiersList = string[]
interface KeycloakToken {
account: {
access_token: string
}
}
interface KeycloakIdpToken {
access_token: string;
expires_in: number;
refresh_expires_in: number;
refresh_token: string;
token_type: string;
"not-before-policy": number;
scope: string;
version: string;
}
interface PatreonResponse {
data: UserData;
included: IncludedItem[];
links: {
self: string;
};
}
interface UserData {
attributes: UserAttributes;
id: string;
relationships: {
memberships: {
data: Relationship[];
};
};
type: "user";
}
interface UserAttributes {
about: string | null;
created: string; // ISO 8601 date string
first_name: string;
full_name: string;
image_url: string;
last_name: string;
thumb_url: string;
url: string;
vanity: string | null;
}
interface Relationship {
id: string;
type: string;
}
type IncludedItem = Member | Tier | Benefit;
interface Member {
attributes: Record<string, unknown>;
id: string;
relationships: {
currently_entitled_tiers: {
data: Relationship[];
};
};
type: "member";
}
export interface Tier {
attributes: Record<string, unknown>;
id: string;
relationships: {
benefits: {
data: Relationship[];
};
};
type: "tier";
}
interface Benefit {
attributes: Record<string, unknown>;
id: string;
type: "benefit";
}
export interface ICampaign {
pledgeSum: number;
patronCount: number;
}
export interface IMarshalledCampaign {
data: {
attributes: {
pledge_sum: number,
patron_count: number
}
}
}
export const tiers = {
everyone: '-1',
free: '10620388',
archiveSupporter: '8154170',
stealthSupporter: '9561793',
tuneItUp: '9184994',
maxQ: '22529959',
archiveCollector: '8154171',
advancedArchiveSupporter: '8686045',
quantumSupporter: '8694826',
sneakyQuantumSupporter: '9560538',
luberPlusPlus: '8686022'
}
// Maps patreon tiers to keycloak roles
// @todo this is a dirty hack, this should be removed once we figure out how to sync keycloak roles at login.
export const tiersToRolesMap: Record<string, string[]> = {
everyone: ['cdn_2', 'default-roles-futureporn', 'offline_access', 'uma_authorization'],
free: ['cdn_2', 'default-roles-futureporn', 'offline_access', 'uma_authorization'],
archiveSupporter: ['cdn_2', 'cdn_1', 'patron', 'website_shoutout', 'offline_access', 'default-roles-futureporn', 'uma_authorization'],
stealthSupporter: ['hacker', 'cdn_2', 'cdn_1', 'patron', 'website_shoutout', 'offline_access', 'default-roles-futureporn', 'uma_authorization'],
tuneItUp: ['cdn_2', 'cdn_1', 'patron', 'website_shoutout', 'offline_access', 'default-roles-futureporn', 'uma_authorization'],
maxQ: ['cdn_2', 'cdn_1', 'patron', 'website_shoutout', 'offline_access', 'default-roles-futureporn', 'uma_authorization', 'uploader'],
archiveCollector: ['cdn_2', 'cdn_1', 'patron', 'website_shoutout', 'offline_access', 'default-roles-futureporn', 'uma_authorization', 'uploader'],
advancedArchiveSupporter: ['cdn_2', 'cdn_1', 'patron', 'website_shoutout', 'offline_access', 'default-roles-futureporn', 'uma_authorization', 'uploader'],
quantumSupporter: ['cdn_2', 'cdn_1', 'patron', 'website_shoutout', 'offline_access', 'default-roles-futureporn', 'uma_authorization', 'uploader'],
sneakyQuantumSupporter: ['cdn_2', 'cdn_1', 'patron', 'website_shoutout', 'offline_access', 'default-roles-futureporn', 'uma_authorization', 'uploader'],
luberPlusPlus: ['cdn_2', 'cdn_1', 'patron', 'website_shoutout', 'offline_access', 'default-roles-futureporn', 'uma_authorization', 'uploader']
};
export async function updateKeycloakUserPatreonEntitlements(token: JWT): Promise<TiersList> {
console.log(`updateKeycloakUserPatreonEntitlements() invoked.`)
const userId = token?.sub
if (!userId) {
throw new Error(`failed to get userId from token.sub`);
}
const keycloakidpToken = await getKeycloakIdpToken(token.access_token)
if (!keycloakidpToken) {
throw new Error(`failed to get keycloakIdpToken; it was falsy.`)
}
const patreonTiersList = await getPatreonMemberships(keycloakidpToken)
await syncronizeKeycloakRoles(userId, patreonTiersList)
return patreonTiersList
}
export async function getKeycloakIdpToken(access_token: string): Promise<KeycloakIdpToken> {
// console.log(`getKeycloakIdpToken() using access_token=${access_token}`)
// @todo check the access_token. if it is expired, use the refresh_token to get a new access_token.
const res = await fetch(`https://keycloak.fp.sbtp.xyz/realms/futureporn/broker/patreon/token`, {
headers: {
'Authorization': `Bearer ${access_token}`
}
})
if (!res.ok) {
const bod = await res.text()
const msg = `req.status=${res.status} req.statusText=${res.statusText} body=${bod}`
console.log(msg)
throw new Error(`Failed to getKeycloakIdpToken. ${msg}`)
}
const idpToken = await res.json()
return idpToken
}
export function extractCurrentlyEntitledTiers(response: PatreonResponse): Relationship[] {
return response.included
.filter((item): item is Member => item.type === "member")
.flatMap(member => member.relationships.currently_entitled_tiers.data);
}
export async function getPatreonMemberships(token: KeycloakIdpToken): Promise<TiersList> {
// console.log(`getPatreonMemberships with keycloakidpToken as follows`)
// console.log(token)
const query = 'fields%5Buser%5D=about,created,email,first_name,full_name,image_url,last_name,thumb_url,url,vanity&include=memberships,memberships.currently_entitled_tiers,memberships.currently_entitled_tiers.benefits';
const res = await fetch(`${patreonApiIdentityUrl}?${query}`, {
headers: {
'Authorization': `Bearer ${token.access_token}`
}
})
const data = await res.json() as PatreonResponse
// from the currently_entitled_tiers, we only want the id.
// we filter out any non-futureporn patreon tiers.
return extractCurrentlyEntitledTiers(data)
.map((rel) => rel.id)
.filter((t) => Object.values(tiers).includes(t))
}
export function isEntitledToPatronVideoAccess(session: Session): boolean {
if (!session.user?.patreonBenefits) return false;
const patreonBenefits = session.user.patreonBenefits
return (patreonBenefits.includes(patreonVideoAccessBenefitId))
}
export async function getPatrons(): Promise<PublicPatron[]> {
let patrons: PublicPatron[] = []
try {
const url = `${postgrestLocalUrl}/patrons`
console.log(`GET requesting ${url}`)
const res = await fetch(url);
const data = await res.json();
if (!res.ok) throw new Error(`failed to get /patrons. res.status=${res.status}, res.statusText=${res.statusText}`);
if (!data) throw new Error(`no patron data was available. ${JSON.stringify(data)}`);
patrons = data
} catch (e) {
console.error('failed to get patrons~ list')
console.error(e)
return [] as PublicPatron[]
}
return patrons
}
export async function getCampaign(): Promise<ICampaign> {
const res = await fetch('https://www.patreon.com/api/campaigns/8012692', {
headers: {
accept: 'application/json'
},
next: {
revalidate: 43200 // 12 hour cache
}
})
const campaignData = await res.json();
const data = {
patronCount: campaignData.data.attributes.patron_count,
pledgeSum: campaignData.data.attributes.campaign_pledge_sum
}
return data
}