Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
807623d
feat(web): complete artwork configuration system for DAO creation
dan13ram Sep 27, 2026
78a6406
fix(auth): add IP detection fallbacks for local development
dan13ram Sep 27, 2026
86c395a
fix(web): restore DAO image preview
dan13ram Sep 27, 2026
f4de2a2
feat: add legal pages and consolidate footer/header components
dan13ram Sep 28, 2026
5ac9615
feat: add network indicator component and restore network display in …
dan13ram Sep 28, 2026
30e4de1
fix: resolve all linting errors and warnings with proper architectura…
dan13ram Sep 28, 2026
be1497d
fix(typecheck): resolve all TypeScript compilation errors
dan13ram Sep 28, 2026
1618478
feat(artwork-source): hide upload option when NEXT_PUBLIC_PINATA_UPLO…
dan13ram Sep 28, 2026
33ee07c
feat(artwork-playground): enable layer reordering for starter collect…
dan13ram Sep 28, 2026
349cd48
feat(create-dao): support external image URLs
dan13ram Sep 28, 2026
4f980ae
feat(create-dao): accept IPFS image CIDs
dan13ram Sep 28, 2026
ed4c1f8
feat(web): integrate gateway fallback into artwork preview
dan13ram Sep 28, 2026
8c4a259
fix: prettier formatting in image-loader.ts
dan13ram Sep 28, 2026
4b8d7ed
feat(web): create useArtworkPreview hook and refactor ArtworkPreviewC…
dan13ram Sep 28, 2026
a651bb7
feat(web): add IPFS client utilities and useFallbackSrc hook
dan13ram Sep 28, 2026
d906a6c
feat(web): improve artwork and image loading
dan13ram Sep 28, 2026
c671370
fix(pinata): use signed upload endpoint correctly
dan13ram Sep 28, 2026
9975083
fix(uploads): make authorization portable
dan13ram Sep 28, 2026
39bdffc
fix(uploads): validate MIME types and CIDs correctly
dan13ram Sep 28, 2026
43bd5f8
fix(create-dao): use fallback image preview
dan13ram Sep 28, 2026
91cf38b
feat(create-dao): add image loading skeleton
dan13ram Sep 28, 2026
771a3b3
fix(create-dao): repair artwork directory uploads
dan13ram Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,42 @@ STELLAR_WEB_AUTH_DOMAIN=localhost

# Optional display name used in the wallet signing prompt.
AUTH_APP_NAME=Stellar DAOs

# DAO Artwork Generation (optional, enables image generation features)
# Set to 'true' to enable AI-powered artwork generation for DAO images
# Used by: DaoImageField (client), /api/artwork/generate (server)
NEXT_PUBLIC_IMAGE_GENERATION_ENABLED=false

# Batch size for AI image generation (number of candidates to generate)
# Default: 4, adjust based on model and API quotas
# Server-side only, used by /api/artwork/generate
GENERATION_BATCH_SIZE=4

# Vercel AI Gateway API key for image generation
# Required when NEXT_PUBLIC_IMAGE_GENERATION_ENABLED=true
# Server-side only, never expose publicly
AI_GATEWAY_API_KEY=

# Image model to use for generation (must be supported by Vercel AI Gateway)
# Example: openai:dall-e-3
# Server-side only, used by /api/artwork/generate
IMAGE_MODEL=openai:dall-e-3

# Pinata Upload Configuration (optional, enables direct IPFS uploads)
# Set to 'true' to enable artwork directory uploads
# Used by: DaoImageField (client), /api/uploads/* (server)
NEXT_PUBLIC_PINATA_UPLOADS_ENABLED=false

# Pinata JWT for upload authorization
# Required when NEXT_PUBLIC_PINATA_UPLOADS_ENABLED=true
# Can be either:
# - A standard JWT with pinFileToIPFS permission
# - An API key that can generate restricted upload tokens
# Used by: /api/pinata/generate-jwt (server), /api/uploads/pinata-url (server)
# Server-side only, never expose publicly
PINATA_JWT=

