- Add 100 blog posts covering AI, development, and tech topics - Add .env.example for environment configuration - Add accessibility and lighthouse audit scripts - Remove obsolete SEO reports and temporary files - Remove dev-dist build artifacts and backup files - Remove unused portrait images (moved/consolidated elsewhere) - Update contact form and component improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1231 lines
32 KiB
Markdown
1231 lines
32 KiB
Markdown
# Digital Product Delivery
|
||
|
||
**Meta-Description:** Digitale Produkte sicher ausliefern. Download-Links, License Keys, E-Books und Software mit Node.js und Next.js automatisiert bereitstellen.
|
||
|
||
**Keywords:** Digital Products, Download Links, License Keys, E-Book Delivery, Software Distribution, Signed URLs, Content Protection
|
||
|
||
---
|
||
|
||
## Einführung
|
||
|
||
**Digital Product Delivery** automatisiert die Auslieferung von Downloads, Lizenzen und digitalen Gütern nach dem Kauf. Von **signierten URLs** bis **License Key Generation** – sichere Delivery ist entscheidend für digitale Geschäftsmodelle. Dieser Guide zeigt Production-ready Implementierungen.
|
||
|
||
---
|
||
|
||
## Delivery Architecture
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ DIGITAL PRODUCT DELIVERY SYSTEM │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ PRODUCT TYPES │ │
|
||
│ ├─────────────────────────────────────────────────────┤ │
|
||
│ │ │ │
|
||
│ │ 📁 Downloadable Files 📋 License Keys │ │
|
||
│ │ ├── E-Books (PDF/EPUB) ├── Software Licenses │ │
|
||
│ │ ├── Audio/Video ├── API Access Keys │ │
|
||
│ │ ├── Software Packages └── Subscription Codes │ │
|
||
│ │ └── Templates/Assets │ │
|
||
│ │ │ │
|
||
│ │ 🔗 Access Grants 📧 Email Delivery │ │
|
||
│ │ ├── Course Access ├── Welcome Emails │ │
|
||
│ │ ├── Membership Areas ├── Download Links │ │
|
||
│ │ └── Private Content └── License Delivery │ │
|
||
│ │ │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ DELIVERY PIPELINE │ │
|
||
│ │ │ │
|
||
│ │ Payment ──► Fulfillment ──► Delivery ──► Access │ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ Webhook Generate Send Email Track │ │
|
||
│ │ Received Assets/Keys with Links Downloads │ │
|
||
│ │ │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Security Measures: │
|
||
│ ├── Signed URLs with expiration │
|
||
│ ├── Download limits per purchase │
|
||
│ ├── IP tracking and fraud detection │
|
||
│ └── License key validation │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Product & License Models
|
||
|
||
```typescript
|
||
// prisma/schema.prisma
|
||
model DigitalProduct {
|
||
id String @id @default(cuid())
|
||
name String
|
||
slug String @unique
|
||
description String?
|
||
type ProductType
|
||
price Int // cents
|
||
currency String @default("EUR")
|
||
|
||
// File-based products
|
||
fileKey String? // S3 key
|
||
fileName String?
|
||
fileSize Int?
|
||
fileMimeType String?
|
||
|
||
// License-based products
|
||
licenseType LicenseType?
|
||
maxActivations Int?
|
||
|
||
// Metadata
|
||
metadata Json?
|
||
active Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
purchases Purchase[]
|
||
licenses License[]
|
||
}
|
||
|
||
model Purchase {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
productId String
|
||
product DigitalProduct @relation(fields: [productId], references: [id])
|
||
|
||
// Payment info
|
||
stripePaymentId String?
|
||
amount Int
|
||
currency String
|
||
status PurchaseStatus @default(PENDING)
|
||
|
||
// Delivery tracking
|
||
deliveredAt DateTime?
|
||
downloadCount Int @default(0)
|
||
maxDownloads Int @default(5)
|
||
|
||
// Download links
|
||
downloads Download[]
|
||
licenses License[]
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
user User @relation(fields: [userId], references: [id])
|
||
}
|
||
|
||
model Download {
|
||
id String @id @default(cuid())
|
||
purchaseId String
|
||
purchase Purchase @relation(fields: [purchaseId], references: [id])
|
||
|
||
token String @unique
|
||
expiresAt DateTime
|
||
downloadedAt DateTime?
|
||
ipAddress String?
|
||
userAgent String?
|
||
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
model License {
|
||
id String @id @default(cuid())
|
||
purchaseId String
|
||
purchase Purchase @relation(fields: [purchaseId], references: [id])
|
||
productId String
|
||
product DigitalProduct @relation(fields: [productId], references: [id])
|
||
|
||
key String @unique
|
||
status LicenseStatus @default(ACTIVE)
|
||
activations Int @default(0)
|
||
maxActivations Int
|
||
|
||
// Activation tracking
|
||
activationRecords LicenseActivation[]
|
||
|
||
expiresAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
model LicenseActivation {
|
||
id String @id @default(cuid())
|
||
licenseId String
|
||
license License @relation(fields: [licenseId], references: [id])
|
||
|
||
machineId String
|
||
machineName String?
|
||
ipAddress String?
|
||
activatedAt DateTime @default(now())
|
||
deactivatedAt DateTime?
|
||
|
||
@@unique([licenseId, machineId])
|
||
}
|
||
|
||
enum ProductType {
|
||
DOWNLOAD // Downloadable file
|
||
LICENSE // Software license
|
||
SUBSCRIPTION // Recurring access
|
||
COURSE // Online course access
|
||
MEMBERSHIP // Membership access
|
||
}
|
||
|
||
enum LicenseType {
|
||
PERPETUAL // Forever
|
||
SUBSCRIPTION // Time-limited
|
||
TRIAL // Trial period
|
||
}
|
||
|
||
enum PurchaseStatus {
|
||
PENDING
|
||
COMPLETED
|
||
REFUNDED
|
||
FAILED
|
||
}
|
||
|
||
enum LicenseStatus {
|
||
ACTIVE
|
||
SUSPENDED
|
||
REVOKED
|
||
EXPIRED
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Signed URL Generation
|
||
|
||
```typescript
|
||
// lib/delivery/signed-urls.ts
|
||
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||
import crypto from 'crypto';
|
||
|
||
const s3 = new S3Client({
|
||
region: process.env.AWS_REGION!,
|
||
credentials: {
|
||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
|
||
}
|
||
});
|
||
|
||
interface SignedUrlOptions {
|
||
expiresIn?: number; // seconds
|
||
downloadFilename?: string;
|
||
}
|
||
|
||
export async function generateSignedDownloadUrl(
|
||
fileKey: string,
|
||
options: SignedUrlOptions = {}
|
||
): Promise<string> {
|
||
const { expiresIn = 3600, downloadFilename } = options;
|
||
|
||
const command = new GetObjectCommand({
|
||
Bucket: process.env.S3_BUCKET!,
|
||
Key: fileKey,
|
||
ResponseContentDisposition: downloadFilename
|
||
? `attachment; filename="${downloadFilename}"`
|
||
: undefined
|
||
});
|
||
|
||
return getSignedUrl(s3, command, { expiresIn });
|
||
}
|
||
|
||
// Custom signed URL for self-hosted files
|
||
export function generateCustomSignedUrl(
|
||
path: string,
|
||
expiresIn: number = 3600
|
||
): string {
|
||
const expires = Math.floor(Date.now() / 1000) + expiresIn;
|
||
const signature = crypto
|
||
.createHmac('sha256', process.env.DOWNLOAD_SECRET!)
|
||
.update(`${path}:${expires}`)
|
||
.digest('hex');
|
||
|
||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||
return `${baseUrl}/api/download/${path}?expires=${expires}&sig=${signature}`;
|
||
}
|
||
|
||
export function verifySignedUrl(
|
||
path: string,
|
||
expires: string,
|
||
signature: string
|
||
): boolean {
|
||
const expiresNum = parseInt(expires, 10);
|
||
|
||
// Check expiration
|
||
if (Date.now() / 1000 > expiresNum) {
|
||
return false;
|
||
}
|
||
|
||
// Verify signature
|
||
const expectedSig = crypto
|
||
.createHmac('sha256', process.env.DOWNLOAD_SECRET!)
|
||
.update(`${path}:${expiresNum}`)
|
||
.digest('hex');
|
||
|
||
return crypto.timingSafeEqual(
|
||
Buffer.from(signature),
|
||
Buffer.from(expectedSig)
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Download Token System
|
||
|
||
```typescript
|
||
// lib/delivery/download-tokens.ts
|
||
import { db } from '@/lib/db';
|
||
import crypto from 'crypto';
|
||
import { addHours } from 'date-fns';
|
||
|
||
interface CreateDownloadTokenResult {
|
||
token: string;
|
||
downloadUrl: string;
|
||
expiresAt: Date;
|
||
}
|
||
|
||
export async function createDownloadToken(
|
||
purchaseId: string,
|
||
expirationHours: number = 24
|
||
): Promise<CreateDownloadTokenResult> {
|
||
const purchase = await db.purchase.findUnique({
|
||
where: { id: purchaseId },
|
||
include: { product: true }
|
||
});
|
||
|
||
if (!purchase) {
|
||
throw new Error('Purchase not found');
|
||
}
|
||
|
||
if (purchase.downloadCount >= purchase.maxDownloads) {
|
||
throw new Error('Download limit exceeded');
|
||
}
|
||
|
||
const token = crypto.randomBytes(32).toString('hex');
|
||
const expiresAt = addHours(new Date(), expirationHours);
|
||
|
||
await db.download.create({
|
||
data: {
|
||
purchaseId,
|
||
token,
|
||
expiresAt
|
||
}
|
||
});
|
||
|
||
const downloadUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/download/${token}`;
|
||
|
||
return { token, downloadUrl, expiresAt };
|
||
}
|
||
|
||
export async function validateAndConsumeToken(
|
||
token: string,
|
||
ipAddress?: string,
|
||
userAgent?: string
|
||
): Promise<{ fileKey: string; fileName: string } | null> {
|
||
const download = await db.download.findUnique({
|
||
where: { token },
|
||
include: {
|
||
purchase: {
|
||
include: { product: true }
|
||
}
|
||
}
|
||
});
|
||
|
||
if (!download) {
|
||
return null;
|
||
}
|
||
|
||
// Check expiration
|
||
if (download.expiresAt < new Date()) {
|
||
return null;
|
||
}
|
||
|
||
// Check if already used
|
||
if (download.downloadedAt) {
|
||
return null;
|
||
}
|
||
|
||
// Check purchase download limits
|
||
if (download.purchase.downloadCount >= download.purchase.maxDownloads) {
|
||
return null;
|
||
}
|
||
|
||
// Mark as used and increment counter
|
||
await db.$transaction([
|
||
db.download.update({
|
||
where: { id: download.id },
|
||
data: {
|
||
downloadedAt: new Date(),
|
||
ipAddress,
|
||
userAgent
|
||
}
|
||
}),
|
||
db.purchase.update({
|
||
where: { id: download.purchaseId },
|
||
data: {
|
||
downloadCount: { increment: 1 }
|
||
}
|
||
})
|
||
]);
|
||
|
||
return {
|
||
fileKey: download.purchase.product.fileKey!,
|
||
fileName: download.purchase.product.fileName!
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## License Key Generation
|
||
|
||
```typescript
|
||
// lib/delivery/license-keys.ts
|
||
import crypto from 'crypto';
|
||
import { db } from '@/lib/db';
|
||
|
||
interface LicenseKeyOptions {
|
||
prefix?: string;
|
||
segments?: number;
|
||
segmentLength?: number;
|
||
}
|
||
|
||
export function generateLicenseKey(options: LicenseKeyOptions = {}): string {
|
||
const {
|
||
prefix = 'PRO',
|
||
segments = 4,
|
||
segmentLength = 5
|
||
} = options;
|
||
|
||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No I, O, 0, 1
|
||
const keySegments: string[] = [];
|
||
|
||
for (let i = 0; i < segments; i++) {
|
||
let segment = '';
|
||
for (let j = 0; j < segmentLength; j++) {
|
||
const randomIndex = crypto.randomInt(0, chars.length);
|
||
segment += chars[randomIndex];
|
||
}
|
||
keySegments.push(segment);
|
||
}
|
||
|
||
return `${prefix}-${keySegments.join('-')}`;
|
||
}
|
||
|
||
// Checksum-validated license key
|
||
export function generateValidatedLicenseKey(productCode: string): string {
|
||
const key = generateLicenseKey({ prefix: productCode });
|
||
const checksum = calculateChecksum(key);
|
||
return `${key}-${checksum}`;
|
||
}
|
||
|
||
function calculateChecksum(key: string): string {
|
||
const hash = crypto
|
||
.createHash('sha256')
|
||
.update(key + process.env.LICENSE_SECRET!)
|
||
.digest('hex');
|
||
return hash.substring(0, 4).toUpperCase();
|
||
}
|
||
|
||
export function validateLicenseKeyFormat(key: string): boolean {
|
||
// Format: XXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXX
|
||
const pattern = /^[A-Z]{2,4}-([A-Z0-9]{5}-){3,4}[A-Z0-9]{4}$/;
|
||
|
||
if (!pattern.test(key)) {
|
||
return false;
|
||
}
|
||
|
||
// Validate checksum
|
||
const parts = key.split('-');
|
||
const checksum = parts.pop()!;
|
||
const keyWithoutChecksum = parts.join('-');
|
||
|
||
return calculateChecksum(keyWithoutChecksum) === checksum;
|
||
}
|
||
|
||
// Create license in database
|
||
export async function createLicense(
|
||
purchaseId: string,
|
||
productId: string
|
||
): Promise<License> {
|
||
const product = await db.digitalProduct.findUnique({
|
||
where: { id: productId }
|
||
});
|
||
|
||
if (!product) {
|
||
throw new Error('Product not found');
|
||
}
|
||
|
||
const key = generateValidatedLicenseKey(
|
||
product.slug.substring(0, 3).toUpperCase()
|
||
);
|
||
|
||
return db.license.create({
|
||
data: {
|
||
purchaseId,
|
||
productId,
|
||
key,
|
||
maxActivations: product.maxActivations || 3,
|
||
expiresAt: product.licenseType === 'SUBSCRIPTION'
|
||
? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
|
||
: null
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## License Activation API
|
||
|
||
```typescript
|
||
// app/api/licenses/activate/route.ts
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { db } from '@/lib/db';
|
||
import { validateLicenseKeyFormat } from '@/lib/delivery/license-keys';
|
||
|
||
interface ActivationRequest {
|
||
licenseKey: string;
|
||
machineId: string;
|
||
machineName?: string;
|
||
}
|
||
|
||
export async function POST(request: NextRequest) {
|
||
try {
|
||
const body: ActivationRequest = await request.json();
|
||
const { licenseKey, machineId, machineName } = body;
|
||
|
||
// Validate format
|
||
if (!validateLicenseKeyFormat(licenseKey)) {
|
||
return NextResponse.json(
|
||
{ error: 'Invalid license key format' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// Find license
|
||
const license = await db.license.findUnique({
|
||
where: { key: licenseKey },
|
||
include: {
|
||
product: true,
|
||
activationRecords: true
|
||
}
|
||
});
|
||
|
||
if (!license) {
|
||
return NextResponse.json(
|
||
{ error: 'License not found' },
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
// Check status
|
||
if (license.status !== 'ACTIVE') {
|
||
return NextResponse.json(
|
||
{ error: `License is ${license.status.toLowerCase()}` },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
// Check expiration
|
||
if (license.expiresAt && license.expiresAt < new Date()) {
|
||
await db.license.update({
|
||
where: { id: license.id },
|
||
data: { status: 'EXPIRED' }
|
||
});
|
||
|
||
return NextResponse.json(
|
||
{ error: 'License has expired' },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
// Check if already activated on this machine
|
||
const existingActivation = license.activationRecords.find(
|
||
a => a.machineId === machineId && !a.deactivatedAt
|
||
);
|
||
|
||
if (existingActivation) {
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: 'Already activated on this machine',
|
||
license: formatLicenseResponse(license)
|
||
});
|
||
}
|
||
|
||
// Check activation limit
|
||
const activeActivations = license.activationRecords.filter(
|
||
a => !a.deactivatedAt
|
||
).length;
|
||
|
||
if (activeActivations >= license.maxActivations) {
|
||
return NextResponse.json(
|
||
{
|
||
error: 'Maximum activations reached',
|
||
maxActivations: license.maxActivations,
|
||
currentActivations: activeActivations
|
||
},
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
// Create activation
|
||
const ipAddress = request.headers.get('x-forwarded-for') ||
|
||
request.headers.get('x-real-ip');
|
||
|
||
await db.$transaction([
|
||
db.licenseActivation.create({
|
||
data: {
|
||
licenseId: license.id,
|
||
machineId,
|
||
machineName,
|
||
ipAddress
|
||
}
|
||
}),
|
||
db.license.update({
|
||
where: { id: license.id },
|
||
data: { activations: { increment: 1 } }
|
||
})
|
||
]);
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: 'License activated successfully',
|
||
license: formatLicenseResponse(license)
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Activation error:', error);
|
||
return NextResponse.json(
|
||
{ error: 'Activation failed' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
function formatLicenseResponse(license: any) {
|
||
return {
|
||
productName: license.product.name,
|
||
licenseType: license.product.licenseType,
|
||
expiresAt: license.expiresAt,
|
||
features: license.product.metadata?.features || []
|
||
};
|
||
}
|
||
|
||
// Deactivation endpoint
|
||
export async function DELETE(request: NextRequest) {
|
||
const { searchParams } = new URL(request.url);
|
||
const licenseKey = searchParams.get('key');
|
||
const machineId = searchParams.get('machineId');
|
||
|
||
if (!licenseKey || !machineId) {
|
||
return NextResponse.json(
|
||
{ error: 'License key and machine ID required' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const license = await db.license.findUnique({
|
||
where: { key: licenseKey }
|
||
});
|
||
|
||
if (!license) {
|
||
return NextResponse.json(
|
||
{ error: 'License not found' },
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
const activation = await db.licenseActivation.findUnique({
|
||
where: {
|
||
licenseId_machineId: {
|
||
licenseId: license.id,
|
||
machineId
|
||
}
|
||
}
|
||
});
|
||
|
||
if (!activation || activation.deactivatedAt) {
|
||
return NextResponse.json(
|
||
{ error: 'No active activation found' },
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
await db.$transaction([
|
||
db.licenseActivation.update({
|
||
where: { id: activation.id },
|
||
data: { deactivatedAt: new Date() }
|
||
}),
|
||
db.license.update({
|
||
where: { id: license.id },
|
||
data: { activations: { decrement: 1 } }
|
||
})
|
||
]);
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: 'License deactivated successfully'
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Fulfillment Service
|
||
|
||
```typescript
|
||
// lib/delivery/fulfillment.ts
|
||
import { db } from '@/lib/db';
|
||
import { createDownloadToken } from './download-tokens';
|
||
import { createLicense } from './license-keys';
|
||
import { sendEmail } from '@/lib/email';
|
||
|
||
interface FulfillmentResult {
|
||
success: boolean;
|
||
downloadUrl?: string;
|
||
licenseKey?: string;
|
||
error?: string;
|
||
}
|
||
|
||
export async function fulfillPurchase(
|
||
purchaseId: string
|
||
): Promise<FulfillmentResult> {
|
||
const purchase = await db.purchase.findUnique({
|
||
where: { id: purchaseId },
|
||
include: {
|
||
product: true,
|
||
user: true
|
||
}
|
||
});
|
||
|
||
if (!purchase) {
|
||
return { success: false, error: 'Purchase not found' };
|
||
}
|
||
|
||
if (purchase.status !== 'COMPLETED') {
|
||
return { success: false, error: 'Purchase not completed' };
|
||
}
|
||
|
||
if (purchase.deliveredAt) {
|
||
return { success: false, error: 'Already delivered' };
|
||
}
|
||
|
||
try {
|
||
let downloadUrl: string | undefined;
|
||
let licenseKey: string | undefined;
|
||
|
||
const { product } = purchase;
|
||
|
||
// Handle different product types
|
||
switch (product.type) {
|
||
case 'DOWNLOAD':
|
||
const downloadResult = await createDownloadToken(purchaseId);
|
||
downloadUrl = downloadResult.downloadUrl;
|
||
break;
|
||
|
||
case 'LICENSE':
|
||
const license = await createLicense(purchaseId, product.id);
|
||
licenseKey = license.key;
|
||
break;
|
||
|
||
case 'SUBSCRIPTION':
|
||
case 'MEMBERSHIP':
|
||
// Grant access in database
|
||
await grantAccess(purchase.userId, product.id);
|
||
break;
|
||
|
||
case 'COURSE':
|
||
await enrollInCourse(purchase.userId, product.id);
|
||
break;
|
||
}
|
||
|
||
// Mark as delivered
|
||
await db.purchase.update({
|
||
where: { id: purchaseId },
|
||
data: { deliveredAt: new Date() }
|
||
});
|
||
|
||
// Send delivery email
|
||
await sendDeliveryEmail(purchase, downloadUrl, licenseKey);
|
||
|
||
return { success: true, downloadUrl, licenseKey };
|
||
|
||
} catch (error) {
|
||
console.error('Fulfillment error:', error);
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Fulfillment failed'
|
||
};
|
||
}
|
||
}
|
||
|
||
async function grantAccess(userId: string, productId: string): Promise<void> {
|
||
await db.userAccess.create({
|
||
data: {
|
||
userId,
|
||
productId,
|
||
grantedAt: new Date()
|
||
}
|
||
});
|
||
}
|
||
|
||
async function enrollInCourse(userId: string, productId: string): Promise<void> {
|
||
const product = await db.digitalProduct.findUnique({
|
||
where: { id: productId }
|
||
});
|
||
|
||
const courseId = product?.metadata?.courseId;
|
||
if (!courseId) {
|
||
throw new Error('Course ID not found in product metadata');
|
||
}
|
||
|
||
await db.courseEnrollment.create({
|
||
data: {
|
||
userId,
|
||
courseId,
|
||
enrolledAt: new Date()
|
||
}
|
||
});
|
||
}
|
||
|
||
async function sendDeliveryEmail(
|
||
purchase: any,
|
||
downloadUrl?: string,
|
||
licenseKey?: string
|
||
): Promise<void> {
|
||
const { user, product } = purchase;
|
||
|
||
await sendEmail({
|
||
to: user.email,
|
||
template: 'product-delivery',
|
||
data: {
|
||
userName: user.name,
|
||
productName: product.name,
|
||
productType: product.type,
|
||
downloadUrl,
|
||
licenseKey,
|
||
supportEmail: 'support@example.com'
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Download API Endpoint
|
||
|
||
```typescript
|
||
// app/api/download/[token]/route.ts
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
||
import { validateAndConsumeToken } from '@/lib/delivery/download-tokens';
|
||
|
||
const s3 = new S3Client({
|
||
region: process.env.AWS_REGION!,
|
||
credentials: {
|
||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
|
||
}
|
||
});
|
||
|
||
export async function GET(
|
||
request: NextRequest,
|
||
{ params }: { params: { token: string } }
|
||
) {
|
||
const { token } = params;
|
||
|
||
const ipAddress = request.headers.get('x-forwarded-for') ||
|
||
request.headers.get('x-real-ip') ||
|
||
undefined;
|
||
const userAgent = request.headers.get('user-agent') || undefined;
|
||
|
||
// Validate and consume token
|
||
const result = await validateAndConsumeToken(token, ipAddress, userAgent);
|
||
|
||
if (!result) {
|
||
return NextResponse.json(
|
||
{ error: 'Invalid or expired download link' },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
try {
|
||
// Fetch file from S3
|
||
const command = new GetObjectCommand({
|
||
Bucket: process.env.S3_BUCKET!,
|
||
Key: result.fileKey
|
||
});
|
||
|
||
const s3Response = await s3.send(command);
|
||
|
||
if (!s3Response.Body) {
|
||
throw new Error('File not found');
|
||
}
|
||
|
||
// Stream the file
|
||
const stream = s3Response.Body as ReadableStream;
|
||
|
||
return new NextResponse(stream, {
|
||
headers: {
|
||
'Content-Type': s3Response.ContentType || 'application/octet-stream',
|
||
'Content-Disposition': `attachment; filename="${result.fileName}"`,
|
||
'Content-Length': s3Response.ContentLength?.toString() || '',
|
||
'Cache-Control': 'no-store'
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Download error:', error);
|
||
return NextResponse.json(
|
||
{ error: 'Download failed' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Stripe Webhook Integration
|
||
|
||
```typescript
|
||
// app/api/webhooks/stripe/route.ts
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import Stripe from 'stripe';
|
||
import { db } from '@/lib/db';
|
||
import { fulfillPurchase } from '@/lib/delivery/fulfillment';
|
||
|
||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||
|
||
export async function POST(request: NextRequest) {
|
||
const body = await request.text();
|
||
const signature = request.headers.get('stripe-signature')!;
|
||
|
||
let event: Stripe.Event;
|
||
|
||
try {
|
||
event = stripe.webhooks.constructEvent(
|
||
body,
|
||
signature,
|
||
process.env.STRIPE_WEBHOOK_SECRET!
|
||
);
|
||
} catch (error) {
|
||
return NextResponse.json(
|
||
{ error: 'Invalid signature' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
switch (event.type) {
|
||
case 'checkout.session.completed':
|
||
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
|
||
break;
|
||
|
||
case 'payment_intent.succeeded':
|
||
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
|
||
break;
|
||
|
||
case 'charge.refunded':
|
||
await handleRefund(event.data.object as Stripe.Charge);
|
||
break;
|
||
}
|
||
|
||
return NextResponse.json({ received: true });
|
||
}
|
||
|
||
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
|
||
const purchaseId = session.metadata?.purchaseId;
|
||
|
||
if (!purchaseId) {
|
||
console.error('No purchase ID in session metadata');
|
||
return;
|
||
}
|
||
|
||
// Update purchase status
|
||
await db.purchase.update({
|
||
where: { id: purchaseId },
|
||
data: {
|
||
status: 'COMPLETED',
|
||
stripePaymentId: session.payment_intent as string
|
||
}
|
||
});
|
||
|
||
// Fulfill the purchase
|
||
const result = await fulfillPurchase(purchaseId);
|
||
|
||
if (!result.success) {
|
||
console.error('Fulfillment failed:', result.error);
|
||
// Alert for manual intervention
|
||
await alertFulfillmentFailure(purchaseId, result.error);
|
||
}
|
||
}
|
||
|
||
async function handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
|
||
// Handle one-time payments
|
||
const purchase = await db.purchase.findFirst({
|
||
where: { stripePaymentId: paymentIntent.id }
|
||
});
|
||
|
||
if (purchase && purchase.status !== 'COMPLETED') {
|
||
await db.purchase.update({
|
||
where: { id: purchase.id },
|
||
data: { status: 'COMPLETED' }
|
||
});
|
||
|
||
await fulfillPurchase(purchase.id);
|
||
}
|
||
}
|
||
|
||
async function handleRefund(charge: Stripe.Charge) {
|
||
const purchase = await db.purchase.findFirst({
|
||
where: { stripePaymentId: charge.payment_intent as string }
|
||
});
|
||
|
||
if (purchase) {
|
||
await db.purchase.update({
|
||
where: { id: purchase.id },
|
||
data: { status: 'REFUNDED' }
|
||
});
|
||
|
||
// Revoke licenses
|
||
await db.license.updateMany({
|
||
where: { purchaseId: purchase.id },
|
||
data: { status: 'REVOKED' }
|
||
});
|
||
|
||
// Revoke access
|
||
await db.userAccess.deleteMany({
|
||
where: {
|
||
userId: purchase.userId,
|
||
productId: purchase.productId
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async function alertFulfillmentFailure(purchaseId: string, error?: string) {
|
||
// Send alert to admin
|
||
await sendEmail({
|
||
to: process.env.ADMIN_EMAIL!,
|
||
template: 'fulfillment-failure',
|
||
data: { purchaseId, error }
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Customer Download Page
|
||
|
||
```typescript
|
||
// app/purchases/[id]/page.tsx
|
||
import { db } from '@/lib/db';
|
||
import { auth } from '@/lib/auth';
|
||
import { createDownloadToken } from '@/lib/delivery/download-tokens';
|
||
import { redirect, notFound } from 'next/navigation';
|
||
|
||
interface Props {
|
||
params: { id: string };
|
||
}
|
||
|
||
export default async function PurchasePage({ params }: Props) {
|
||
const session = await auth();
|
||
if (!session?.user) redirect('/login');
|
||
|
||
const purchase = await db.purchase.findUnique({
|
||
where: {
|
||
id: params.id,
|
||
userId: session.user.id
|
||
},
|
||
include: {
|
||
product: true,
|
||
licenses: true,
|
||
downloads: {
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 5
|
||
}
|
||
}
|
||
});
|
||
|
||
if (!purchase) notFound();
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto p-6">
|
||
<h1 className="text-2xl font-bold mb-6">{purchase.product.name}</h1>
|
||
|
||
<div className="bg-white rounded-lg shadow p-6 space-y-6">
|
||
{/* Purchase Info */}
|
||
<div className="border-b pb-4">
|
||
<p className="text-sm text-gray-500">Purchase Date</p>
|
||
<p>{new Date(purchase.createdAt).toLocaleDateString()}</p>
|
||
</div>
|
||
|
||
{/* Download Section */}
|
||
{purchase.product.type === 'DOWNLOAD' && (
|
||
<DownloadSection purchase={purchase} />
|
||
)}
|
||
|
||
{/* License Section */}
|
||
{purchase.product.type === 'LICENSE' && (
|
||
<LicenseSection licenses={purchase.licenses} />
|
||
)}
|
||
|
||
{/* Access Section */}
|
||
{['SUBSCRIPTION', 'MEMBERSHIP', 'COURSE'].includes(purchase.product.type) && (
|
||
<AccessSection product={purchase.product} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DownloadSection({ purchase }: { purchase: any }) {
|
||
const remainingDownloads = purchase.maxDownloads - purchase.downloadCount;
|
||
|
||
return (
|
||
<div>
|
||
<h2 className="font-semibold mb-2">Download</h2>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
{remainingDownloads} downloads remaining
|
||
</p>
|
||
|
||
{remainingDownloads > 0 ? (
|
||
<form action={generateNewDownload}>
|
||
<input type="hidden" name="purchaseId" value={purchase.id} />
|
||
<button
|
||
type="submit"
|
||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||
>
|
||
Generate Download Link
|
||
</button>
|
||
</form>
|
||
) : (
|
||
<p className="text-red-600">Download limit reached. Contact support.</p>
|
||
)}
|
||
|
||
{/* Recent Downloads */}
|
||
{purchase.downloads.length > 0 && (
|
||
<div className="mt-4">
|
||
<h3 className="text-sm font-medium mb-2">Recent Downloads</h3>
|
||
<ul className="text-sm text-gray-600 space-y-1">
|
||
{purchase.downloads.map((d: any) => (
|
||
<li key={d.id}>
|
||
{new Date(d.createdAt).toLocaleString()} -
|
||
{d.downloadedAt ? ' Downloaded' : ' Pending'}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LicenseSection({ licenses }: { licenses: any[] }) {
|
||
return (
|
||
<div>
|
||
<h2 className="font-semibold mb-2">License Keys</h2>
|
||
{licenses.map(license => (
|
||
<div key={license.id} className="bg-gray-50 p-4 rounded mb-2">
|
||
<code className="text-lg font-mono">{license.key}</code>
|
||
<div className="text-sm text-gray-600 mt-2">
|
||
<p>Status: {license.status}</p>
|
||
<p>Activations: {license.activations} / {license.maxActivations}</p>
|
||
{license.expiresAt && (
|
||
<p>Expires: {new Date(license.expiresAt).toLocaleDateString()}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccessSection({ product }: { product: any }) {
|
||
const accessUrl = getAccessUrl(product);
|
||
|
||
return (
|
||
<div>
|
||
<h2 className="font-semibold mb-2">Access</h2>
|
||
<a
|
||
href={accessUrl}
|
||
className="inline-block bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
|
||
>
|
||
Access {product.name}
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
async function generateNewDownload(formData: FormData) {
|
||
'use server';
|
||
|
||
const purchaseId = formData.get('purchaseId') as string;
|
||
const result = await createDownloadToken(purchaseId);
|
||
|
||
redirect(result.downloadUrl);
|
||
}
|
||
|
||
function getAccessUrl(product: any): string {
|
||
switch (product.type) {
|
||
case 'COURSE':
|
||
return `/courses/${product.metadata?.courseSlug}`;
|
||
case 'MEMBERSHIP':
|
||
return `/members`;
|
||
default:
|
||
return `/dashboard`;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Best Practices
|
||
|
||
| Aspect | Recommendation |
|
||
|--------|----------------|
|
||
| **URLs** | Signed, time-limited, single-use |
|
||
| **Limits** | 3-5 downloads per purchase |
|
||
| **Licenses** | Checksum validation, activation limits |
|
||
| **Email** | Immediate delivery confirmation |
|
||
| **Refunds** | Auto-revoke access and licenses |
|
||
| **Monitoring** | Track failed deliveries, alert admins |
|
||
|
||
---
|
||
|
||
## Fazit
|
||
|
||
Digital Product Delivery erfordert:
|
||
|
||
1. **Security**: Signierte URLs, Token, validierte Keys
|
||
2. **Automation**: Webhook-triggered Fulfillment
|
||
3. **Limits**: Download-Counts, Activation-Limits
|
||
4. **UX**: Klare Download-Pages, License-Management
|
||
|
||
Sichere Delivery schützt digitale Produkte und sorgt für gute Customer Experience.
|
||
|
||
---
|
||
|
||
## Bildprompts
|
||
|
||
1. "Digital product delivery flow diagram, download and license paths"
|
||
2. "License key activation interface, software registration screen"
|
||
3. "E-commerce download page, purchase confirmation with download button"
|
||
|
||
---
|
||
|
||
## Quellen
|
||
|
||
- [AWS S3 Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html)
|
||
- [Stripe Fulfillment](https://stripe.com/docs/payments/checkout/fulfill-orders)
|
||
- [Software Licensing Best Practices](https://www.keygen.sh/docs/guides)
|
||
- [Gumroad Digital Products](https://gumroad.com/features/digital-products)
|