Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ RUN npm install -g pnpm && pnpm install
# Last supported deno version for netlify is 2.2.4
RUN npm install -g deno@2.2.4

RUN npm install -g sst
RUN npm install -g sst@3.17.14

RUN echo "LC_ALL=en_US.UTF-8" >> /etc/environment
RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen
Expand Down
72 changes: 72 additions & 0 deletions app/api/slugs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireUserId } from '~/auth/session';
import { generateDefaultSlug, generateUniqueSlug, isValidSlug } from '~/utils/slug';
import { prisma } from '~/lib/prisma';

export async function POST(request: NextRequest) {
try {
await requireUserId(request);

const { baseSlug, chatId, siteName, existingSlug } = await request.json<any>(); // TODO: type
Copy link
Contributor

@bears4barrett bears4barrett Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth handling this TODO and any now? Or do you expect this in a future PR?


// Validate input
if (!baseSlug && !chatId) {
return NextResponse.json({ error: 'Either baseSlug or chatId is required' }, { status: 400 });
}

// Generate base slug if not provided
const slugToUse = baseSlug || generateDefaultSlug(chatId, siteName);

// Validate the base slug
if (!isValidSlug(slugToUse)) {
return NextResponse.json({ error: 'Invalid slug format' }, { status: 400 });
}

// Generate unique slug
const uniqueSlug = await generateUniqueSlug(slugToUse, existingSlug);

return NextResponse.json({ slug: uniqueSlug });
} catch (error) {
console.error('Error generating slug:', error);
return NextResponse.json({ error: 'Failed to generate slug' }, { status: 500 });
}
}

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const slug = searchParams.get('slug');

if (!slug) {
return NextResponse.json({ error: 'Slug parameter is required' }, { status: 400 });
}

// Validate slug format
const isValid = isValidSlug(slug);

if (!isValid) {
return NextResponse.json({ isValid: false, isAvailable: false });
}

// Check if slug is available (not used by other websites)
const existingWebsite = await prisma.website.findFirst({
where: {
slug,
},
select: {
id: true,
},
});

const isAvailable = !existingWebsite;

return NextResponse.json({
isValid: true,
isAvailable,
message: isAvailable ? 'Slug is available' : 'Slug is already taken',
});
} catch (error) {
console.error('Error validating slug:', error);
return NextResponse.json({ error: 'Failed to validate slug' }, { status: 500 });
}
}
32 changes: 32 additions & 0 deletions app/api/validate-url/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { validateDeploymentUrl } from '~/lib/utils/app-url';

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');

if (!url) {
return NextResponse.json({ success: false, error: 'URL parameter is required' }, { status: 400 });
}

// Validate the URL format
try {
new URL(url);
} catch {
return NextResponse.json({ success: false, error: 'Invalid URL format' }, { status: 400 });
}

// Validate the deployment URL
const result = await validateDeploymentUrl(url);

return NextResponse.json({
success: true,
valid: result.valid,
error: result.error,
});
} catch (error) {
console.error('URL validation error:', error);
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 });
}
}
200 changes: 95 additions & 105 deletions app/api/websites/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,136 +1,126 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { PermissionAction, PermissionResource, Prisma } from '@prisma/client';
import { subject } from '@casl/ability';
import { requireUserAbility } from '~/auth/session';
import { deleteWebsite, getWebsite, updateWebsite } from '~/lib/services/websiteService';
import { env } from '~/env';
import { requireUserId } from '~/auth/session';
import { prisma } from '~/lib/prisma';
import { logger } from '~/utils/logger';
import { generateUniqueSlug } from '~/utils/slug';
import { z } from 'zod';

// Netlify API client
const NETLIFY_API_URL = 'https://api.netlify.com/api/v1';
const NETLIFY_ACCESS_TOKEN = env.server.NETLIFY_AUTH_TOKEN;