# Preferred Pinata IPFS gateway host for resolver URLs
# Falls back to built-in gateway list if unavailable
# Available to both client and server (NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_PINATA_GATEWAY=nouns-builder.mypinata.cloud
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"panda:codegen": "panda codegen && panda cssgen"
},
"dependencies": {
"@ai-sdk/gateway": "^4.0.96",
"@builder-stellar/auction-bindings": "workspace:*",
"@builder-stellar/governor-bindings": "workspace:*",
"@builder-stellar/manager-bindings": "workspace:*",
Expand All @@ -26,7 +27,9 @@
"@neondatabase/serverless": "^1.1.0",
"@noble/hashes": "^2.2.0",
"@stellar/stellar-sdk": "^17.0.1",
"ai": "^4.0.0",
"buffer": "^6.0.3",
"framer-motion": "^13.4.4",
"iron-session": "^8.0.4",
"next": "^15.4.0",
"papaparse": "^5.7.0",
Expand Down
Binary file added apps/web/public/images/dao-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
171 changes: 171 additions & 0 deletions apps/web/src/app/api/artwork/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { NextRequest, NextResponse } from 'next/server';

import { generateDaoImageCandidates, GenerateDaoImageInputSchema } from '@/lib/ai-image-generation';
import { AuthError, authErrorResponse, requireAuthenticatedSession } from '@/lib/auth/server';

/**
* Simple in-memory rate limiter for MVP
* TODO: Replace with Redis-backed distributed rate limiter
*/
const generationLimiter = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(
key: string,
maxPerHour: number = 3,
_maxConcurrent: number = 1
): { allowed: boolean; reason?: string } {
const now = Date.now();
const limit = generationLimiter.get(key);

if (!limit || limit.resetAt < now) {
// Reset window
generationLimiter.set(key, { count: 1, resetAt: now + 60 * 60 * 1000 });
return { allowed: true };
}

if (limit.count >= maxPerHour) {
return {
allowed: false,
reason: `Rate limit exceeded. Maximum ${maxPerHour} requests per hour.`
};
}

limit.count++;
return { allowed: true };
}

/**
* Simple CSRF token validation
* In production, consider using a proper CSRF library
*/
function validateCsrfToken(request: NextRequest): boolean {
// Get CSRF token from header
const csrfToken = request.headers.get('x-csrf-token');

// Verify it's a same-origin request
const origin = request.headers.get('origin');
const requestUrl = new URL(request.url);

if (origin && new URL(origin).origin !== requestUrl.origin) {
return false;
}

// For MVP, just require the token header to be present
// TODO: Implement proper stateful CSRF token validation
return !!csrfToken;
}

/**
* POST /api/artwork/generate
*
* Generates DAO identity image candidates using AI.
*
* Request body:
* {
* name: string (1-100 chars)
* description: string (1-500 chars)
* artDirection?: string (max 600 chars)
* stylePreset?: 'modern' | 'vintage' | 'abstract' | 'minimal' | 'vibrant'
* }
*
* Response:
* {
* candidates: [
* {
* id: string
* temporaryUrl: string
* expiresAt: ISO8601 timestamp
* model: string
* revisedPrompt?: string
* }
* ]
* }
*
* Error responses:
* - 401: Unauthenticated
* - 403: CSRF validation failed
* - 404: Feature disabled
* - 422: Invalid input or rate limit exceeded
* - 500: Generation service error
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
// Check if feature is enabled
if (process.env.NEXT_PUBLIC_IMAGE_GENERATION_ENABLED !== 'true') {
return NextResponse.json({ error: 'Image generation is not enabled', code: 'FEATURE_DISABLED' }, { status: 404 });
}

// Validate CSRF token
if (!validateCsrfToken(request)) {
return NextResponse.json({ error: 'CSRF validation failed', code: 'CSRF_INVALID' }, { status: 403 });
}

// Check authentication
const session = await requireAuthenticatedSession();

// Apply rate limiting
// Limit by both wallet address and IP
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const rateLimitKey = `gen:${session.address}:${clientIp}`;
const rateLimit = checkRateLimit(rateLimitKey);

if (!rateLimit.allowed) {
return NextResponse.json({ error: rateLimit.reason, code: 'RATE_LIMIT_EXCEEDED' }, { status: 429 });
}

// Parse and validate request body
const body = await request.json().catch(() => ({}));

const validationResult = GenerateDaoImageInputSchema.safeParse(body);
if (!validationResult.success) {
return NextResponse.json(
{
error: 'Invalid input',
code: 'VALIDATION_ERROR',
details: validationResult.error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message
}))
},
{ status: 422 }
);
}

const input = validationResult.data;

// Generate candidates
const candidates = await generateDaoImageCandidates(input);

return NextResponse.json(
{
candidates: candidates.map((c) => ({
id: c.id,
temporaryUrl: c.temporaryUrl,
expiresAt: c.expiresAt.toISOString(),
model: c.model,
revisedPrompt: c.revisedPrompt
}))
},
{ status: 200 }
);
} catch (error) {
if (error instanceof AuthError) {
return authErrorResponse(error);
}

if (error instanceof SyntaxError) {
return NextResponse.json({ error: 'Invalid JSON in request body', code: 'JSON_PARSE_ERROR' }, { status: 422 });
}

// Log error server-side only
console.error('[/api/artwork/generate]', error);

return NextResponse.json(
{
error: 'Image generation failed. Please try again later.',
code: 'GENERATION_ERROR'
},
{ status: 500 }
);
}
}
141 changes: 141 additions & 0 deletions apps/web/src/app/api/pinata/generate-jwt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';

import { AuthError, authErrorResponse, requireAuthenticatedSession } from '@/lib/auth/server';
import { AuthenticationError, getPinataService, PinataError } from '@/lib/pinata-service';

/**
* Simple in-memory rate limiter for JWT generation
* Rate limit: 20 requests per 60 seconds per user (generous to avoid blocking legitimate use)
*/
const jwtLimiter = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(key: string, maxPerMinute: number = 20): { allowed: boolean; reason?: string } {
const now = Date.now();
const limit = jwtLimiter.get(key);

if (!limit || limit.resetAt < now) {
// Reset window
jwtLimiter.set(key, { count: 1, resetAt: now + 60 * 1000 });
return { allowed: true };
}

if (limit.count >= maxPerMinute) {
return {
allowed: false,
reason: `Rate limit exceeded. Maximum ${maxPerMinute} requests per minute.`
};
}

limit.count++;
return { allowed: true };
}

/**
* POST /api/pinata/generate-jwt
*
* Generates a single-use JWT for directory uploads to Pinata's legacy endpoint.
* The JWT has restricted permissions (pinFileToIPFS only) and expires after one use.
*
* Response:
* {
* jwt: string (JWT token for Pinata API)
* expiresAt: ISO8601 timestamp
* }
*
* Error responses:
* - 401: Unauthenticated
* - 429: Rate limit exceeded
* - 500: Service error
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
// Check authentication
const session = await requireAuthenticatedSession();

// Apply rate limiting
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const rateLimitKey = `jwt:${session.address}:${clientIp}`;
const rateLimit = checkRateLimit(rateLimitKey);

if (!rateLimit.allowed) {
return NextResponse.json(
{
error: rateLimit.reason,
code: 'RATE_LIMIT_EXCEEDED',
retryAfter: 60
},
{ status: 429, headers: { 'Retry-After': '60' } }
);
}

// Generate JWT from Pinata service
const pinataService = getPinataService();
let jwt: string;
try {
jwt = await pinataService.generateUploadJwt();
} catch (jwtError) {
console.error('[/api/pinata/generate-jwt] Failed to generate JWT:', jwtError);
if (jwtError instanceof AuthenticationError) {
return NextResponse.json(
{
error: 'IPFS service authentication failed. The server may not be configured correctly.',
code: 'SERVICE_AUTH_FAILED',
details: process.env.NODE_ENV === 'development' ? jwtError.message : undefined
},
{ status: 500 }
);
}
throw jwtError;
}

if (!jwt) {
throw new Error('JWT generation returned empty token');
}

// JWT typically expires after one use or within a reasonable time window
// Set expiry to 1 hour from now as a safety margin
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);

return NextResponse.json(
{
jwt,
expiresAt: expiresAt.toISOString()
},
{ status: 200 }
);
} catch (error) {
if (error instanceof AuthError) {
return authErrorResponse(error);
}

if (error instanceof PinataError) {
console.error('[/api/pinata/generate-jwt] Pinata service error:', {
code: error.code,
message: error.message,
status: error.status
});
return NextResponse.json(
{
error: error.message || 'Failed to generate upload token',
code: error.code || 'SERVICE_ERROR',
status: error.status
},
{ status: error.status || 500 }
);
}

console.error('[/api/pinata/generate-jwt] Unexpected error:', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});

return NextResponse.json(
{
error: 'Failed to generate upload token. Please try again later.',
code: 'INTERNAL_SERVER_ERROR',
retryable: true
},
{ status: 500 }
);
}
}
Loading