-
Notifications
You must be signed in to change notification settings - Fork 11
Add deployed apps gateway #178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kapicic
wants to merge
14
commits into
main
Choose a base branch
from
stevan/eng-818
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
defc183
Add POC of proxying via iframe (WIP)
kapicic 5166385
Merge branch 'main' into poc/app-viewer
kapicic 17098cb
Add headers to vercel deploy plugin
kapicic 138d643
Remove proxy endpoints
kapicic 47e02eb
Add slug to website table, implement automatic slug generation
kapicic 69912dc
Merge branch 'main' into stevan/eng-818
kapicic 3a31ef5
Fix vercel deployments, add deployments tab link
kapicic bc0ef67
Move from /slug to /apps
kapicic 10b909b
Fix netlify deploy saving https
kapicic 4a2585d
Fix aws deploy plugin with correct next config export
kapicic e80ea5d
Merge branch 'main' into stevan/eng-818
kapicic ecc262e
Add exact base url as allowed origin in deployed apps
kapicic 1e689d9
Add click outside handler
kapicic 0494d3e
Add update slugs, replace direct links with app viewer links
kapicic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| // 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 }); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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?