async function deleteNetlifySite(siteId: string) {
if (!NETLIFY_ACCESS_TOKEN) {
throw new Error('NETLIFY_ACCESS_TOKEN is not configured');
}

const response = await fetch(`${NETLIFY_API_URL}/sites/${siteId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${NETLIFY_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
});

if (!response.ok) {
const error = (await response.json()) as any;
throw new Error(`Failed to delete Netlify site: ${error.message || response.statusText}`);
}

return true;
}

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { userAbility } = await requireUserAbility(request);
const { id } = await params;

const website = await getWebsite(id);

if (
userAbility.cannot(PermissionAction.read, subject(PermissionResource.Website, website)) &&
userAbility.cannot(
PermissionAction.read,
subject(PermissionResource.Environment, { id: website.environmentId ?? undefined }),
)
) {
return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 });
}

return NextResponse.json({ success: true, website });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
return NextResponse.json({ success: false, error: 'Website not found' }, { status: 404 });
}

return NextResponse.json({ success: false, error: 'Failed to fetch website' }, { status: 500 });
}
}

const patchRequestSchema = z.object({
siteName: z.string().min(1, 'Site name is required'),
const requestBodySchema = z.object({
siteName: z.string().min(1).max(100).optional(),
slug: z.string().min(1).max(50).optional(),
});

export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { userAbility } = await requireUserAbility(request);
const userId = await requireUserId(request);
const { id } = await params;

const body = await request.json();
const parsedBody = patchRequestSchema.safeParse(body);

if (!parsedBody.success) {
return NextResponse.json({ success: false, error: parsedBody.error.flatten().fieldErrors }, { status: 400 });
const parseResult = requestBodySchema.parse(body);
const { siteName, slug } = parseResult;

// Verify the website belongs to the user
const existingWebsite = await prisma.website.findFirst({
where: {
id,
createdById: userId,
},
});

if (!existingWebsite) {
return NextResponse.json({ error: 'Website not found' }, { status: 404 });
}

const website = await getWebsite(id);
// Prepare update data
const updateData: any = {};

if (
userAbility.cannot(PermissionAction.update, subject(PermissionResource.Website, website)) &&
userAbility.cannot(
PermissionAction.update,
subject(PermissionResource.Environment, { id: website.environmentId ?? undefined }),
)
) {
return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 });
if (siteName !== undefined) {
updateData.siteName = siteName;
}

const updatedWebsite = await updateWebsite(id, parsedBody.data);

return NextResponse.json({ success: true, website: updatedWebsite });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
return NextResponse.json({ success: false, error: `Website not found` }, { status: 404 });
if (slug !== undefined) {
if (slug && slug.trim()) {
// Check if slug is already taken by another website
const existingSlugWebsite = await prisma.website.findFirst({
where: {
slug: slug.trim(),
id: { not: id }, // Exclude current website
},
});

if (existingSlugWebsite) {
return NextResponse.json({ error: 'Slug is already taken' }, { status: 400 });
}

updateData.slug = slug.trim();
} else {
// Generate a new slug if empty
const baseSlug = siteName || existingWebsite.siteName || 'untitled-app';
updateData.slug = await generateUniqueSlug(baseSlug, existingWebsite.slug || undefined);
}
}

return NextResponse.json({ success: false, error: 'Failed to update website' }, { status: 500 });
// Update the website
const updatedWebsite = await prisma.website.update({
where: { id },
data: updateData,
include: {
environment: {
select: {
id: true,
name: true,
},
},
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});

return NextResponse.json({
success: true,
website: updatedWebsite,
});
} catch (error) {
logger.error('Error updating website', {
error: error instanceof Error ? error.message : String(error),
websiteId: (await params).id,
});
return NextResponse.json({ error: 'Failed to update website' }, { status: 500 });
}
}

export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { userAbility } = await requireUserAbility(request);
const userId = await requireUserId(request);
const { id } = await params;

const website = await getWebsite(id);

if (
userAbility.cannot(PermissionAction.delete, subject(PermissionResource.Website, website)) &&
userAbility.cannot(
PermissionAction.delete,
subject(PermissionResource.Environment, { id: website.environmentId ?? undefined }),
)
) {
return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 });
}
// Verify the website belongs to the user
const existingWebsite = await prisma.website.findFirst({
where: {
id,
createdById: userId,
},
});

// Delete from Netlify first
if (website?.siteId) {
await deleteNetlifySite(website.siteId);
if (!existingWebsite) {
return NextResponse.json({ error: 'Website not found' }, { status: 404 });
}

await deleteWebsite(id);
// Delete the website
await prisma.website.delete({
where: { id },
});

return NextResponse.json({ success: true });
} catch (error) {
logger.error('Error deleting website:', error);

if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
return NextResponse.json({ success: false, error: `Website not found` }, { status: 404 });
}

return NextResponse.json({ success: false, error: 'Failed to delete website' }, { status: 500 });
logger.error('Error deleting website', {
error: error instanceof Error ? error.message : String(error),
websiteId: (await params).id,
});
return NextResponse.json({ error: 'Failed to delete website' }, { status: 500 });
}
}
Loading
Loading