Initial commit

This commit is contained in:
Michilis
2025-12-14 23:08:45 -03:00
commit 1e1753dff3
58 changed files with 18294 additions and 0 deletions

55
backend/env.example Normal file
View 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

File diff suppressed because it is too large Load Diff

39
backend/package.json Normal file
View 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"
}
}

View 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
View 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();
});

View 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
View 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;

View 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();
};

View 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';
}
}

View 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();
};

View 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,
};
})();

View 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
View 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
View 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;

View 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;

View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export default router;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
'&#x27;': "'",
'&#x2F;': '/',
};
return text.replace(/&[^;]+;/g, (entity) => {
return entities[entity] || entity;
});
}
export default { fetchMetadata };

View 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);
}
};
}