mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
feat: Add Cloudflare StreamPlayer component and update schema with billing and access control
- Introduced `CloudflareStreamPlayer` React component for embedding streams - Updated `package.json` with new dependencies: `@cloudflare/stream-react` and `stripe` - Extended database schema with user tiers, Stripe billing, storage, and archive management - Added access control logic in `access.ts` for user tiers and feature permissions - Enhanced billing logic with archive storage limits and subscription checks
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { getAuth } from "./auth"
|
||||
import { db } from "@/db/connection"
|
||||
import { users } from "@/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
// User tiers
|
||||
export type UserTier = "free" | "creator" | "dev"
|
||||
|
||||
// Features and which tiers can access them
|
||||
const FEATURE_ACCESS: Record<string, UserTier[]> = {
|
||||
// Archive features - creators and devs only
|
||||
archive_create: ["creator", "dev"],
|
||||
archive_view_own: ["creator", "dev"],
|
||||
archive_view_public: ["free", "creator", "dev"], // Anyone can view public archives
|
||||
|
||||
// Stream features - dev only for now
|
||||
stream_create: ["dev"],
|
||||
stream_view: ["free", "creator", "dev"],
|
||||
|
||||
// Canvas - everyone
|
||||
canvas_create: ["free", "creator", "dev"],
|
||||
|
||||
// Sell content - creators only
|
||||
sell_content: ["creator", "dev"],
|
||||
}
|
||||
|
||||
export type Feature = keyof typeof FEATURE_ACCESS
|
||||
|
||||
/**
|
||||
* Get user's tier from database
|
||||
*/
|
||||
export async function getUserTier(request: Request): Promise<UserTier | null> {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
try {
|
||||
const [user] = await database
|
||||
.select({ tier: users.tier })
|
||||
.from(users)
|
||||
.where(eq(users.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
return (user?.tier as UserTier) ?? "free"
|
||||
} catch (error) {
|
||||
console.error("[access] Error getting user tier:", error)
|
||||
return "free"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to a feature
|
||||
*/
|
||||
export async function hasFeatureAccess(
|
||||
request: Request,
|
||||
feature: Feature
|
||||
): Promise<boolean> {
|
||||
const tier = await getUserTier(request)
|
||||
|
||||
if (!tier) {
|
||||
// Not authenticated - only allow public features
|
||||
return FEATURE_ACCESS[feature]?.includes("free") ?? false
|
||||
}
|
||||
|
||||
const allowedTiers = FEATURE_ACCESS[feature]
|
||||
if (!allowedTiers) {
|
||||
// Unknown feature - deny by default
|
||||
return false
|
||||
}
|
||||
|
||||
return allowedTiers.includes(tier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all features user has access to
|
||||
*/
|
||||
export async function getUserFeatures(request: Request): Promise<Feature[]> {
|
||||
const tier = await getUserTier(request)
|
||||
|
||||
if (!tier) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(FEATURE_ACCESS)
|
||||
.filter(([_, tiers]) => tiers.includes(tier))
|
||||
.map(([feature]) => feature as Feature)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check access and return appropriate error response if denied
|
||||
*/
|
||||
export async function requireFeatureAccess(
|
||||
request: Request,
|
||||
feature: Feature
|
||||
): Promise<Response | null> {
|
||||
const hasAccess = await hasFeatureAccess(request, feature)
|
||||
|
||||
if (!hasAccess) {
|
||||
const tier = await getUserTier(request)
|
||||
|
||||
if (!tier) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Feature not available",
|
||||
feature,
|
||||
currentTier: tier,
|
||||
requiredTiers: FEATURE_ACCESS[feature],
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return null // Access granted
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade user tier (admin function)
|
||||
*/
|
||||
export async function setUserTier(
|
||||
userId: string,
|
||||
tier: UserTier
|
||||
): Promise<void> {
|
||||
const database = db()
|
||||
await database
|
||||
.update(users)
|
||||
.set({ tier, updatedAt: new Date() })
|
||||
.where(eq(users.id, userId))
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getFlowgladServer } from "./flowglad"
|
||||
import { getAuth } from "./auth"
|
||||
import { db } from "@/db/connection"
|
||||
import { stripe_subscriptions, storage_usage } from "@/db/schema"
|
||||
import { eq, and, gte, lte } from "drizzle-orm"
|
||||
|
||||
// Usage limits
|
||||
const GUEST_FREE_REQUESTS = 5
|
||||
@@ -234,3 +237,274 @@ export async function getBillingSummary(request: Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Archive Storage Billing (Stripe-based)
|
||||
// =============================================================================
|
||||
|
||||
const ARCHIVE_LIMITS = {
|
||||
free: { archives: 0, storageBytes: 0 },
|
||||
paid: { archives: 10, storageBytes: 1073741824 }, // 1GB
|
||||
}
|
||||
|
||||
type StorageCheckResult = {
|
||||
allowed: boolean
|
||||
archivesRemaining: number
|
||||
archivesLimit: number
|
||||
bytesRemaining: number
|
||||
bytesLimit: number
|
||||
reason?: "no_subscription" | "archive_limit" | "storage_limit"
|
||||
isPaid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can create a new archive (requires active Stripe subscription)
|
||||
*/
|
||||
export async function checkArchiveAllowed(
|
||||
request: Request,
|
||||
fileSizeBytes: number = 0
|
||||
): Promise<StorageCheckResult> {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining: 0,
|
||||
archivesLimit: 0,
|
||||
bytesRemaining: 0,
|
||||
bytesLimit: 0,
|
||||
reason: "no_subscription",
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
try {
|
||||
// Check for active Stripe subscription
|
||||
const [subscription] = await database
|
||||
.select()
|
||||
.from(stripe_subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(stripe_subscriptions.user_id, session.user.id),
|
||||
eq(stripe_subscriptions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining: 0,
|
||||
archivesLimit: 0,
|
||||
bytesRemaining: 0,
|
||||
bytesLimit: 0,
|
||||
reason: "no_subscription",
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Get usage for current billing period
|
||||
const now = new Date()
|
||||
const [usage] = await database
|
||||
.select()
|
||||
.from(storage_usage)
|
||||
.where(
|
||||
and(
|
||||
eq(storage_usage.user_id, session.user.id),
|
||||
lte(storage_usage.period_start, now),
|
||||
gte(storage_usage.period_end, now)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const archivesUsed = usage?.archives_used ?? 0
|
||||
const archivesLimit = usage?.archives_limit ?? ARCHIVE_LIMITS.paid.archives
|
||||
const bytesUsed = usage?.storage_bytes_used ?? 0
|
||||
const bytesLimit =
|
||||
usage?.storage_bytes_limit ?? ARCHIVE_LIMITS.paid.storageBytes
|
||||
|
||||
const archivesRemaining = Math.max(0, archivesLimit - archivesUsed)
|
||||
const bytesRemaining = Math.max(0, bytesLimit - bytesUsed)
|
||||
|
||||
if (archivesRemaining <= 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining: 0,
|
||||
archivesLimit,
|
||||
bytesRemaining,
|
||||
bytesLimit,
|
||||
reason: "archive_limit",
|
||||
isPaid: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (fileSizeBytes > 0 && fileSizeBytes > bytesRemaining) {
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining,
|
||||
archivesLimit,
|
||||
bytesRemaining,
|
||||
bytesLimit,
|
||||
reason: "storage_limit",
|
||||
isPaid: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
archivesRemaining,
|
||||
archivesLimit,
|
||||
bytesRemaining,
|
||||
bytesLimit,
|
||||
isPaid: true,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error checking archive allowed:", error)
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining: 0,
|
||||
archivesLimit: 0,
|
||||
bytesRemaining: 0,
|
||||
bytesLimit: 0,
|
||||
reason: "no_subscription",
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record storage usage after creating an archive
|
||||
*/
|
||||
export async function recordStorageUsage(
|
||||
request: Request,
|
||||
fileSizeBytes: number
|
||||
): Promise<void> {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
return
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
try {
|
||||
const now = new Date()
|
||||
const [usage] = await database
|
||||
.select()
|
||||
.from(storage_usage)
|
||||
.where(
|
||||
and(
|
||||
eq(storage_usage.user_id, session.user.id),
|
||||
lte(storage_usage.period_start, now),
|
||||
gte(storage_usage.period_end, now)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (usage) {
|
||||
await database
|
||||
.update(storage_usage)
|
||||
.set({
|
||||
archives_used: usage.archives_used + 1,
|
||||
storage_bytes_used: usage.storage_bytes_used + fileSizeBytes,
|
||||
updated_at: now,
|
||||
})
|
||||
.where(eq(storage_usage.id, usage.id))
|
||||
|
||||
console.log(
|
||||
`[billing] Recorded storage: +1 archive, +${fileSizeBytes} bytes`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error recording storage usage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get archive billing summary for UI display
|
||||
*/
|
||||
export async function getArchiveBillingSummary(request: Request) {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
planName: "Guest",
|
||||
}
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
try {
|
||||
const [subscription] = await database
|
||||
.select()
|
||||
.from(stripe_subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(stripe_subscriptions.user_id, session.user.id),
|
||||
eq(stripe_subscriptions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (subscription) {
|
||||
const now = new Date()
|
||||
const [usage] = await database
|
||||
.select()
|
||||
.from(storage_usage)
|
||||
.where(
|
||||
and(
|
||||
eq(storage_usage.user_id, session.user.id),
|
||||
lte(storage_usage.period_start, now),
|
||||
gte(storage_usage.period_end, now)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: true,
|
||||
planName: "Archive",
|
||||
storage: {
|
||||
archivesUsed: usage?.archives_used ?? 0,
|
||||
archivesLimit: usage?.archives_limit ?? ARCHIVE_LIMITS.paid.archives,
|
||||
bytesUsed: usage?.storage_bytes_used ?? 0,
|
||||
bytesLimit:
|
||||
usage?.storage_bytes_limit ?? ARCHIVE_LIMITS.paid.storageBytes,
|
||||
},
|
||||
currentPeriodEnd: subscription.current_period_end,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
planName: "Free",
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error getting archive summary:", error)
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
planName: "Free",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B"
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { StreamPlayback } from "@/lib/stream/playback"
|
||||
|
||||
export type StreamPageData = {
|
||||
user: {
|
||||
id: string
|
||||
@@ -12,6 +14,7 @@ export type StreamPageData = {
|
||||
is_live: boolean
|
||||
viewer_count: number
|
||||
hls_url: string | null
|
||||
playback: StreamPlayback | null
|
||||
thumbnail_url: string | null
|
||||
started_at: string | null
|
||||
} | null
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export type CloudflareStreamRef = {
|
||||
uid: string
|
||||
customerCode?: string
|
||||
}
|
||||
|
||||
export type StreamPlayback =
|
||||
| { type: "cloudflare"; uid: string; customerCode?: string }
|
||||
| { type: "hls"; url: string }
|
||||
|
||||
type PlaybackInput = {
|
||||
hlsUrl?: string | null
|
||||
cloudflareUid?: string | null
|
||||
cloudflareCustomerCode?: string | null
|
||||
}
|
||||
|
||||
export function resolveStreamPlayback({
|
||||
hlsUrl,
|
||||
cloudflareUid,
|
||||
cloudflareCustomerCode,
|
||||
}: PlaybackInput): StreamPlayback | null {
|
||||
if (cloudflareUid) {
|
||||
return {
|
||||
type: "cloudflare",
|
||||
uid: cloudflareUid,
|
||||
customerCode: cloudflareCustomerCode ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (!hlsUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cloudflare = parseCloudflareStreamUrl(hlsUrl)
|
||||
if (cloudflare) {
|
||||
return {
|
||||
type: "cloudflare",
|
||||
uid: cloudflare.uid,
|
||||
customerCode: cloudflare.customerCode,
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "hls", url: hlsUrl }
|
||||
}
|
||||
|
||||
export function parseCloudflareStreamUrl(url: string): CloudflareStreamRef | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase()
|
||||
const isCloudflareHost =
|
||||
host.endsWith(".cloudflarestream.com") ||
|
||||
host === "iframe.cloudflarestream.com" ||
|
||||
host.endsWith("videodelivery.net")
|
||||
|
||||
if (!isCloudflareHost) {
|
||||
return null
|
||||
}
|
||||
|
||||
const pathParts = parsed.pathname.split("/").filter(Boolean)
|
||||
if (!pathParts.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uid = pathParts[0]
|
||||
if (!uid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const customerMatch = host.match(/^customer-([a-z0-9-]+)\.cloudflarestream\.com$/i)
|
||||
const customerCode = customerMatch?.[1]
|
||||
|
||||
return { uid, customerCode }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Stripe from "stripe"
|
||||
|
||||
type StripeEnv = {
|
||||
STRIPE_SECRET_KEY?: string
|
||||
STRIPE_WEBHOOK_SECRET?: string
|
||||
STRIPE_ARCHIVE_PRICE_ID?: string // Archive subscription price
|
||||
}
|
||||
|
||||
const getEnv = (): StripeEnv => {
|
||||
let STRIPE_SECRET_KEY: string | undefined
|
||||
let STRIPE_WEBHOOK_SECRET: string | undefined
|
||||
let STRIPE_ARCHIVE_PRICE_ID: string | undefined
|
||||
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: StripeEnv } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
STRIPE_SECRET_KEY = ctx?.cloudflare?.env?.STRIPE_SECRET_KEY
|
||||
STRIPE_WEBHOOK_SECRET = ctx?.cloudflare?.env?.STRIPE_WEBHOOK_SECRET
|
||||
STRIPE_ARCHIVE_PRICE_ID = ctx?.cloudflare?.env?.STRIPE_ARCHIVE_PRICE_ID
|
||||
} catch {
|
||||
// Not in server context
|
||||
}
|
||||
|
||||
STRIPE_SECRET_KEY = STRIPE_SECRET_KEY ?? process.env.STRIPE_SECRET_KEY
|
||||
STRIPE_WEBHOOK_SECRET =
|
||||
STRIPE_WEBHOOK_SECRET ?? process.env.STRIPE_WEBHOOK_SECRET
|
||||
STRIPE_ARCHIVE_PRICE_ID =
|
||||
STRIPE_ARCHIVE_PRICE_ID ?? process.env.STRIPE_ARCHIVE_PRICE_ID
|
||||
|
||||
return { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_ARCHIVE_PRICE_ID }
|
||||
}
|
||||
|
||||
export const getStripe = (): Stripe | null => {
|
||||
const env = getEnv()
|
||||
|
||||
if (!env.STRIPE_SECRET_KEY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Stripe(env.STRIPE_SECRET_KEY)
|
||||
}
|
||||
|
||||
export const getStripeConfig = () => {
|
||||
const env = getEnv()
|
||||
return {
|
||||
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
|
||||
archivePriceId: env.STRIPE_ARCHIVE_PRICE_ID,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user