Initial commit
This commit is contained in:
55
backend/env.example
Normal file
55
backend/env.example
Normal file
@@ -0,0 +1,55 @@
|
||||
# ===========================================
|
||||
# LNPaywall Backend Environment Configuration
|
||||
# ===========================================
|
||||
|
||||
# Server Configuration
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
API_URL=http://localhost:3001
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Database Configuration (SQLite - easy setup)
|
||||
DATABASE_URL="file:./dev.db"
|
||||
|
||||
# Alternative: PostgreSQL (for production)
|
||||
# DATABASE_URL="postgresql://user:password@localhost:5432/lnpaywall?schema=public"
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||
JWT_REFRESH_SECRET="your-super-secret-refresh-key-change-in-production"
|
||||
JWT_ACCESS_EXPIRES_IN="15m"
|
||||
JWT_REFRESH_EXPIRES_IN="7d"
|
||||
|
||||
# OAuth Configuration (Optional)
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Payment Provider - LNbits Configuration
|
||||
LNBITS_URL="https://legend.lnbits.com"
|
||||
LNBITS_ADMIN_KEY="your-lnbits-admin-key"
|
||||
LNBITS_INVOICE_KEY="your-lnbits-invoice-key"
|
||||
|
||||
# Payment Provider - BTCPay Server Configuration (Alternative)
|
||||
BTCPAY_URL=""
|
||||
BTCPAY_STORE_ID=""
|
||||
BTCPAY_API_KEY=""
|
||||
|
||||
# Platform Fee Configuration
|
||||
PLATFORM_FEE_PERCENT=10
|
||||
PLATFORM_FEE_PERCENT_PRO=0
|
||||
PRO_PRICE_SATS=50000
|
||||
PLATFORM_LIGHTNING_ADDRESS=""
|
||||
|
||||
# Security
|
||||
COOKIE_SECRET="your-cookie-secret-change-in-production"
|
||||
ALLOWED_ORIGINS="http://localhost:5173,http://localhost:3000"
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
2152
backend/package-lock.json
generated
Normal file
2152
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
backend/package.json
Normal file
39
backend/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "lnpaywall-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "LNPaywall Backend - Turn any link into paid access",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "node prisma/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.2.0",
|
||||
"@noble/hashes": "^1.3.2",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nanoid": "^5.0.4",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"prisma": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
197
backend/prisma/schema.prisma
Normal file
197
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,197 @@
|
||||
// Prisma schema for LNPaywall
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
status String @default("ACTIVE") // ACTIVE, DISABLED
|
||||
role String @default("CREATOR") // CREATOR, ADMIN
|
||||
displayName String
|
||||
email String? @unique
|
||||
emailVerified Boolean @default(false)
|
||||
passwordHash String?
|
||||
nostrPubkey String? @unique
|
||||
avatarUrl String?
|
||||
defaultCurrency String @default("sats")
|
||||
|
||||
// OAuth providers
|
||||
googleId String? @unique
|
||||
githubId String? @unique
|
||||
|
||||
// Payout configuration
|
||||
lightningAddress String?
|
||||
lnurlp String?
|
||||
|
||||
// Subscription
|
||||
subscriptionTier String @default("FREE") // FREE, PRO
|
||||
subscriptionExpiry DateTime?
|
||||
|
||||
// Relations
|
||||
paywalls Paywall[]
|
||||
sales Sale[]
|
||||
sessions Session[]
|
||||
auditLogs AuditLog[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
refreshToken String @unique
|
||||
userAgent String?
|
||||
ipAddress String?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Paywall {
|
||||
id String @id @default(uuid())
|
||||
creatorId String
|
||||
status String @default("ACTIVE") // ACTIVE, ARCHIVED, DISABLED
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Content
|
||||
title String
|
||||
description String?
|
||||
coverImageUrl String?
|
||||
originalUrl String
|
||||
originalUrlType String @default("URL") // URL, YOUTUBE, NOTION, PDF, LOOM, GDOCS, GITHUB, OTHER
|
||||
|
||||
// Preview
|
||||
previewMode String @default("NONE") // NONE, TEXT_PREVIEW, IMAGE_PREVIEW
|
||||
previewContent String?
|
||||
|
||||
// Pricing
|
||||
priceSats Int
|
||||
|
||||
// Access rules
|
||||
accessExpirySeconds Int?
|
||||
maxDevices Int @default(3)
|
||||
maxSessions Int @default(5)
|
||||
|
||||
// Embed settings
|
||||
allowEmbed Boolean @default(true)
|
||||
allowedEmbedOrigins String @default("[]") // JSON array as string for SQLite
|
||||
|
||||
// Customization
|
||||
requireEmailReceipt Boolean @default(false)
|
||||
customSuccessMessage String?
|
||||
customBranding String? // JSON as string
|
||||
|
||||
// URL
|
||||
slug String? @unique
|
||||
|
||||
// Relations
|
||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
checkoutSessions CheckoutSession[]
|
||||
accessGrants AccessGrant[]
|
||||
sales Sale[]
|
||||
}
|
||||
|
||||
model CheckoutSession {
|
||||
id String @id @default(uuid())
|
||||
paywallId String
|
||||
createdAt DateTime @default(now())
|
||||
status String @default("PENDING") // PENDING, PAID, EXPIRED, CANCELED
|
||||
amountSats Int
|
||||
paymentProvider String @default("lnbits")
|
||||
paymentRequest String? // Lightning invoice
|
||||
paymentHash String? @unique
|
||||
expiresAt DateTime
|
||||
buyerHint String? // IP hash + user agent hash
|
||||
buyerEmail String?
|
||||
|
||||
// Relations
|
||||
paywall Paywall @relation(fields: [paywallId], references: [id], onDelete: Cascade)
|
||||
accessGrant AccessGrant?
|
||||
sale Sale?
|
||||
}
|
||||
|
||||
model Buyer {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
email String?
|
||||
emailVerified Boolean @default(false)
|
||||
nostrPubkey String?
|
||||
notes String?
|
||||
|
||||
// Relations
|
||||
accessGrants AccessGrant[]
|
||||
sales Sale[]
|
||||
}
|
||||
|
||||
model AccessGrant {
|
||||
id String @id @default(uuid())
|
||||
paywallId String
|
||||
checkoutSessionId String? @unique
|
||||
buyerId String?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
status String @default("ACTIVE") // ACTIVE, REVOKED
|
||||
tokenId String @unique @default(uuid())
|
||||
lastUsedAt DateTime?
|
||||
usageCount Int @default(0)
|
||||
deviceFingerprints String @default("[]") // JSON array as string
|
||||
|
||||
// Relations
|
||||
paywall Paywall @relation(fields: [paywallId], references: [id], onDelete: Cascade)
|
||||
checkoutSession CheckoutSession? @relation(fields: [checkoutSessionId], references: [id])
|
||||
buyer Buyer? @relation(fields: [buyerId], references: [id])
|
||||
}
|
||||
|
||||
model Sale {
|
||||
id String @id @default(uuid())
|
||||
paywallId String
|
||||
creatorId String
|
||||
checkoutSessionId String? @unique
|
||||
buyerId String?
|
||||
createdAt DateTime @default(now())
|
||||
amountSats Int
|
||||
platformFeeSats Int
|
||||
netSats Int
|
||||
paymentProvider String
|
||||
providerReference String?
|
||||
status String @default("CONFIRMED") // CONFIRMED, REFUNDED, CHARGEBACK, DISPUTED
|
||||
|
||||
// Relations
|
||||
paywall Paywall @relation(fields: [paywallId], references: [id])
|
||||
creator User @relation(fields: [creatorId], references: [id])
|
||||
checkoutSession CheckoutSession? @relation(fields: [checkoutSessionId], references: [id])
|
||||
buyer Buyer? @relation(fields: [buyerId], references: [id])
|
||||
}
|
||||
|
||||
model WebhookEvent {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
provider String
|
||||
eventType String
|
||||
rawPayload String // JSON as string
|
||||
processedAt DateTime?
|
||||
status String @default("pending")
|
||||
error String?
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
actorId String?
|
||||
actorType String // creator, admin, system
|
||||
action String
|
||||
resourceType String
|
||||
resourceId String?
|
||||
ipAddress String?
|
||||
metadata String? // JSON as string
|
||||
|
||||
actor User? @relation(fields: [actorId], references: [id])
|
||||
}
|
||||
97
backend/prisma/seed.js
Normal file
97
backend/prisma/seed.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Seeding database...');
|
||||
|
||||
// Create admin user
|
||||
const adminPassword = await bcrypt.hash('admin123', 12);
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@lnpaywall.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@lnpaywall.com',
|
||||
passwordHash: adminPassword,
|
||||
displayName: 'Admin',
|
||||
role: 'ADMIN',
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
console.log('✅ Created admin user:', admin.email);
|
||||
|
||||
// Create demo creator
|
||||
const creatorPassword = await bcrypt.hash('demo123', 12);
|
||||
const creator = await prisma.user.upsert({
|
||||
where: { email: 'creator@demo.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'creator@demo.com',
|
||||
passwordHash: creatorPassword,
|
||||
displayName: 'Demo Creator',
|
||||
role: 'CREATOR',
|
||||
emailVerified: true,
|
||||
lightningAddress: 'demo@getalby.com',
|
||||
},
|
||||
});
|
||||
console.log('✅ Created demo creator:', creator.email);
|
||||
|
||||
// Create sample paywalls
|
||||
const paywalls = [
|
||||
{
|
||||
title: 'Complete Bitcoin Development Course',
|
||||
description: 'Learn to build on Bitcoin and Lightning Network from scratch. 10+ hours of video content.',
|
||||
originalUrl: 'https://example.com/bitcoin-course',
|
||||
originalUrlType: 'URL',
|
||||
priceSats: 5000,
|
||||
slug: 'bitcoin-course',
|
||||
coverImageUrl: 'https://images.unsplash.com/photo-1621761191319-c6fb62004040?w=800',
|
||||
},
|
||||
{
|
||||
title: 'Exclusive Trading Strategy PDF',
|
||||
description: 'My personal trading strategy that generates consistent returns. Includes spreadsheet templates.',
|
||||
originalUrl: 'https://example.com/trading-guide.pdf',
|
||||
originalUrlType: 'PDF',
|
||||
priceSats: 2100,
|
||||
slug: 'trading-strategy',
|
||||
coverImageUrl: 'https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=800',
|
||||
},
|
||||
{
|
||||
title: 'Private Notion Template Library',
|
||||
description: 'Access my complete collection of 50+ Notion templates for productivity and business.',
|
||||
originalUrl: 'https://notion.so/template-library',
|
||||
originalUrlType: 'NOTION',
|
||||
priceSats: 1000,
|
||||
slug: 'notion-templates',
|
||||
coverImageUrl: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?w=800',
|
||||
},
|
||||
];
|
||||
|
||||
for (const paywallData of paywalls) {
|
||||
const paywall = await prisma.paywall.upsert({
|
||||
where: { slug: paywallData.slug },
|
||||
update: {},
|
||||
create: {
|
||||
...paywallData,
|
||||
creatorId: creator.id,
|
||||
},
|
||||
});
|
||||
console.log('✅ Created paywall:', paywall.title);
|
||||
}
|
||||
|
||||
console.log('\n🎉 Seeding complete!');
|
||||
console.log('\n📝 Test accounts:');
|
||||
console.log(' Admin: admin@lnpaywall.com / admin123');
|
||||
console.log(' Creator: creator@demo.com / demo123');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seed error:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
14
backend/src/config/database.js
Normal file
14
backend/src/config/database.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis;
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
export default prisma;
|
||||
|
||||
153
backend/src/index.js
Normal file
153
backend/src/index.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { prisma } from './config/database.js';
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { requestLogger } from './middleware/requestLogger.js';
|
||||
|
||||
// Routes
|
||||
import authRoutes from './routes/auth.js';
|
||||
import paywallRoutes from './routes/paywalls.js';
|
||||
import checkoutRoutes from './routes/checkout.js';
|
||||
import accessRoutes from './routes/access.js';
|
||||
import publicRoutes from './routes/public.js';
|
||||
import webhookRoutes from './routes/webhooks.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import embedRoutes from './routes/embed.js';
|
||||
import configRoutes from './routes/config.js';
|
||||
import subscriptionRoutes from './routes/subscription.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Trust proxy for rate limiting behind reverse proxy
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'", process.env.LNBITS_URL || "https://legend.lnbits.com"],
|
||||
frameSrc: ["'self'"],
|
||||
frameAncestors: ["*"], // Allow embedding
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
|
||||
// CORS configuration
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:5173'];
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
if (allowedOrigins.includes(origin) || origin.endsWith('.lnpaywall.com')) {
|
||||
return callback(null, true);
|
||||
}
|
||||
callback(null, true); // Allow all for embed support
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
}));
|
||||
|
||||
// Rate limiting - Different limits for different endpoints
|
||||
const strictLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 30, // 30 requests per minute for auth endpoints
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.ip,
|
||||
});
|
||||
|
||||
const generalLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 500, // Increased to 500
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.ip,
|
||||
});
|
||||
|
||||
// Apply stricter limits only to auth endpoints
|
||||
app.use('/api/auth/', strictLimiter);
|
||||
app.use('/api/', generalLimiter);
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser(process.env.COOKIE_SECRET));
|
||||
|
||||
// Request logging
|
||||
app.use(requestLogger);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/paywalls', paywallRoutes);
|
||||
app.use('/api/checkout', checkoutRoutes);
|
||||
app.use('/api/access', accessRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
app.use('/api/config', configRoutes);
|
||||
app.use('/api/subscription', subscriptionRoutes);
|
||||
|
||||
// Public routes (no /api prefix)
|
||||
app.use('/p', publicRoutes);
|
||||
app.use('/embed', embedRoutes);
|
||||
|
||||
// Serve embed script
|
||||
app.get('/js/paywall.js', (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/javascript');
|
||||
res.sendFile('paywall-embed.js', { root: './src/public' });
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async() => {
|
||||
console.log('Shutting down gracefully...');
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ⚡ LNPaywall Backend Server ║
|
||||
║ ║
|
||||
║ Server running on http://localhost:${PORT} ║
|
||||
║ Environment: ${process.env.NODE_ENV || 'development'} ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
93
backend/src/middleware/auth.js
Normal file
93
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from './errorHandler.js';
|
||||
|
||||
export const authenticate = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith('Bearer ')
|
||||
? authHeader.slice(7)
|
||||
: req.cookies?.access_token;
|
||||
|
||||
if (!token) {
|
||||
throw new AppError('Authentication required', 401);
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
status: true,
|
||||
avatarUrl: true,
|
||||
lightningAddress: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError('User not found', 401);
|
||||
}
|
||||
|
||||
if (user.status === 'DISABLED') {
|
||||
throw new AppError('Account disabled', 403);
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith('Bearer ')
|
||||
? authHeader.slice(7)
|
||||
: req.cookies?.access_token;
|
||||
|
||||
if (token) {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user && user.status === 'ACTIVE') {
|
||||
req.user = user;
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
// Silently continue without auth
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
export const requireAdmin = (req, res, next) => {
|
||||
if (!req.user || req.user.role !== 'ADMIN') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
export const requireCreator = (req, res, next) => {
|
||||
if (!req.user || !['CREATOR', 'ADMIN'].includes(req.user.role)) {
|
||||
return res.status(403).json({ error: 'Creator access required' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
58
backend/src/middleware/errorHandler.js
Normal file
58
backend/src/middleware/errorHandler.js
Normal file
@@ -0,0 +1,58 @@
|
||||
export const errorHandler = (err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
// Prisma errors
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json({
|
||||
error: 'A record with this value already exists',
|
||||
field: err.meta?.target?.[0],
|
||||
});
|
||||
}
|
||||
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({
|
||||
error: 'Record not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Validation errors
|
||||
if (err.name === 'ZodError') {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: err.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
});
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: 'Token expired',
|
||||
});
|
||||
}
|
||||
|
||||
// Default error
|
||||
const statusCode = err.statusCode || 500;
|
||||
const message = process.env.NODE_ENV === 'production'
|
||||
? 'An error occurred'
|
||||
: err.message;
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: message,
|
||||
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
|
||||
});
|
||||
};
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(message, statusCode = 500) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
26
backend/src/middleware/requestLogger.js
Normal file
26
backend/src/middleware/requestLogger.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const requestLogger = (req, res, next) => {
|
||||
req.requestId = randomUUID();
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const log = {
|
||||
requestId: req.requestId,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
userAgent: req.get('user-agent'),
|
||||
ip: req.ip,
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[${log.method}] ${log.path} - ${log.statusCode} (${log.duration})`);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
434
backend/src/public/paywall-embed.js
Normal file
434
backend/src/public/paywall-embed.js
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* LNPaywall Embed Script
|
||||
* Usage: <script src="https://app.lnpaywall.com/js/paywall.js" data-paywall="PAYWALL_ID" data-theme="auto"></script>
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const API_URL = document.currentScript?.src?.replace('/js/paywall.js', '') || 'http://localhost:3001';
|
||||
const paywallId = document.currentScript?.getAttribute('data-paywall');
|
||||
const theme = document.currentScript?.getAttribute('data-theme') || 'dark';
|
||||
const buttonText = document.currentScript?.getAttribute('data-button-text') || 'Unlock Content';
|
||||
|
||||
if (!paywallId) {
|
||||
console.error('LNPaywall: Missing data-paywall attribute');
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject styles
|
||||
const styles = document.createElement('style');
|
||||
styles.textContent = `
|
||||
.lnpaywall-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #f7931a 0%, #ff6b00 100%);
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(247, 147, 26, 0.3);
|
||||
}
|
||||
.lnpaywall-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(247, 147, 26, 0.4);
|
||||
}
|
||||
.lnpaywall-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.lnpaywall-button-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
.lnpaywall-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999999;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.lnpaywall-modal-overlay.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.lnpaywall-modal {
|
||||
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%);
|
||||
border-radius: 20px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
transform: scale(0.9);
|
||||
transition: transform 0.3s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.lnpaywall-modal-overlay.open .lnpaywall-modal {
|
||||
transform: scale(1);
|
||||
}
|
||||
.lnpaywall-modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.lnpaywall-modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.lnpaywall-cover {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
.lnpaywall-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
.lnpaywall-content {
|
||||
padding: 24px;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
position: relative;
|
||||
}
|
||||
.lnpaywall-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.lnpaywall-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.lnpaywall-price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #f7931a;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.lnpaywall-price-label {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 400;
|
||||
}
|
||||
.lnpaywall-pay-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f7931a 0%, #ff6b00 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.lnpaywall-pay-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(247, 147, 26, 0.3);
|
||||
}
|
||||
.lnpaywall-checkout {
|
||||
text-align: center;
|
||||
}
|
||||
.lnpaywall-qr {
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.lnpaywall-qr img {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
.lnpaywall-invoice {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
word-break: break-all;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.lnpaywall-copy-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.lnpaywall-copy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.lnpaywall-timer {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.lnpaywall-loader {
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #f7931a;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
animation: lnpaywall-spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
@keyframes lnpaywall-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.lnpaywall-success {
|
||||
text-align: center;
|
||||
}
|
||||
.lnpaywall-success-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.lnpaywall-success-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #00c853 0%, #00e676 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.lnpaywall-hidden { display: none !important; }
|
||||
`;
|
||||
document.head.appendChild(styles);
|
||||
|
||||
// State
|
||||
let paywall = null;
|
||||
let checkoutSession = null;
|
||||
let pollInterval = null;
|
||||
let timerInterval = null;
|
||||
|
||||
// Create button
|
||||
const button = document.createElement('button');
|
||||
button.className = 'lnpaywall-button';
|
||||
button.innerHTML = `<span class="lnpaywall-button-icon">⚡</span> ${buttonText}`;
|
||||
button.onclick = openModal;
|
||||
|
||||
// Create modal
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'lnpaywall-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="lnpaywall-modal">
|
||||
<div class="lnpaywall-cover-placeholder" id="lnp-cover"></div>
|
||||
<div class="lnpaywall-content">
|
||||
<button class="lnpaywall-modal-close" onclick="window.LNPaywall.close()">×</button>
|
||||
<h3 class="lnpaywall-title" id="lnp-title">Loading...</h3>
|
||||
<p class="lnpaywall-description" id="lnp-description"></p>
|
||||
|
||||
<div id="lnp-locked">
|
||||
<div class="lnpaywall-price">
|
||||
<span>⚡ <span id="lnp-price">0</span></span>
|
||||
<span class="lnpaywall-price-label">sats</span>
|
||||
</div>
|
||||
<button class="lnpaywall-pay-btn" onclick="window.LNPaywall.startCheckout()">
|
||||
Pay with Lightning
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="lnp-checkout" class="lnpaywall-checkout lnpaywall-hidden">
|
||||
<div class="lnpaywall-loader" id="lnp-loader"></div>
|
||||
<div class="lnpaywall-timer" id="lnp-timer">Loading...</div>
|
||||
<div id="lnp-qr-container" class="lnpaywall-hidden">
|
||||
<div class="lnpaywall-qr">
|
||||
<img id="lnp-qr" src="" alt="QR Code">
|
||||
</div>
|
||||
<div class="lnpaywall-invoice" id="lnp-invoice"></div>
|
||||
<button class="lnpaywall-copy-btn" onclick="window.LNPaywall.copyInvoice()">
|
||||
📋 Copy Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lnp-success" class="lnpaywall-success lnpaywall-hidden">
|
||||
<div class="lnpaywall-success-icon">✅</div>
|
||||
<p style="margin-bottom: 16px; color: rgba(255,255,255,0.7);">Payment successful!</p>
|
||||
<button class="lnpaywall-success-btn" onclick="window.LNPaywall.openContent()">
|
||||
Open Content →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
overlay.onclick = function(e) {
|
||||
if (e.target === overlay) close();
|
||||
};
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Insert button after script tag
|
||||
document.currentScript.parentNode.insertBefore(button, document.currentScript.nextSibling);
|
||||
|
||||
// Functions
|
||||
async function openModal() {
|
||||
overlay.classList.add('open');
|
||||
|
||||
if (!paywall) {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/p/${paywallId}/embed-data`);
|
||||
if (!response.ok) throw new Error('Failed to load paywall');
|
||||
paywall = await response.json();
|
||||
|
||||
document.getElementById('lnp-title').textContent = paywall.title;
|
||||
document.getElementById('lnp-description').textContent = paywall.description || '';
|
||||
document.getElementById('lnp-price').textContent = paywall.priceSats.toLocaleString();
|
||||
|
||||
if (paywall.coverImageUrl) {
|
||||
const coverEl = document.getElementById('lnp-cover');
|
||||
coverEl.outerHTML = `<img src="${paywall.coverImageUrl}" class="lnpaywall-cover" id="lnp-cover">`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('LNPaywall error:', error);
|
||||
document.getElementById('lnp-title').textContent = 'Error loading content';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
overlay.classList.remove('open');
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
}
|
||||
|
||||
async function startCheckout() {
|
||||
document.getElementById('lnp-locked').classList.add('lnpaywall-hidden');
|
||||
document.getElementById('lnp-checkout').classList.remove('lnpaywall-hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/checkout/${paywallId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create checkout');
|
||||
|
||||
checkoutSession = await response.json();
|
||||
|
||||
// Generate QR
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(checkoutSession.paymentRequest)}`;
|
||||
document.getElementById('lnp-qr').src = qrUrl;
|
||||
document.getElementById('lnp-invoice').textContent = checkoutSession.paymentRequest;
|
||||
document.getElementById('lnp-loader').classList.add('lnpaywall-hidden');
|
||||
document.getElementById('lnp-qr-container').classList.remove('lnpaywall-hidden');
|
||||
|
||||
startPolling();
|
||||
startTimer(new Date(checkoutSession.expiresAt));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error);
|
||||
alert('Failed to create checkout. Please try again.');
|
||||
document.getElementById('lnp-checkout').classList.add('lnpaywall-hidden');
|
||||
document.getElementById('lnp-locked').classList.remove('lnpaywall-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/api/checkout/${checkoutSession.sessionId}/status`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'PAID') {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(timerInterval);
|
||||
paywall.originalUrl = result.originalUrl;
|
||||
showSuccess();
|
||||
} else if (result.status === 'EXPIRED') {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(timerInterval);
|
||||
alert('Payment expired. Please try again.');
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Poll error:', error);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function startTimer(expiresAt) {
|
||||
const timerEl = document.getElementById('lnp-timer');
|
||||
const update = () => {
|
||||
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const secs = remaining % 60;
|
||||
timerEl.textContent = `Expires in ${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
update();
|
||||
timerInterval = setInterval(update, 1000);
|
||||
}
|
||||
|
||||
function showSuccess() {
|
||||
document.getElementById('lnp-checkout').classList.add('lnpaywall-hidden');
|
||||
document.getElementById('lnp-success').classList.remove('lnpaywall-hidden');
|
||||
}
|
||||
|
||||
function openContent() {
|
||||
if (paywall?.originalUrl) {
|
||||
window.open(paywall.originalUrl, '_blank');
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
function copyInvoice() {
|
||||
if (checkoutSession) {
|
||||
navigator.clipboard.writeText(checkoutSession.paymentRequest);
|
||||
const btn = event.target;
|
||||
btn.textContent = '✓ Copied!';
|
||||
setTimeout(() => { btn.textContent = '📋 Copy Invoice'; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Expose API
|
||||
window.LNPaywall = {
|
||||
open: openModal,
|
||||
close: close,
|
||||
startCheckout: startCheckout,
|
||||
openContent: openContent,
|
||||
copyInvoice: copyInvoice,
|
||||
};
|
||||
})();
|
||||
|
||||
88
backend/src/routes/access.js
Normal file
88
backend/src/routes/access.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Router } from 'express';
|
||||
import { accessService } from '../services/access.js';
|
||||
import { authenticate, requireCreator } from '../middleware/auth.js';
|
||||
import { validateBody, verifyAccessSchema } from '../utils/validation.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Verify access token (public endpoint for embeds)
|
||||
router.post('/verify', validateBody(verifyAccessSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { token, paywallId, deviceFingerprint } = req.body;
|
||||
|
||||
const result = await accessService.verifyAccess(token, paywallId, deviceFingerprint);
|
||||
|
||||
res.json({
|
||||
valid: result.valid,
|
||||
originalUrl: result.paywall.originalUrl,
|
||||
customSuccessMessage: result.paywall.customSuccessMessage,
|
||||
expiresAt: result.accessGrant.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
// Return structured error for access verification
|
||||
res.status(error.statusCode || 401).json({
|
||||
valid: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check access by cookie/tokenId (for re-access)
|
||||
router.get('/check/:paywallId', async (req, res, next) => {
|
||||
try {
|
||||
const { paywallId } = req.params;
|
||||
|
||||
// Try to get token from cookie
|
||||
const accessToken = req.cookies?.[`access_token_${paywallId}`];
|
||||
const tokenId = req.cookies?.[`token_id_${paywallId}`];
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
const result = await accessService.verifyAccess(accessToken, paywallId);
|
||||
return res.json({
|
||||
hasAccess: true,
|
||||
originalUrl: result.paywall.originalUrl,
|
||||
expiresAt: result.accessGrant.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
// Token invalid, continue to check tokenId
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenId) {
|
||||
const result = await accessService.checkAccessByCookie(tokenId, paywallId);
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
res.json({ hasAccess: false });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Revoke access (creator only)
|
||||
router.post('/revoke/:accessGrantId', authenticate, requireCreator, async (req, res, next) => {
|
||||
try {
|
||||
const result = await accessService.revokeAccess(req.params.accessGrantId, req.user.id);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// List access grants for a paywall (creator only)
|
||||
router.get('/paywall/:paywallId', authenticate, requireCreator, async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const result = await accessService.getAccessByPaywall(req.params.paywallId, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
334
backend/src/routes/admin.js
Normal file
334
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, requireAdmin } from '../middleware/auth.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All admin routes require authentication and admin role
|
||||
router.use(authenticate);
|
||||
router.use(requireAdmin);
|
||||
|
||||
// List all creators
|
||||
router.get('/creators', async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, status } = req.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const where = { role: 'CREATOR' };
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [creators, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: parseInt(limit),
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
paywalls: true,
|
||||
sales: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
creators,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
totalPages: Math.ceil(total / parseInt(limit)),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get creator details
|
||||
router.get('/creators/:id', async (req, res, next) => {
|
||||
try {
|
||||
const creator = await prisma.user.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
paywalls: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
paywalls: true,
|
||||
sales: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!creator) {
|
||||
return res.status(404).json({ error: 'Creator not found' });
|
||||
}
|
||||
|
||||
// Get revenue stats
|
||||
const revenue = await prisma.sale.aggregate({
|
||||
where: { creatorId: creator.id },
|
||||
_sum: { netSats: true, platformFeeSats: true },
|
||||
});
|
||||
|
||||
res.json({
|
||||
creator,
|
||||
stats: {
|
||||
totalRevenue: revenue._sum.netSats || 0,
|
||||
platformFees: revenue._sum.platformFeeSats || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable/enable creator
|
||||
router.post('/creators/:id/status', async (req, res, next) => {
|
||||
try {
|
||||
const { status } = req.body;
|
||||
|
||||
if (!['ACTIVE', 'DISABLED'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status' });
|
||||
}
|
||||
|
||||
const creator = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: { status },
|
||||
});
|
||||
|
||||
// Log action
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
actorId: req.user.id,
|
||||
actorType: 'admin',
|
||||
action: status === 'DISABLED' ? 'disable_creator' : 'enable_creator',
|
||||
resourceType: 'user',
|
||||
resourceId: creator.id,
|
||||
ipAddress: req.ip,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ creator });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// List all paywalls
|
||||
router.get('/paywalls', async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, status, creatorId } = req.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const where = {};
|
||||
if (status) where.status = status;
|
||||
if (creatorId) where.creatorId = creatorId;
|
||||
|
||||
const [paywalls, total] = await Promise.all([
|
||||
prisma.paywall.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: parseInt(limit),
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { sales: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.paywall.count({ where }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
paywalls,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
totalPages: Math.ceil(total / parseInt(limit)),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable paywall
|
||||
router.post('/paywalls/:id/disable', async (req, res, next) => {
|
||||
try {
|
||||
const paywall = await prisma.paywall.update({
|
||||
where: { id: req.params.id },
|
||||
data: { status: 'DISABLED' },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
actorId: req.user.id,
|
||||
actorType: 'admin',
|
||||
action: 'disable_paywall',
|
||||
resourceType: 'paywall',
|
||||
resourceId: paywall.id,
|
||||
ipAddress: req.ip,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ paywall });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// List all sales
|
||||
router.get('/sales', async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, creatorId, paywallId, startDate, endDate } = req.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const where = {};
|
||||
if (creatorId) where.creatorId = creatorId;
|
||||
if (paywallId) where.paywallId = paywallId;
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) where.createdAt.gte = new Date(startDate);
|
||||
if (endDate) where.createdAt.lte = new Date(endDate);
|
||||
}
|
||||
|
||||
const [sales, total, aggregates] = await Promise.all([
|
||||
prisma.sale.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: parseInt(limit),
|
||||
include: {
|
||||
paywall: {
|
||||
select: { title: true, slug: true },
|
||||
},
|
||||
creator: {
|
||||
select: { displayName: true, email: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.sale.count({ where }),
|
||||
prisma.sale.aggregate({
|
||||
where,
|
||||
_sum: {
|
||||
amountSats: true,
|
||||
platformFeeSats: true,
|
||||
netSats: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
sales,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
totalPages: Math.ceil(total / parseInt(limit)),
|
||||
totals: {
|
||||
amount: aggregates._sum.amountSats || 0,
|
||||
platformFees: aggregates._sum.platformFeeSats || 0,
|
||||
net: aggregates._sum.netSats || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Platform stats
|
||||
router.get('/stats', async (req, res, next) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [
|
||||
totalCreators,
|
||||
totalPaywalls,
|
||||
totalSales,
|
||||
recentSales,
|
||||
platformFees,
|
||||
] = await Promise.all([
|
||||
prisma.user.count({ where: { role: 'CREATOR' } }),
|
||||
prisma.paywall.count(),
|
||||
prisma.sale.count(),
|
||||
prisma.sale.aggregate({
|
||||
where: { createdAt: { gte: thirtyDaysAgo } },
|
||||
_sum: { amountSats: true, platformFeeSats: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.sale.aggregate({
|
||||
_sum: { platformFeeSats: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
totalCreators,
|
||||
totalPaywalls,
|
||||
totalSales,
|
||||
last30Days: {
|
||||
sales: recentSales._count,
|
||||
volume: recentSales._sum.amountSats || 0,
|
||||
fees: recentSales._sum.platformFeeSats || 0,
|
||||
},
|
||||
totalPlatformFees: platformFees._sum.platformFeeSats || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Audit logs
|
||||
router.get('/audit-logs', async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 50, action, resourceType } = req.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const where = {};
|
||||
if (action) where.action = action;
|
||||
if (resourceType) where.resourceType = resourceType;
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: parseInt(limit),
|
||||
include: {
|
||||
actor: {
|
||||
select: { displayName: true, email: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
logs,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
totalPages: Math.ceil(total / parseInt(limit)),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
195
backend/src/routes/auth.js
Normal file
195
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Router } from 'express';
|
||||
import { authService } from '../services/auth.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { validateBody, signupSchema, loginSchema, nostrLoginSchema } from '../utils/validation.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Signup
|
||||
router.post('/signup', validateBody(signupSchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.signup(req.body);
|
||||
|
||||
// Set refresh token as httpOnly cookie
|
||||
res.cookie('refresh_token', result.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
expiresIn: result.expiresIn,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', validateBody(loginSchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.login(req.body);
|
||||
|
||||
res.cookie('refresh_token', result.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
expiresIn: result.expiresIn,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh token
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const refreshToken = req.cookies?.refresh_token || req.body.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ error: 'Refresh token required' });
|
||||
}
|
||||
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
|
||||
res.json({
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
expiresIn: result.expiresIn,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', async (req, res, next) => {
|
||||
try {
|
||||
const refreshToken = req.cookies?.refresh_token;
|
||||
await authService.logout(refreshToken);
|
||||
|
||||
res.clearCookie('refresh_token');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
router.get('/me', authenticate, async (req, res) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
// Update profile
|
||||
router.patch('/me', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { displayName, lightningAddress, avatarUrl } = req.body;
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
displayName,
|
||||
lightningAddress,
|
||||
avatarUrl,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
lightningAddress: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ user: updated });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Nostr challenge
|
||||
router.post('/nostr/challenge', async (req, res) => {
|
||||
const challenge = randomBytes(32).toString('hex');
|
||||
|
||||
// Store challenge temporarily (in production, use Redis with TTL)
|
||||
// For now, we'll include it in the response and verify the signature
|
||||
res.json({
|
||||
challenge,
|
||||
message: `Sign this message to login to LNPaywall: ${challenge}`,
|
||||
});
|
||||
});
|
||||
|
||||
// Nostr verify
|
||||
router.post('/nostr/verify', validateBody(nostrLoginSchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.nostrLogin(req.body);
|
||||
|
||||
res.cookie('refresh_token', result.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
expiresIn: result.expiresIn,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// OAuth start (redirect to provider)
|
||||
router.get('/oauth/:provider/start', (req, res) => {
|
||||
const { provider } = req.params;
|
||||
const { redirect } = req.query;
|
||||
|
||||
// Store redirect URL in session/cookie for after OAuth callback
|
||||
if (redirect) {
|
||||
res.cookie('oauth_redirect', redirect, {
|
||||
httpOnly: true,
|
||||
maxAge: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// In production, implement full OAuth flow
|
||||
// For now, return instructions
|
||||
res.json({
|
||||
message: `OAuth ${provider} not fully implemented. Use email/password or Nostr login.`,
|
||||
supported: ['google', 'github'],
|
||||
});
|
||||
});
|
||||
|
||||
// OAuth callback
|
||||
router.get('/oauth/:provider/callback', async (req, res, next) => {
|
||||
try {
|
||||
const { provider } = req.params;
|
||||
const { code } = req.query;
|
||||
|
||||
// In production, exchange code for tokens and get user info
|
||||
res.json({
|
||||
message: `OAuth ${provider} callback received`,
|
||||
code: code ? 'received' : 'missing',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
114
backend/src/routes/checkout.js
Normal file
114
backend/src/routes/checkout.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Router } from 'express';
|
||||
import { checkoutService } from '../services/checkout.js';
|
||||
import { accessService } from '../services/access.js';
|
||||
import { validateBody, createCheckoutSchema } from '../utils/validation.js';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Rate limit checkout creation
|
||||
const checkoutLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 10, // 10 requests per minute
|
||||
message: { error: 'Too many checkout attempts, please try again later.' },
|
||||
});
|
||||
|
||||
// Create checkout session
|
||||
router.post('/:paywallId', checkoutLimiter, validateBody(createCheckoutSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { paywallId } = req.params;
|
||||
const { buyerEmail } = req.body;
|
||||
|
||||
const buyerHint = checkoutService.generateBuyerHint(
|
||||
req.ip,
|
||||
req.get('user-agent') || ''
|
||||
);
|
||||
|
||||
const session = await checkoutService.createSession(paywallId, {
|
||||
buyerEmail,
|
||||
buyerHint,
|
||||
});
|
||||
|
||||
res.status(201).json(session);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get checkout session status
|
||||
router.get('/:sessionId', async (req, res, next) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const session = await checkoutService.getSession(sessionId);
|
||||
|
||||
res.json({
|
||||
id: session.id,
|
||||
status: session.status,
|
||||
amountSats: session.amountSats,
|
||||
paymentRequest: session.paymentRequest,
|
||||
expiresAt: session.expiresAt,
|
||||
paywall: {
|
||||
id: session.paywall.id,
|
||||
title: session.paywall.title,
|
||||
description: session.paywall.description,
|
||||
coverImageUrl: session.paywall.coverImageUrl,
|
||||
originalUrl: session.status === 'PAID' ? session.paywall.originalUrl : null,
|
||||
},
|
||||
accessGrant: session.accessGrant ? {
|
||||
tokenId: session.accessGrant.tokenId,
|
||||
expiresAt: session.accessGrant.expiresAt,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Check payment status (polling endpoint)
|
||||
router.get('/:sessionId/status', async (req, res, next) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const result = await checkoutService.checkPaymentStatus(sessionId);
|
||||
|
||||
// If paid, generate access token
|
||||
if (result.status === 'PAID' && result.accessGrant) {
|
||||
const session = await checkoutService.getSession(sessionId);
|
||||
const accessToken = accessService.generateAccessToken(result.accessGrant, session.paywall);
|
||||
|
||||
// Set access token cookie
|
||||
const maxAge = result.accessGrant.expiresAt
|
||||
? result.accessGrant.expiresAt - new Date()
|
||||
: 365 * 24 * 60 * 60 * 1000; // 1 year if no expiry
|
||||
|
||||
res.cookie('access_token_' + session.paywall.id, accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge,
|
||||
});
|
||||
|
||||
// Also set token ID in non-httpOnly cookie for JS access
|
||||
res.cookie('token_id_' + session.paywall.id, result.accessGrant.tokenId, {
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge,
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'PAID',
|
||||
accessToken,
|
||||
tokenId: result.accessGrant.tokenId,
|
||||
originalUrl: session.paywall.originalUrl,
|
||||
customSuccessMessage: session.paywall.customSuccessMessage,
|
||||
});
|
||||
} else {
|
||||
res.json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
19
backend/src/routes/config.js
Normal file
19
backend/src/routes/config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get public platform configuration
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
platformFeePercent: parseInt(process.env.PLATFORM_FEE_PERCENT) || 10,
|
||||
platformFeePercentPro: parseInt(process.env.PLATFORM_FEE_PERCENT_PRO) || 0,
|
||||
proPriceSats: parseInt(process.env.PRO_PRICE_SATS) || 50000,
|
||||
features: {
|
||||
nostrLogin: true,
|
||||
oauthLogin: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
381
backend/src/routes/embed.js
Normal file
381
backend/src/routes/embed.js
Normal file
@@ -0,0 +1,381 @@
|
||||
import { Router } from 'express';
|
||||
import { paywallService } from '../services/paywall.js';
|
||||
import { accessService } from '../services/access.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Serve embed HTML (for iframe embedding)
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const paywall = await paywallService.findById(id);
|
||||
|
||||
// Check origin restrictions
|
||||
const origin = req.get('origin') || req.get('referer');
|
||||
if (paywall.allowedEmbedOrigins && paywall.allowedEmbedOrigins.length > 0) {
|
||||
const allowed = paywall.allowedEmbedOrigins.some(allowedOrigin => {
|
||||
if (!origin) return true; // Allow direct access
|
||||
return origin.includes(allowedOrigin);
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
return res.status(403).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a2e; color: #fff; }
|
||||
.error { text-align: center; padding: 2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h3>⚠️ Embedding Not Allowed</h3>
|
||||
<p>This paywall cannot be embedded on this domain.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!paywall.allowEmbed) {
|
||||
return res.status(403).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a2e; color: #fff; }
|
||||
.error { text-align: center; padding: 2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h3>⚠️ Embedding Disabled</h3>
|
||||
<p>Embedding is disabled for this content.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
// Check if user has access
|
||||
const tokenId = req.cookies?.[`token_id_${paywall.id}`];
|
||||
let hasAccess = false;
|
||||
let originalUrl = null;
|
||||
|
||||
if (tokenId) {
|
||||
const accessResult = await accessService.checkAccessByCookie(tokenId, paywall.id);
|
||||
hasAccess = accessResult.hasAccess;
|
||||
if (hasAccess) {
|
||||
originalUrl = accessResult.paywall.originalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
|
||||
// Serve embed HTML
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(paywall.title)} - LNPaywall</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%);
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.paywall-card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 16px;
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.description {
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f7931a;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.price-label {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-weight: 400;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f7931a 0%, #ff6b00 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(247, 147, 26, 0.3);
|
||||
}
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #00c853 0%, #00e676 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.checkout-container {
|
||||
text-align: center;
|
||||
}
|
||||
.qr-code {
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.qr-code img {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
.invoice-text {
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
word-break: break-all;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.copy-btn {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.timer {
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.success-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.hidden { display: none; }
|
||||
.loader {
|
||||
border: 3px solid rgba(255,255,255,0.1);
|
||||
border-top-color: #f7931a;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="paywall-card">
|
||||
${paywall.coverImageUrl ? `<img src="${escapeHtml(paywall.coverImageUrl)}" class="cover-image" alt="">` : '<div class="cover-image"></div>'}
|
||||
|
||||
<div class="content">
|
||||
<h2 class="title">${escapeHtml(paywall.title)}</h2>
|
||||
${paywall.description ? `<p class="description">${escapeHtml(paywall.description)}</p>` : ''}
|
||||
|
||||
<!-- Locked State -->
|
||||
<div id="locked-state" class="${hasAccess ? 'hidden' : ''}">
|
||||
<div class="price">
|
||||
<span>⚡ ${paywall.priceSats.toLocaleString()}</span>
|
||||
<span class="price-label">sats</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="startCheckout()">
|
||||
Unlock Content
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Checkout State -->
|
||||
<div id="checkout-state" class="checkout-container hidden">
|
||||
<div class="loader"></div>
|
||||
<p class="timer" id="timer">Loading invoice...</p>
|
||||
<div id="qr-container" class="hidden">
|
||||
<div class="qr-code">
|
||||
<img id="qr-image" src="" alt="QR Code">
|
||||
</div>
|
||||
<div class="invoice-text" id="invoice-text"></div>
|
||||
<button class="copy-btn" onclick="copyInvoice()">📋 Copy Invoice</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlocked State -->
|
||||
<div id="unlocked-state" class="${hasAccess ? '' : 'hidden'}">
|
||||
<div class="success-icon">✅</div>
|
||||
<p style="margin-bottom: 1rem; color: rgba(255,255,255,0.7);">Access granted!</p>
|
||||
<button class="btn btn-success" onclick="openContent()">
|
||||
Open Content →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_URL = '${apiUrl}';
|
||||
const PAYWALL_ID = '${paywall.id}';
|
||||
let originalUrl = ${hasAccess ? `'${originalUrl}'` : 'null'};
|
||||
let checkoutSession = null;
|
||||
let pollInterval = null;
|
||||
|
||||
async function startCheckout() {
|
||||
document.getElementById('locked-state').classList.add('hidden');
|
||||
document.getElementById('checkout-state').classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(API_URL + '/api/checkout/' + PAYWALL_ID, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create checkout');
|
||||
|
||||
checkoutSession = await response.json();
|
||||
|
||||
// Generate QR code
|
||||
const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' +
|
||||
encodeURIComponent(checkoutSession.paymentRequest);
|
||||
document.getElementById('qr-image').src = qrUrl;
|
||||
document.getElementById('invoice-text').textContent = checkoutSession.paymentRequest;
|
||||
document.getElementById('qr-container').classList.remove('hidden');
|
||||
document.querySelector('.loader').classList.add('hidden');
|
||||
|
||||
// Start polling
|
||||
startPolling();
|
||||
startTimer(new Date(checkoutSession.expiresAt));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error);
|
||||
alert('Failed to create checkout. Please try again.');
|
||||
document.getElementById('checkout-state').classList.add('hidden');
|
||||
document.getElementById('locked-state').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
API_URL + '/api/checkout/' + checkoutSession.sessionId + '/status',
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'PAID') {
|
||||
clearInterval(pollInterval);
|
||||
originalUrl = result.originalUrl;
|
||||
showUnlocked();
|
||||
} else if (result.status === 'EXPIRED') {
|
||||
clearInterval(pollInterval);
|
||||
alert('Payment expired. Please try again.');
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Poll error:', error);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function startTimer(expiresAt) {
|
||||
const timerEl = document.getElementById('timer');
|
||||
const update = () => {
|
||||
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const secs = remaining % 60;
|
||||
timerEl.textContent = 'Expires in ' + mins + ':' + secs.toString().padStart(2, '0');
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
};
|
||||
update();
|
||||
const timerInterval = setInterval(update, 1000);
|
||||
}
|
||||
|
||||
function showUnlocked() {
|
||||
document.getElementById('checkout-state').classList.add('hidden');
|
||||
document.getElementById('unlocked-state').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function openContent() {
|
||||
if (originalUrl) {
|
||||
window.open(originalUrl, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
function copyInvoice() {
|
||||
if (checkoutSession) {
|
||||
navigator.clipboard.writeText(checkoutSession.paymentRequest);
|
||||
event.target.textContent = '✓ Copied!';
|
||||
setTimeout(() => { event.target.textContent = '📋 Copy Invoice'; }, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
173
backend/src/routes/paywalls.js
Normal file
173
backend/src/routes/paywalls.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Router } from 'express';
|
||||
import { paywallService } from '../services/paywall.js';
|
||||
import { authenticate, requireCreator } from '../middleware/auth.js';
|
||||
import { validateBody, createPaywallSchema, updatePaywallSchema } from '../utils/validation.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
router.use(requireCreator);
|
||||
|
||||
// Create paywall
|
||||
router.post('/', validateBody(createPaywallSchema), async (req, res, next) => {
|
||||
try {
|
||||
const paywall = await paywallService.create(req.user.id, req.body);
|
||||
res.status(201).json({ paywall });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// List paywalls
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { status, page = 1, limit = 20 } = req.query;
|
||||
const result = await paywallService.listByCreator(req.user.id, {
|
||||
status,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get paywall stats
|
||||
router.get('/stats', async (req, res, next) => {
|
||||
try {
|
||||
const stats = await paywallService.getStats(req.user.id);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch URL metadata
|
||||
router.post('/fetch-metadata', async (req, res, next) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
const metadata = await paywallService.fetchUrlMetadata(url);
|
||||
res.json(metadata);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get single paywall
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const paywall = await paywallService.findByIdAndCreator(req.params.id, req.user.id);
|
||||
|
||||
// Get additional stats
|
||||
const [salesCount, totalRevenue] = await Promise.all([
|
||||
prisma.sale.count({ where: { paywallId: paywall.id } }),
|
||||
prisma.sale.aggregate({
|
||||
where: { paywallId: paywall.id },
|
||||
_sum: { netSats: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
paywall,
|
||||
stats: {
|
||||
salesCount,
|
||||
totalRevenue: totalRevenue._sum.netSats || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update paywall
|
||||
router.patch('/:id', validateBody(updatePaywallSchema), async (req, res, next) => {
|
||||
try {
|
||||
const paywall = await paywallService.update(req.user.id, req.params.id, req.body);
|
||||
res.json({ paywall });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Archive paywall
|
||||
router.post('/:id/archive', async (req, res, next) => {
|
||||
try {
|
||||
const paywall = await paywallService.archive(req.user.id, req.params.id);
|
||||
res.json({ paywall });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Activate paywall
|
||||
router.post('/:id/activate', async (req, res, next) => {
|
||||
try {
|
||||
await paywallService.findByIdAndCreator(req.params.id, req.user.id);
|
||||
|
||||
const paywall = await prisma.paywall.update({
|
||||
where: { id: req.params.id },
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
|
||||
res.json({ paywall });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get paywall sales
|
||||
router.get('/:id/sales', async (req, res, next) => {
|
||||
try {
|
||||
await paywallService.findByIdAndCreator(req.params.id, req.user.id);
|
||||
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const [sales, total] = await Promise.all([
|
||||
prisma.sale.findMany({
|
||||
where: { paywallId: req.params.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: parseInt(limit),
|
||||
}),
|
||||
prisma.sale.count({ where: { paywallId: req.params.id } }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
sales,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
totalPages: Math.ceil(total / parseInt(limit)),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get embed code
|
||||
router.get('/:id/embed', async (req, res, next) => {
|
||||
try {
|
||||
const paywall = await paywallService.findByIdAndCreator(req.params.id, req.user.id);
|
||||
const baseUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
|
||||
const embedCode = {
|
||||
iframe: `<iframe src="${baseUrl}/embed/${paywall.id}" width="100%" height="400" frameborder="0" style="border-radius: 12px; max-width: 400px;"></iframe>`,
|
||||
button: `<script src="${apiUrl}/js/paywall.js" data-paywall="${paywall.id}" data-theme="auto"></script>`,
|
||||
link: `${baseUrl}/p/${paywall.slug || paywall.id}`,
|
||||
};
|
||||
|
||||
res.json(embedCode);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
97
backend/src/routes/public.js
Normal file
97
backend/src/routes/public.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express';
|
||||
import { paywallService } from '../services/paywall.js';
|
||||
import { accessService } from '../services/access.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get public paywall data for hosted page
|
||||
router.get('/:slugOrId', async (req, res, next) => {
|
||||
try {
|
||||
const { slugOrId } = req.params;
|
||||
const paywall = await paywallService.findBySlugOrId(slugOrId);
|
||||
|
||||
// Check if user already has access
|
||||
const tokenId = req.cookies?.[`token_id_${paywall.id}`];
|
||||
let hasAccess = false;
|
||||
let accessInfo = null;
|
||||
|
||||
if (tokenId) {
|
||||
const accessResult = await accessService.checkAccessByCookie(tokenId, paywall.id);
|
||||
hasAccess = accessResult.hasAccess;
|
||||
if (hasAccess) {
|
||||
accessInfo = {
|
||||
originalUrl: accessResult.paywall.originalUrl,
|
||||
expiresAt: accessResult.accessGrant?.expiresAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
paywall: {
|
||||
id: paywall.id,
|
||||
title: paywall.title,
|
||||
description: paywall.description,
|
||||
coverImageUrl: paywall.coverImageUrl,
|
||||
priceSats: paywall.priceSats,
|
||||
previewMode: paywall.previewMode,
|
||||
previewContent: paywall.previewContent,
|
||||
originalUrlType: paywall.originalUrlType,
|
||||
customSuccessMessage: paywall.customSuccessMessage,
|
||||
customBranding: paywall.customBranding,
|
||||
// Only include original URL if user has access
|
||||
originalUrl: hasAccess ? paywall.originalUrl : null,
|
||||
creator: paywall.creator,
|
||||
},
|
||||
hasAccess,
|
||||
accessInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get paywall for embed (checks origin)
|
||||
router.get('/:id/embed-data', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const paywall = await paywallService.findById(id);
|
||||
|
||||
// Check origin restrictions
|
||||
const origin = req.get('origin') || req.get('referer');
|
||||
if (paywall.allowedEmbedOrigins && paywall.allowedEmbedOrigins.length > 0) {
|
||||
const allowed = paywall.allowedEmbedOrigins.some(allowedOrigin => {
|
||||
if (!origin) return false;
|
||||
return origin.includes(allowedOrigin);
|
||||
});
|
||||
|
||||
if (!allowed && origin) {
|
||||
return res.status(403).json({
|
||||
error: 'Embedding not allowed from this origin',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!paywall.allowEmbed) {
|
||||
return res.status(403).json({
|
||||
error: 'Embedding is disabled for this paywall',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: paywall.id,
|
||||
title: paywall.title,
|
||||
description: paywall.description,
|
||||
coverImageUrl: paywall.coverImageUrl,
|
||||
priceSats: paywall.priceSats,
|
||||
previewMode: paywall.previewMode,
|
||||
previewContent: paywall.previewContent,
|
||||
customBranding: paywall.customBranding,
|
||||
creator: paywall.creator,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
103
backend/src/routes/subscription.js
Normal file
103
backend/src/routes/subscription.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { lnbitsService } from '../services/lnbits.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const PRO_PRICE_SATS = parseInt(process.env.PRO_PRICE_SATS) || 50000;
|
||||
const PRO_DURATION_DAYS = 30;
|
||||
|
||||
// Create pro subscription checkout
|
||||
router.post('/checkout', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
|
||||
// Check if already pro and not expired
|
||||
if (user.subscriptionTier === 'PRO' && user.subscriptionExpiry && user.subscriptionExpiry > new Date()) {
|
||||
return res.status(400).json({ error: 'You already have an active Pro subscription' });
|
||||
}
|
||||
|
||||
// Create invoice
|
||||
const webhookUrl = `${process.env.API_URL}/api/webhooks/subscription`;
|
||||
const memo = `LNPaywall Pro Subscription - 30 days`;
|
||||
|
||||
const invoice = await lnbitsService.createInvoice(PRO_PRICE_SATS, memo, webhookUrl);
|
||||
|
||||
// Store the pending subscription with the payment hash
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO ProSubscriptionCheckout (id, userId, paymentHash, amountSats, status, createdAt, expiresAt)
|
||||
VALUES (${invoice.paymentHash}, ${user.id}, ${invoice.paymentHash}, ${PRO_PRICE_SATS}, 'PENDING', datetime('now'), datetime('now', '+10 minutes'))
|
||||
`.catch(() => {
|
||||
// Table might not exist, create it
|
||||
});
|
||||
|
||||
res.json({
|
||||
paymentRequest: invoice.paymentRequest,
|
||||
paymentHash: invoice.paymentHash,
|
||||
amountSats: PRO_PRICE_SATS,
|
||||
durationDays: PRO_DURATION_DAYS,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Check subscription status
|
||||
router.get('/status', authenticate, async (req, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
subscriptionTier: true,
|
||||
subscriptionExpiry: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isActive = user.subscriptionTier === 'PRO' &&
|
||||
(!user.subscriptionExpiry || user.subscriptionExpiry > new Date());
|
||||
|
||||
res.json({
|
||||
tier: user.subscriptionTier,
|
||||
expiry: user.subscriptionExpiry,
|
||||
isActive,
|
||||
proPriceSats: PRO_PRICE_SATS,
|
||||
proDurationDays: PRO_DURATION_DAYS,
|
||||
});
|
||||
});
|
||||
|
||||
// Check payment status (polling)
|
||||
router.get('/checkout/:paymentHash/status', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { paymentHash } = req.params;
|
||||
|
||||
// Check with LNbits
|
||||
const paymentStatus = await lnbitsService.checkPaymentStatus(paymentHash);
|
||||
|
||||
if (paymentStatus.paid) {
|
||||
// Activate pro subscription
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + PRO_DURATION_DAYS);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
subscriptionTier: 'PRO',
|
||||
subscriptionExpiry: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
status: 'PAID',
|
||||
tier: 'PRO',
|
||||
expiry: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ status: 'PENDING' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
80
backend/src/routes/webhooks.js
Normal file
80
backend/src/routes/webhooks.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Router } from 'express';
|
||||
import { checkoutService } from '../services/checkout.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// LNbits webhook
|
||||
router.post('/lnbits', async (req, res, next) => {
|
||||
try {
|
||||
console.log('LNbits webhook received:', JSON.stringify(req.body));
|
||||
|
||||
const result = await checkoutService.handleWebhook('lnbits', req.body);
|
||||
|
||||
res.json({ received: true, ...result });
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
|
||||
// Log failed webhook
|
||||
await prisma.webhookEvent.create({
|
||||
data: {
|
||||
provider: 'lnbits',
|
||||
eventType: 'unknown',
|
||||
rawPayload: JSON.stringify(req.body),
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
},
|
||||
});
|
||||
|
||||
// Always return 200 to prevent retries
|
||||
res.json({ received: true, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// BTCPay Server webhook (future)
|
||||
router.post('/btcpay', async (req, res, next) => {
|
||||
try {
|
||||
console.log('BTCPay webhook received:', JSON.stringify(req.body));
|
||||
|
||||
// Log webhook
|
||||
await prisma.webhookEvent.create({
|
||||
data: {
|
||||
provider: 'btcpay',
|
||||
eventType: req.body.type || 'unknown',
|
||||
rawPayload: JSON.stringify(req.body),
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Implement BTCPay webhook handling
|
||||
|
||||
res.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error('BTCPay webhook error:', error);
|
||||
res.json({ received: true, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Generic webhook status check
|
||||
router.get('/status', async (req, res) => {
|
||||
const recentEvents = await prisma.webhookEvent.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
provider: true,
|
||||
eventType: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
processedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
recentEvents,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
208
backend/src/services/access.js
Normal file
208
backend/src/services/access.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||
|
||||
export class AccessService {
|
||||
generateAccessToken(accessGrant, paywall) {
|
||||
const payload = {
|
||||
tokenId: accessGrant.tokenId,
|
||||
paywallId: accessGrant.paywallId,
|
||||
type: 'access',
|
||||
};
|
||||
|
||||
// Set expiry if access grant has expiry
|
||||
const options = {};
|
||||
if (accessGrant.expiresAt) {
|
||||
const expiresIn = Math.floor((accessGrant.expiresAt - new Date()) / 1000);
|
||||
if (expiresIn > 0) {
|
||||
options.expiresIn = expiresIn;
|
||||
}
|
||||
}
|
||||
|
||||
return jwt.sign(payload, JWT_SECRET, options);
|
||||
}
|
||||
|
||||
async verifyAccess(token, paywallId, deviceFingerprint = null) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.type !== 'access') {
|
||||
throw new AppError('Invalid token type', 401);
|
||||
}
|
||||
|
||||
// Find access grant
|
||||
const accessGrant = await prisma.accessGrant.findUnique({
|
||||
where: { tokenId: decoded.tokenId },
|
||||
include: {
|
||||
paywall: {
|
||||
select: {
|
||||
id: true,
|
||||
originalUrl: true,
|
||||
maxDevices: true,
|
||||
customSuccessMessage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!accessGrant) {
|
||||
throw new AppError('Access not found', 404);
|
||||
}
|
||||
|
||||
if (accessGrant.status === 'REVOKED') {
|
||||
throw new AppError('Access revoked', 403);
|
||||
}
|
||||
|
||||
// Check paywall ID matches
|
||||
if (paywallId && accessGrant.paywallId !== paywallId) {
|
||||
throw new AppError('Access not valid for this content', 403);
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (accessGrant.expiresAt && accessGrant.expiresAt < new Date()) {
|
||||
throw new AppError('Access expired', 403);
|
||||
}
|
||||
|
||||
// Check device fingerprint
|
||||
if (deviceFingerprint) {
|
||||
const devices = JSON.parse(accessGrant.deviceFingerprints || '[]');
|
||||
const maxDevices = accessGrant.paywall.maxDevices || 3;
|
||||
|
||||
if (!devices.includes(deviceFingerprint)) {
|
||||
if (devices.length >= maxDevices) {
|
||||
throw new AppError('Maximum devices reached', 403);
|
||||
}
|
||||
|
||||
// Add new device
|
||||
await prisma.accessGrant.update({
|
||||
where: { id: accessGrant.id },
|
||||
data: {
|
||||
deviceFingerprints: JSON.stringify([...devices, deviceFingerprint]),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update usage
|
||||
await prisma.accessGrant.update({
|
||||
where: { id: accessGrant.id },
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
usageCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
accessGrant,
|
||||
paywall: accessGrant.paywall,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Invalid access token', 401);
|
||||
}
|
||||
}
|
||||
|
||||
async revokeAccess(accessGrantId, userId) {
|
||||
const accessGrant = await prisma.accessGrant.findUnique({
|
||||
where: { id: accessGrantId },
|
||||
include: {
|
||||
paywall: {
|
||||
select: { creatorId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!accessGrant) {
|
||||
throw new AppError('Access grant not found', 404);
|
||||
}
|
||||
|
||||
if (accessGrant.paywall.creatorId !== userId) {
|
||||
throw new AppError('Unauthorized', 403);
|
||||
}
|
||||
|
||||
await prisma.accessGrant.update({
|
||||
where: { id: accessGrantId },
|
||||
data: { status: 'REVOKED' },
|
||||
});
|
||||
|
||||
return { revoked: true };
|
||||
}
|
||||
|
||||
async getAccessByPaywall(paywallId, options = {}) {
|
||||
const { page = 1, limit = 20 } = options;
|
||||
|
||||
const [grants, total] = await Promise.all([
|
||||
prisma.accessGrant.findMany({
|
||||
where: { paywallId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
include: {
|
||||
buyer: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.accessGrant.count({ where: { paywallId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
grants,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async checkAccessByCookie(tokenId, paywallId) {
|
||||
const accessGrant = await prisma.accessGrant.findUnique({
|
||||
where: { tokenId },
|
||||
include: {
|
||||
paywall: {
|
||||
select: {
|
||||
id: true,
|
||||
originalUrl: true,
|
||||
customSuccessMessage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!accessGrant) {
|
||||
return { hasAccess: false };
|
||||
}
|
||||
|
||||
if (accessGrant.status === 'REVOKED') {
|
||||
return { hasAccess: false, reason: 'revoked' };
|
||||
}
|
||||
|
||||
if (accessGrant.expiresAt && accessGrant.expiresAt < new Date()) {
|
||||
return { hasAccess: false, reason: 'expired' };
|
||||
}
|
||||
|
||||
if (paywallId && accessGrant.paywallId !== paywallId) {
|
||||
return { hasAccess: false };
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
accessGrant,
|
||||
paywall: accessGrant.paywall,
|
||||
};
|
||||
}
|
||||
|
||||
generateDeviceFingerprint(userAgent, ip) {
|
||||
const data = `${userAgent}:${ip}`;
|
||||
return createHash('sha256').update(data).digest('hex').slice(0, 16);
|
||||
}
|
||||
}
|
||||
|
||||
export const accessService = new AccessService();
|
||||
export default accessService;
|
||||
|
||||
337
backend/src/services/auth.js
Normal file
337
backend/src/services/auth.js
Normal file
@@ -0,0 +1,337 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomUUID, createHash } from 'crypto';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
import { schnorr } from '@noble/curves/secp256k1';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
|
||||
const JWT_ACCESS_EXPIRES_IN = process.env.JWT_ACCESS_EXPIRES_IN || '15m';
|
||||
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
|
||||
|
||||
export class AuthService {
|
||||
async hashPassword(password) {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
async comparePassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
generateAccessToken(userId) {
|
||||
return jwt.sign(
|
||||
{ userId, type: 'access' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_ACCESS_EXPIRES_IN }
|
||||
);
|
||||
}
|
||||
|
||||
generateRefreshToken(userId, sessionId) {
|
||||
return jwt.sign(
|
||||
{ userId, sessionId, type: 'refresh' },
|
||||
JWT_REFRESH_SECRET,
|
||||
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
||||
);
|
||||
}
|
||||
|
||||
verifyAccessToken(token) {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
}
|
||||
|
||||
verifyRefreshToken(token) {
|
||||
return jwt.verify(token, JWT_REFRESH_SECRET);
|
||||
}
|
||||
|
||||
async signup({ email, password, displayName }) {
|
||||
// Check if user exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new AppError('Email already registered', 409);
|
||||
}
|
||||
|
||||
// Create user
|
||||
const passwordHash = await this.hashPassword(password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
displayName: displayName || email.split('@')[0],
|
||||
},
|
||||
});
|
||||
|
||||
// Create session and tokens
|
||||
const tokens = await this.createSession(user.id);
|
||||
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async login({ email, password }) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new AppError('Invalid email or password', 401);
|
||||
}
|
||||
|
||||
const isValid = await this.comparePassword(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new AppError('Invalid email or password', 401);
|
||||
}
|
||||
|
||||
if (user.status === 'DISABLED') {
|
||||
throw new AppError('Account disabled', 403);
|
||||
}
|
||||
|
||||
const tokens = await this.createSession(user.id);
|
||||
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async createSession(userId, userAgent = null, ipAddress = null) {
|
||||
const sessionId = randomUUID();
|
||||
const refreshToken = this.generateRefreshToken(userId, sessionId);
|
||||
const accessToken = this.generateAccessToken(userId);
|
||||
|
||||
// Calculate expiry (7 days)
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
id: sessionId,
|
||||
userId,
|
||||
refreshToken,
|
||||
userAgent,
|
||||
ipAddress,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: 900, // 15 minutes in seconds
|
||||
};
|
||||
}
|
||||
|
||||
async refreshTokens(refreshToken) {
|
||||
try {
|
||||
const decoded = this.verifyRefreshToken(refreshToken);
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { refreshToken },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
throw new AppError('Session expired', 401);
|
||||
}
|
||||
|
||||
if (session.user.status === 'DISABLED') {
|
||||
throw new AppError('Account disabled', 403);
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = this.generateAccessToken(session.userId);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
user: this.sanitizeUser(session.user),
|
||||
expiresIn: 900,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError('Invalid refresh token', 401);
|
||||
}
|
||||
}
|
||||
|
||||
async logout(refreshToken) {
|
||||
if (refreshToken) {
|
||||
await prisma.session.deleteMany({
|
||||
where: { refreshToken },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async logoutAll(userId) {
|
||||
await prisma.session.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
async findOrCreateOAuthUser({ provider, providerId, email, displayName, avatarUrl }) {
|
||||
const providerField = `${provider}Id`;
|
||||
|
||||
// Try to find by provider ID
|
||||
let user = await prisma.user.findFirst({
|
||||
where: { [providerField]: providerId },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const tokens = await this.createSession(user.id);
|
||||
return { user: this.sanitizeUser(user), ...tokens };
|
||||
}
|
||||
|
||||
// Try to find by email and link
|
||||
if (email) {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Link the OAuth provider
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { [providerField]: providerId },
|
||||
});
|
||||
|
||||
const tokens = await this.createSession(user.id);
|
||||
return { user: this.sanitizeUser(user), ...tokens };
|
||||
}
|
||||
}
|
||||
|
||||
// Create new user
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: email?.toLowerCase(),
|
||||
displayName: displayName || email?.split('@')[0] || 'User',
|
||||
avatarUrl,
|
||||
[providerField]: providerId,
|
||||
emailVerified: !!email,
|
||||
},
|
||||
});
|
||||
|
||||
const tokens = await this.createSession(user.id);
|
||||
return { user: this.sanitizeUser(user), ...tokens };
|
||||
}
|
||||
|
||||
async nostrLogin({ pubkey, signature, challenge, event }) {
|
||||
// Verify the Nostr event signature if provided
|
||||
if (event) {
|
||||
// Validate NIP-98 style event
|
||||
const isValid = await this.verifyNostrEvent(event, pubkey);
|
||||
if (!isValid) {
|
||||
throw new AppError('Invalid Nostr signature', 401);
|
||||
}
|
||||
} else if (signature && challenge) {
|
||||
// Simple schnorr signature verification
|
||||
const isValid = await this.verifySchnorrSignature(pubkey, signature, challenge);
|
||||
if (!isValid) {
|
||||
throw new AppError('Invalid signature', 401);
|
||||
}
|
||||
}
|
||||
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { nostrPubkey: pubkey },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Create new user with Nostr pubkey
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
nostrPubkey: pubkey,
|
||||
displayName: `nostr:${pubkey.slice(0, 8)}...`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (user.status === 'DISABLED') {
|
||||
throw new AppError('Account disabled', 403);
|
||||
}
|
||||
|
||||
const tokens = await this.createSession(user.id);
|
||||
return { user: this.sanitizeUser(user), ...tokens, isNewUser: !user };
|
||||
}
|
||||
|
||||
async verifySchnorrSignature(pubkey, signature, message) {
|
||||
try {
|
||||
const messageHash = createHash('sha256').update(message).digest();
|
||||
const sigBytes = hexToBytes(signature);
|
||||
const pubkeyBytes = hexToBytes(pubkey);
|
||||
return schnorr.verify(sigBytes, messageHash, pubkeyBytes);
|
||||
} catch (error) {
|
||||
console.error('Signature verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyNostrEvent(event, expectedPubkey) {
|
||||
try {
|
||||
// Verify event structure
|
||||
if (!event.id || !event.pubkey || !event.sig || !event.created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify pubkey matches
|
||||
if (event.pubkey !== expectedPubkey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check event isn't too old (5 minutes)
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(now - event.created_at) > 300) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Serialize event for ID verification
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
]);
|
||||
|
||||
// Verify event ID
|
||||
const eventId = createHash('sha256').update(serialized).digest('hex');
|
||||
if (eventId !== event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const sigBytes = hexToBytes(event.sig);
|
||||
const idBytes = hexToBytes(event.id);
|
||||
const pubkeyBytes = hexToBytes(event.pubkey);
|
||||
|
||||
return schnorr.verify(sigBytes, idBytes, pubkeyBytes);
|
||||
} catch (error) {
|
||||
console.error('Nostr event verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sanitizeUser(user) {
|
||||
const isPro = user.subscriptionTier === 'PRO' &&
|
||||
(!user.subscriptionExpiry || user.subscriptionExpiry > new Date());
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
lightningAddress: user.lightningAddress,
|
||||
nostrPubkey: user.nostrPubkey,
|
||||
subscriptionTier: user.subscriptionTier || 'FREE',
|
||||
subscriptionExpiry: user.subscriptionExpiry,
|
||||
isPro,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
export default authService;
|
||||
|
||||
326
backend/src/services/checkout.js
Normal file
326
backend/src/services/checkout.js
Normal file
@@ -0,0 +1,326 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
import { lnbitsService } from './lnbits.js';
|
||||
import { accessService } from './access.js';
|
||||
import { createHash } from 'crypto';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const PLATFORM_FEE_PERCENT = parseInt(process.env.PLATFORM_FEE_PERCENT) || 10;
|
||||
const PLATFORM_FEE_PERCENT_PRO = parseInt(process.env.PLATFORM_FEE_PERCENT_PRO) || 0;
|
||||
|
||||
export class CheckoutService {
|
||||
async createSession(paywallId, options = {}) {
|
||||
const { buyerEmail, buyerHint } = options;
|
||||
|
||||
// Get paywall
|
||||
const paywall = await prisma.paywall.findUnique({
|
||||
where: { id: paywallId },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
lightningAddress: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!paywall) {
|
||||
throw new AppError('Paywall not found', 404);
|
||||
}
|
||||
|
||||
if (paywall.status !== 'ACTIVE') {
|
||||
throw new AppError('Paywall is not available', 400);
|
||||
}
|
||||
|
||||
// Create Lightning invoice
|
||||
const webhookUrl = `${process.env.API_URL}/api/webhooks/lnbits`;
|
||||
const memo = `LNPaywall: ${paywall.title}`.slice(0, 100);
|
||||
|
||||
const invoice = await lnbitsService.createInvoice(
|
||||
paywall.priceSats,
|
||||
memo,
|
||||
webhookUrl
|
||||
);
|
||||
|
||||
// Calculate expiry (10 minutes)
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setMinutes(expiresAt.getMinutes() + 10);
|
||||
|
||||
// Create checkout session
|
||||
const session = await prisma.checkoutSession.create({
|
||||
data: {
|
||||
paywallId,
|
||||
amountSats: paywall.priceSats,
|
||||
paymentProvider: 'lnbits',
|
||||
paymentRequest: invoice.paymentRequest,
|
||||
paymentHash: invoice.paymentHash,
|
||||
expiresAt,
|
||||
buyerHint,
|
||||
buyerEmail,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
paymentRequest: invoice.paymentRequest,
|
||||
paymentHash: invoice.paymentHash,
|
||||
amountSats: paywall.priceSats,
|
||||
expiresAt,
|
||||
paywall: {
|
||||
id: paywall.id,
|
||||
title: paywall.title,
|
||||
description: paywall.description,
|
||||
coverImageUrl: paywall.coverImageUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getSession(sessionId) {
|
||||
const session = await prisma.checkoutSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
paywall: {
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accessGrant: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new AppError('Checkout session not found', 404);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async checkPaymentStatus(sessionId) {
|
||||
const session = await this.getSession(sessionId);
|
||||
|
||||
// If already paid, return current status
|
||||
if (session.status === 'PAID') {
|
||||
return {
|
||||
status: 'PAID',
|
||||
accessGrant: session.accessGrant,
|
||||
};
|
||||
}
|
||||
|
||||
// If expired, update status
|
||||
if (session.expiresAt < new Date() && session.status === 'PENDING') {
|
||||
await prisma.checkoutSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
return { status: 'EXPIRED' };
|
||||
}
|
||||
|
||||
// Check with LNbits
|
||||
const paymentStatus = await lnbitsService.checkPaymentStatus(session.paymentHash);
|
||||
|
||||
if (paymentStatus.paid) {
|
||||
// Process payment
|
||||
const result = await this.processPayment(session);
|
||||
return {
|
||||
status: 'PAID',
|
||||
accessGrant: result.accessGrant,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: session.status,
|
||||
expiresAt: session.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async processPayment(session) {
|
||||
// Prevent double processing
|
||||
if (session.status === 'PAID') {
|
||||
const existingGrant = await prisma.accessGrant.findUnique({
|
||||
where: { checkoutSessionId: session.id },
|
||||
});
|
||||
return { accessGrant: existingGrant };
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Update session status
|
||||
await tx.checkoutSession.update({
|
||||
where: { id: session.id },
|
||||
data: { status: 'PAID' },
|
||||
});
|
||||
|
||||
// Get paywall and creator info
|
||||
const paywall = await tx.paywall.findUnique({
|
||||
where: { id: session.paywallId },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
lightningAddress: true,
|
||||
subscriptionTier: true,
|
||||
subscriptionExpiry: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check if creator is Pro (no fees for Pro)
|
||||
const isPro = paywall.creator.subscriptionTier === 'PRO' &&
|
||||
(!paywall.creator.subscriptionExpiry || paywall.creator.subscriptionExpiry > new Date());
|
||||
|
||||
// Calculate fees - Pro users get 0% fee
|
||||
const feePercent = isPro ? PLATFORM_FEE_PERCENT_PRO : PLATFORM_FEE_PERCENT;
|
||||
const platformFeeSats = Math.ceil(session.amountSats * feePercent / 100);
|
||||
const netSats = session.amountSats - platformFeeSats;
|
||||
|
||||
// Create sale record
|
||||
const sale = await tx.sale.create({
|
||||
data: {
|
||||
paywallId: session.paywallId,
|
||||
creatorId: paywall.creatorId,
|
||||
checkoutSessionId: session.id,
|
||||
amountSats: session.amountSats,
|
||||
platformFeeSats,
|
||||
netSats,
|
||||
paymentProvider: session.paymentProvider,
|
||||
providerReference: session.paymentHash,
|
||||
},
|
||||
});
|
||||
|
||||
// Create access grant
|
||||
let expiresAt = null;
|
||||
if (paywall.accessExpirySeconds) {
|
||||
expiresAt = new Date();
|
||||
expiresAt.setSeconds(expiresAt.getSeconds() + paywall.accessExpirySeconds);
|
||||
}
|
||||
|
||||
const accessGrant = await tx.accessGrant.create({
|
||||
data: {
|
||||
paywallId: session.paywallId,
|
||||
checkoutSessionId: session.id,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { sale, accessGrant, creator: paywall.creator, netSats };
|
||||
});
|
||||
|
||||
// Auto payout to creator if they have a Lightning Address
|
||||
if (result.creator.lightningAddress && result.netSats > 0) {
|
||||
try {
|
||||
await this.payoutToCreator(result.creator.lightningAddress, result.netSats, result.sale?.id);
|
||||
} catch (error) {
|
||||
console.error('Auto payout failed:', error);
|
||||
// Don't fail the whole transaction, just log the error
|
||||
// Could implement retry logic or mark for manual payout
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async payoutToCreator(lightningAddress, amountSats, saleId) {
|
||||
// Resolve Lightning Address to get invoice
|
||||
const [username, domain] = lightningAddress.split('@');
|
||||
if (!username || !domain) {
|
||||
throw new Error('Invalid Lightning Address format');
|
||||
}
|
||||
|
||||
// Fetch LNURL-pay endpoint
|
||||
const lnurlEndpoint = `https://${domain}/.well-known/lnurlp/${username}`;
|
||||
const lnurlResponse = await fetch(lnurlEndpoint);
|
||||
|
||||
if (!lnurlResponse.ok) {
|
||||
throw new Error(`Failed to resolve Lightning Address: ${lnurlResponse.status}`);
|
||||
}
|
||||
|
||||
const lnurlData = await lnurlResponse.json();
|
||||
|
||||
if (lnurlData.status === 'ERROR') {
|
||||
throw new Error(lnurlData.reason || 'LNURL error');
|
||||
}
|
||||
|
||||
// Check amount limits (amounts are in millisats)
|
||||
const amountMsats = amountSats * 1000;
|
||||
if (amountMsats < lnurlData.minSendable || amountMsats > lnurlData.maxSendable) {
|
||||
throw new Error(`Amount ${amountSats} sats is outside allowed range`);
|
||||
}
|
||||
|
||||
// Get invoice from callback
|
||||
const callbackUrl = new URL(lnurlData.callback);
|
||||
callbackUrl.searchParams.set('amount', amountMsats.toString());
|
||||
|
||||
const invoiceResponse = await fetch(callbackUrl.toString());
|
||||
const invoiceData = await invoiceResponse.json();
|
||||
|
||||
if (invoiceData.status === 'ERROR') {
|
||||
throw new Error(invoiceData.reason || 'Failed to get invoice');
|
||||
}
|
||||
|
||||
// Pay the invoice
|
||||
const payResult = await lnbitsService.payInvoice(invoiceData.pr);
|
||||
|
||||
console.log(`Payout successful: ${amountSats} sats to ${lightningAddress} for sale ${saleId}`);
|
||||
|
||||
return payResult;
|
||||
}
|
||||
|
||||
async handleWebhook(provider, payload) {
|
||||
if (provider === 'lnbits') {
|
||||
return this.handleLNbitsWebhook(payload);
|
||||
}
|
||||
throw new AppError('Unknown payment provider', 400);
|
||||
}
|
||||
|
||||
async handleLNbitsWebhook(payload) {
|
||||
const { payment_hash, paid } = payload;
|
||||
|
||||
if (!paid) return { processed: false };
|
||||
|
||||
// Find checkout session
|
||||
const session = await prisma.checkoutSession.findUnique({
|
||||
where: { paymentHash: payment_hash },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
console.log('Webhook: Session not found for payment hash:', payment_hash);
|
||||
return { processed: false };
|
||||
}
|
||||
|
||||
if (session.status === 'PAID') {
|
||||
return { processed: true, alreadyPaid: true };
|
||||
}
|
||||
|
||||
// Process the payment
|
||||
await this.processPayment(session);
|
||||
|
||||
// Log webhook event
|
||||
await prisma.webhookEvent.create({
|
||||
data: {
|
||||
provider: 'lnbits',
|
||||
eventType: 'payment.received',
|
||||
rawPayload: JSON.stringify(payload),
|
||||
processedAt: new Date(),
|
||||
status: 'processed',
|
||||
},
|
||||
});
|
||||
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
generateBuyerHint(ip, userAgent) {
|
||||
const combined = `${ip}:${userAgent}`;
|
||||
return createHash('sha256').update(combined).digest('hex').slice(0, 32);
|
||||
}
|
||||
}
|
||||
|
||||
export const checkoutService = new CheckoutService();
|
||||
export default checkoutService;
|
||||
|
||||
165
backend/src/services/lnbits.js
Normal file
165
backend/src/services/lnbits.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const LNBITS_URL = process.env.LNBITS_URL || 'https://legend.lnbits.com';
|
||||
const LNBITS_ADMIN_KEY = process.env.LNBITS_ADMIN_KEY;
|
||||
const LNBITS_INVOICE_KEY = process.env.LNBITS_INVOICE_KEY;
|
||||
|
||||
export class LNbitsService {
|
||||
constructor(adminKey = LNBITS_ADMIN_KEY, invoiceKey = LNBITS_INVOICE_KEY) {
|
||||
this.adminKey = adminKey;
|
||||
this.invoiceKey = invoiceKey;
|
||||
this.baseUrl = LNBITS_URL;
|
||||
}
|
||||
|
||||
async createInvoice(amountSats, memo = 'LNPaywall Purchase', webhookUrl = null) {
|
||||
try {
|
||||
const body = {
|
||||
out: false,
|
||||
amount: amountSats,
|
||||
memo: memo,
|
||||
unit: 'sat',
|
||||
};
|
||||
|
||||
if (webhookUrl) {
|
||||
body.webhook = webhookUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/payments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': this.invoiceKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`LNbits error: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
paymentRequest: data.payment_request,
|
||||
paymentHash: data.payment_hash,
|
||||
checkingId: data.checking_id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('LNbits createInvoice error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkPaymentStatus(paymentHash) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/payments/${paymentHash}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Api-Key': this.invoiceKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return { paid: false, pending: true };
|
||||
}
|
||||
throw new Error('Failed to check payment status');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
paid: data.paid === true,
|
||||
pending: data.pending === true,
|
||||
preimage: data.preimage,
|
||||
details: data.details,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('LNbits checkPaymentStatus error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getWalletBalance() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/wallet`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Api-Key': this.invoiceKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get wallet balance');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
balance: Math.floor(data.balance / 1000), // Convert msats to sats
|
||||
name: data.name,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('LNbits getWalletBalance error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async payInvoice(paymentRequest) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/payments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': this.adminKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
out: true,
|
||||
bolt11: paymentRequest,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`LNbits payment error: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
paymentHash: data.payment_hash,
|
||||
checkingId: data.checking_id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('LNbits payInvoice error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async decodeInvoice(paymentRequest) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/payments/decode`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': this.invoiceKey,
|
||||
},
|
||||
body: JSON.stringify({ data: paymentRequest }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to decode invoice');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('LNbits decodeInvoice error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lnbitsService = new LNbitsService();
|
||||
export default lnbitsService;
|
||||
|
||||
353
backend/src/services/paywall.js
Normal file
353
backend/src/services/paywall.js
Normal file
@@ -0,0 +1,353 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
import { fetchMetadata } from '../utils/metadata.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export class PaywallService {
|
||||
async create(userId, data) {
|
||||
// Generate slug if not provided
|
||||
const slug = data.slug || nanoid(10);
|
||||
|
||||
// Validate URL
|
||||
this.validateUrl(data.originalUrl);
|
||||
|
||||
// Detect URL type
|
||||
const urlType = this.detectUrlType(data.originalUrl);
|
||||
|
||||
const paywall = await prisma.paywall.create({
|
||||
data: {
|
||||
creatorId: userId,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
coverImageUrl: data.coverImageUrl,
|
||||
originalUrl: data.originalUrl,
|
||||
originalUrlType: urlType,
|
||||
previewMode: data.previewMode || 'NONE',
|
||||
previewContent: data.previewContent,
|
||||
priceSats: data.priceSats,
|
||||
accessExpirySeconds: data.accessExpirySeconds,
|
||||
maxDevices: data.maxDevices || 3,
|
||||
maxSessions: data.maxSessions || 5,
|
||||
allowEmbed: data.allowEmbed !== false,
|
||||
allowedEmbedOrigins: JSON.stringify(data.allowedEmbedOrigins || []),
|
||||
requireEmailReceipt: data.requireEmailReceipt || false,
|
||||
customSuccessMessage: data.customSuccessMessage,
|
||||
customBranding: data.customBranding ? JSON.stringify(data.customBranding) : null,
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
return paywall;
|
||||
}
|
||||
|
||||
async update(userId, paywallId, data) {
|
||||
const paywall = await this.findByIdAndCreator(paywallId, userId);
|
||||
|
||||
if (data.originalUrl) {
|
||||
this.validateUrl(data.originalUrl);
|
||||
}
|
||||
|
||||
const updated = await prisma.paywall.update({
|
||||
where: { id: paywallId },
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
coverImageUrl: data.coverImageUrl,
|
||||
originalUrl: data.originalUrl,
|
||||
originalUrlType: data.originalUrl ? this.detectUrlType(data.originalUrl) : undefined,
|
||||
previewMode: data.previewMode,
|
||||
previewContent: data.previewContent,
|
||||
priceSats: data.priceSats,
|
||||
accessExpirySeconds: data.accessExpirySeconds,
|
||||
maxDevices: data.maxDevices,
|
||||
maxSessions: data.maxSessions,
|
||||
allowEmbed: data.allowEmbed,
|
||||
allowedEmbedOrigins: data.allowedEmbedOrigins ? JSON.stringify(data.allowedEmbedOrigins) : undefined,
|
||||
requireEmailReceipt: data.requireEmailReceipt,
|
||||
customSuccessMessage: data.customSuccessMessage,
|
||||
customBranding: data.customBranding ? JSON.stringify(data.customBranding) : undefined,
|
||||
slug: data.slug,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async findByIdAndCreator(paywallId, userId) {
|
||||
const paywall = await prisma.paywall.findFirst({
|
||||
where: {
|
||||
id: paywallId,
|
||||
creatorId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paywall) {
|
||||
throw new AppError('Paywall not found', 404);
|
||||
}
|
||||
|
||||
return paywall;
|
||||
}
|
||||
|
||||
async findById(paywallId) {
|
||||
const paywall = await prisma.paywall.findUnique({
|
||||
where: { id: paywallId },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!paywall) {
|
||||
throw new AppError('Paywall not found', 404);
|
||||
}
|
||||
|
||||
return paywall;
|
||||
}
|
||||
|
||||
async findBySlug(slug) {
|
||||
const paywall = await prisma.paywall.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!paywall) {
|
||||
throw new AppError('Paywall not found', 404);
|
||||
}
|
||||
|
||||
return paywall;
|
||||
}
|
||||
|
||||
async findBySlugOrId(slugOrId) {
|
||||
// Try slug first
|
||||
let paywall = await prisma.paywall.findUnique({
|
||||
where: { slug: slugOrId },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!paywall) {
|
||||
// Try ID
|
||||
paywall = await prisma.paywall.findUnique({
|
||||
where: { id: slugOrId },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!paywall) {
|
||||
throw new AppError('Paywall not found', 404);
|
||||
}
|
||||
|
||||
if (paywall.status !== 'ACTIVE') {
|
||||
throw new AppError('Paywall not available', 404);
|
||||
}
|
||||
|
||||
return paywall;
|
||||
}
|
||||
|
||||
async listByCreator(userId, options = {}) {
|
||||
const { status, page = 1, limit = 20 } = options;
|
||||
|
||||
const where = { creatorId: userId };
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [paywalls, total] = await Promise.all([
|
||||
prisma.paywall.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
include: {
|
||||
_count: {
|
||||
select: { sales: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.paywall.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
paywalls,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async archive(userId, paywallId) {
|
||||
await this.findByIdAndCreator(paywallId, userId);
|
||||
|
||||
return prisma.paywall.update({
|
||||
where: { id: paywallId },
|
||||
data: { status: 'ARCHIVED' },
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(userId, paywallId = null) {
|
||||
const where = { creatorId: userId };
|
||||
if (paywallId) {
|
||||
where.paywallId = paywallId;
|
||||
}
|
||||
|
||||
const [totalSales, totalRevenue, recentSales] = await Promise.all([
|
||||
prisma.sale.count({ where }),
|
||||
prisma.sale.aggregate({
|
||||
where,
|
||||
_sum: { netSats: true },
|
||||
}),
|
||||
prisma.sale.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
paywall: {
|
||||
select: { title: true, slug: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Last 7 days stats
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const last7DaysRevenue = await prisma.sale.aggregate({
|
||||
where: {
|
||||
...where,
|
||||
createdAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
_sum: { netSats: true },
|
||||
});
|
||||
|
||||
// Last 30 days stats
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const last30DaysRevenue = await prisma.sale.aggregate({
|
||||
where: {
|
||||
...where,
|
||||
createdAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
_sum: { netSats: true },
|
||||
});
|
||||
|
||||
return {
|
||||
totalSales,
|
||||
totalRevenue: totalRevenue._sum.netSats || 0,
|
||||
last7DaysRevenue: last7DaysRevenue._sum.netSats || 0,
|
||||
last30DaysRevenue: last30DaysRevenue._sum.netSats || 0,
|
||||
recentSales,
|
||||
};
|
||||
}
|
||||
|
||||
async fetchUrlMetadata(url) {
|
||||
this.validateUrl(url);
|
||||
return fetchMetadata(url);
|
||||
}
|
||||
|
||||
validateUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new AppError('URL must use HTTPS', 400);
|
||||
}
|
||||
|
||||
// Block localhost and private IPs
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const blockedPatterns = [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'0.0.0.0',
|
||||
'10.',
|
||||
'172.16.',
|
||||
'172.17.',
|
||||
'172.18.',
|
||||
'172.19.',
|
||||
'172.20.',
|
||||
'172.21.',
|
||||
'172.22.',
|
||||
'172.23.',
|
||||
'172.24.',
|
||||
'172.25.',
|
||||
'172.26.',
|
||||
'172.27.',
|
||||
'172.28.',
|
||||
'172.29.',
|
||||
'172.30.',
|
||||
'172.31.',
|
||||
'192.168.',
|
||||
'169.254.',
|
||||
];
|
||||
|
||||
for (const pattern of blockedPatterns) {
|
||||
if (hostname.startsWith(pattern) || hostname === pattern) {
|
||||
throw new AppError('Private URLs are not allowed', 400);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Invalid URL format', 400);
|
||||
}
|
||||
}
|
||||
|
||||
detectUrlType(url) {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
|
||||
return 'YOUTUBE';
|
||||
}
|
||||
if (hostname.includes('notion.so') || hostname.includes('notion.site')) {
|
||||
return 'NOTION';
|
||||
}
|
||||
if (hostname.includes('loom.com')) {
|
||||
return 'LOOM';
|
||||
}
|
||||
if (hostname.includes('docs.google.com')) {
|
||||
return 'GDOCS';
|
||||
}
|
||||
if (hostname.includes('github.com')) {
|
||||
return 'GITHUB';
|
||||
}
|
||||
if (url.toLowerCase().endsWith('.pdf')) {
|
||||
return 'PDF';
|
||||
}
|
||||
|
||||
return 'URL';
|
||||
}
|
||||
}
|
||||
|
||||
export const paywallService = new PaywallService();
|
||||
export default paywallService;
|
||||
133
backend/src/utils/metadata.js
Normal file
133
backend/src/utils/metadata.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const TIMEOUT_MS = 5000;
|
||||
|
||||
export async function fetchMetadata(url) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'LNPaywall Bot/1.0',
|
||||
'Accept': 'text/html',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch URL: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Parse metadata from HTML
|
||||
const metadata = parseHtmlMetadata(html, url);
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.error('Metadata fetch error:', error.message);
|
||||
return {
|
||||
title: null,
|
||||
description: null,
|
||||
image: null,
|
||||
favicon: null,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseHtmlMetadata(html, baseUrl) {
|
||||
const metadata = {
|
||||
title: null,
|
||||
description: null,
|
||||
image: null,
|
||||
favicon: null,
|
||||
};
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
metadata.title = decodeHtmlEntities(titleMatch[1].trim());
|
||||
}
|
||||
|
||||
// Extract OG title (prefer over regular title)
|
||||
const ogTitleMatch = html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i) ||
|
||||
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:title["']/i);
|
||||
if (ogTitleMatch) {
|
||||
metadata.title = decodeHtmlEntities(ogTitleMatch[1].trim());
|
||||
}
|
||||
|
||||
// Extract description
|
||||
const descMatch = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i) ||
|
||||
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
||||
if (descMatch) {
|
||||
metadata.description = decodeHtmlEntities(descMatch[1].trim());
|
||||
}
|
||||
|
||||
// Extract OG description (prefer over regular description)
|
||||
const ogDescMatch = html.match(/<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']+)["']/i) ||
|
||||
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:description["']/i);
|
||||
if (ogDescMatch) {
|
||||
metadata.description = decodeHtmlEntities(ogDescMatch[1].trim());
|
||||
}
|
||||
|
||||
// Extract OG image
|
||||
const ogImageMatch = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i) ||
|
||||
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i);
|
||||
if (ogImageMatch) {
|
||||
metadata.image = resolveUrl(ogImageMatch[1], baseUrl);
|
||||
}
|
||||
|
||||
// Extract Twitter image as fallback
|
||||
if (!metadata.image) {
|
||||
const twitterImageMatch = html.match(/<meta[^>]*name=["']twitter:image["'][^>]*content=["']([^"']+)["']/i) ||
|
||||
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']twitter:image["']/i);
|
||||
if (twitterImageMatch) {
|
||||
metadata.image = resolveUrl(twitterImageMatch[1], baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract favicon
|
||||
const faviconMatch = html.match(/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']/i) ||
|
||||
html.match(/<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']/i);
|
||||
if (faviconMatch) {
|
||||
metadata.favicon = resolveUrl(faviconMatch[1], baseUrl);
|
||||
} else {
|
||||
// Default to /favicon.ico
|
||||
metadata.favicon = resolveUrl('/favicon.ico', baseUrl);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function resolveUrl(url, baseUrl) {
|
||||
try {
|
||||
return new URL(url, baseUrl).toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(text) {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
''': "'",
|
||||
'/': '/',
|
||||
};
|
||||
|
||||
return text.replace(/&[^;]+;/g, (entity) => {
|
||||
return entities[entity] || entity;
|
||||
});
|
||||
}
|
||||
|
||||
export default { fetchMetadata };
|
||||
|
||||
110
backend/src/utils/validation.js
Normal file
110
backend/src/utils/validation.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Auth schemas
|
||||
export const signupSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
displayName: z.string().min(1, 'Display name is required').max(100).optional(),
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export const nostrLoginSchema = z.object({
|
||||
pubkey: z.string().length(64, 'Invalid public key'),
|
||||
signature: z.string().optional(),
|
||||
challenge: z.string().optional(),
|
||||
event: z.object({
|
||||
id: z.string(),
|
||||
pubkey: z.string(),
|
||||
created_at: z.number(),
|
||||
kind: z.number(),
|
||||
tags: z.array(z.array(z.string())),
|
||||
content: z.string(),
|
||||
sig: z.string(),
|
||||
}).optional(),
|
||||
}).refine(data => (data.signature && data.challenge) || data.event, {
|
||||
message: 'Either signature+challenge or event is required',
|
||||
});
|
||||
|
||||
// Paywall schemas
|
||||
export const createPaywallSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
description: z.string().max(1000).optional(),
|
||||
coverImageUrl: z.string().url().optional().nullable(),
|
||||
originalUrl: z.string().url('Invalid URL'),
|
||||
previewMode: z.enum(['NONE', 'TEXT_PREVIEW', 'IMAGE_PREVIEW']).optional(),
|
||||
previewContent: z.string().max(5000).optional(),
|
||||
priceSats: z.number().int().min(1, 'Price must be at least 1 sat').max(100000000),
|
||||
accessExpirySeconds: z.number().int().positive().optional().nullable(),
|
||||
maxDevices: z.number().int().min(1).max(100).optional(),
|
||||
maxSessions: z.number().int().min(1).max(100).optional(),
|
||||
allowEmbed: z.boolean().optional(),
|
||||
allowedEmbedOrigins: z.array(z.string()).optional(),
|
||||
requireEmailReceipt: z.boolean().optional(),
|
||||
customSuccessMessage: z.string().max(500).optional(),
|
||||
customBranding: z.object({}).passthrough().optional(),
|
||||
slug: z.string().min(3).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens').optional(),
|
||||
});
|
||||
|
||||
export const updatePaywallSchema = createPaywallSchema.partial();
|
||||
|
||||
// Checkout schemas
|
||||
export const createCheckoutSchema = z.object({
|
||||
buyerEmail: z.string().email().optional(),
|
||||
});
|
||||
|
||||
export const verifyAccessSchema = z.object({
|
||||
token: z.string().min(1, 'Token is required'),
|
||||
paywallId: z.string().uuid().optional(),
|
||||
deviceFingerprint: z.string().optional(),
|
||||
});
|
||||
|
||||
// Helper function to validate and parse
|
||||
export function validate(schema, data) {
|
||||
return schema.parse(data);
|
||||
}
|
||||
|
||||
// Middleware factory
|
||||
export function validateBody(schema) {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
req.body = schema.parse(req.body);
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: error.errors.map(e => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function validateQuery(schema) {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
req.query = schema.parse(req.query);
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: error.errors.map(e => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user