diff --git a/.env.example b/.env.example index 85cae55..b81f8da 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ DOCKER_TYPE=socket DOCKER_SOCKET=/var/run/docker.sock # docker port - for http connection DOCKER_PORT= +# docker network used for connecting to containers +DOCKER_NETWORK= # Primary Domain for showing description - for SEO. Leave empty unless you know what you are doing METADATA_URL= # Auth providers - comma separated list of providers diff --git a/package.json b/package.json index 3cb0d28..6ee96cc 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "NODE_ENV=production tsx server.ts", "lint": "biome lint --apply .", + "check": "biome check --apply .", "format": "biome format --write .", "drizzle-kit": "drizzle-kit", "db:push": "drizzle-kit push" diff --git a/server.ts b/server.ts index 3ff209a..3d2c407 100644 --- a/server.ts +++ b/server.ts @@ -9,7 +9,7 @@ import { WebSocketServer } from "ws"; const port = Number.parseInt(process.env.PORT as string) || 3000; const dev = process.env.NODE_ENV !== "production"; if (process.argv.includes("--turbo")) { - process.env.TURBOPACK = "1"; + process.env.TURBOPACK = "1"; } const server = createServer(); const app = next({ dev, port, httpServer: server, hostname: process.env.HOSTNAME }); @@ -19,55 +19,53 @@ const nextRequest = app.getRequestHandler(); const nextUpgrade = app.getUpgradeHandler(); const websockify = new WebSocketServer({ noServer: true }); websockify.on("connection", async (ws, req) => { - try { - const id = req.url?.split("/")[2]; - if (!id) { - ws.close(1008, "Missing ID"); - return; - } - const container = await docker.getContainer(id).inspect(); - const ip = container.NetworkSettings.Networks[container.HostConfig.NetworkMode as string].IPAddress; - const socket = new Socket(); - socket.connect(5901, ip); - ws.on("message", (message: Uint8Array) => { - socket.write(message); - }); - ws.on("close", (code, reason) => { - consola.info( - `✨ Stardust: Connection closed with code ${code} and ${ - reason.toString() ? `reason ${reason.toString()}` : "no reason" - }`, - ); - socket.end(); - }); + try { + const id = req.url?.split("/")[2]; + if (!id) { + ws.close(1008, "Missing ID"); + return; + } + const ip = (await docker.getContainer(id).inspect()).NetworkSettings.Networks[process.env.DOCKER_NETWORK as string].IPAddress + const socket = new Socket(); + socket.connect(5901, ip); + ws.on("message", (message: Uint8Array) => { + socket.write(message); + }); + ws.on("close", (code, reason) => { + consola.info( + `✨ Stardust: Connection closed with code ${code} and ${reason.toString() ? `reason ${reason.toString()}` : "no reason" + }`, + ); + socket.end(); + }); - socket.on("data", (data) => { - ws.send(data); - }); + socket.on("data", (data) => { + ws.send(data); + }); - socket.on("error", (err) => { - consola.warn(`✨ Stardust: ${err.message}`); - ws.close(); - }); + socket.on("error", (err) => { + consola.warn(`✨ Stardust: ${err.message}`); + ws.close(); + }); - socket.on("close", () => { - ws.close(); - }); - } catch (error) { - ws.close(1008, "Server error"); - consola.error(`✨ Stardust: ${(error as Error).message}`); - } + socket.on("close", () => { + ws.close(); + }); + } catch (error) { + ws.close(1008, "Server error"); + consola.error(`✨ Stardust: ${(error as Error).message}`); + } }); server.on("request", nextRequest); server.on("upgrade", async (req, socket, head) => { - if (req.url?.includes("websockify")) { - websockify.handleUpgrade(req, socket, head, (ws) => { - websockify.emit("connection", ws, req, websockify); - }); - } else { - nextUpgrade(req, socket, head); - } + if (req.url?.includes("websockify")) { + websockify.handleUpgrade(req, socket, head, (ws) => { + websockify.emit("connection", ws, req, websockify); + }); + } else { + nextUpgrade(req, socket, head); + } }); server.listen(port, () => { - consola.success(`✨ Stardust: Server listening on ${port}`); + consola.success(`✨ Stardust: Server listening on ${port}`); }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 7a0bd2f..7410d97 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -2,7 +2,8 @@ import { StyledSubmit } from "@/components/submit-button"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { CardContent } from "@/components/ui/card"; import { auth, signIn } from "@/lib/auth"; -import { AlertCircle, LogIn } from "lucide-react"; +import { providers } from "@/lib/auth.config"; +import { AlertCircle } from "lucide-react"; import { redirect } from "next/navigation"; export default async function Login({ @@ -24,21 +25,24 @@ export default async function Login({ ) : null}
- {providersArray.map((provider) => ( -
{ - "use server"; - await signIn(provider); - }} - > - - - Log in with {provider.charAt(0).toUpperCase() + provider.slice(1)} - -
- ))} + {providersArray.map((provider) => { + const { Icon } = providers[provider]; + return ( +
{ + "use server"; + await signIn(provider); + }} + > + + + Log in with {provider.charAt(0).toUpperCase() + provider.slice(1)} + +
+ ); + })}
); diff --git a/src/app/auth/logout/page.tsx b/src/app/auth/logout/page.tsx index 56f268d..087ff32 100644 --- a/src/app/auth/logout/page.tsx +++ b/src/app/auth/logout/page.tsx @@ -13,8 +13,8 @@ export default function SignOut() {
{ "use server"; - await signOut(); - redirect("/"); + await signOut({ redirect: false }); + redirect("/auth/login"); }} > diff --git a/src/components/icons.tsx b/src/components/icons.tsx new file mode 100644 index 0000000..2cd4804 --- /dev/null +++ b/src/components/icons.tsx @@ -0,0 +1,45 @@ +import type { SVGProps } from "react"; +export const GitHubIcon = (props: SVGProps) => ( + + {"GitHub"} + + +); +export const DiscordIcon = (props: SVGProps) => ( + + Discord + + +); +export const GoogleIcon = (props: SVGProps) => ( + + Google + + +); +export const GitLabIcon = (props: SVGProps) => ( + + GitLab + + +); +export const AppleIcon = (props: SVGProps) => ( + + Apple + + +); diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 14bb5d1..36ce950 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -3,31 +3,31 @@ import packageJson from "@/../package.json"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuPortal, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { - NavigationMenu, - NavigationMenuItem, - NavigationMenuLink, - NavigationMenuList, - navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + navigationMenuTriggerStyle, } from "@/components/ui/navigation-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import type { SelectUser } from "@/lib/drizzle/schema"; @@ -37,229 +37,226 @@ import type { Session } from "next-auth"; import { useTheme } from "next-themes"; import Link from "next/link"; import { Fragment, useState } from "react"; +import { GitHubIcon } from "./icons"; export default function Navigation({ - dbUser, - session, + dbUser, + session, }: { - dbUser: SelectUser; - session: Session | null; + dbUser: SelectUser; + session: Session | null; }) { - const { name, email, image } = session?.user || {}; - const [open, setDialogOpen] = useState(false); - const { themes, setTheme } = useTheme(); - const navigationItems: { - icon: React.ReactNode; - label: string; - href: Route; - adminOnly: boolean; - }[] = [ - { - icon: , - label: "Dashboard", - href: "/", - adminOnly: false, - }, - { - label: "Admin", - href: "/admin", - icon: , - adminOnly: true, - }, - ]; - const projectsUsed = [ - { - name: "Next.js", - url: "https://nextjs.org/", - }, - { - name: "noVNC", - url: "https://github.com/noVNC/noVNC", - }, - { - name: "shadcn/ui", - url: "https://ui.shadcn.com/", - }, - ]; - const developers = ["incognitotgt", "yosoof3", "uhidontkno"]; - return ( - + ); } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 8e59b48..47a09ec 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,11 +1,12 @@ -import { providers } from "./lib/providers"; +import { providers } from "@/lib/auth.config"; export async function register() { - const provider = process.env.AUTH_PROVIDERS; - if (!provider?.split(",").every((p) => providers.includes(p))) - throw new Error("No provider specified in environment variables, or invalid provider specified"); - if (!process.env.DATABASE_URL) throw new Error("No database URL specified in environment variables"); - - if (process.env.NEXT_RUNTIME === "nodejs") { - await import("@/lib/session/purge"); - } + const provider = process.env.AUTH_PROVIDERS; + if (!provider?.split(",").every((p) => Object.keys(providers).includes(p))) + throw new Error("No provider specified in environment variables, or invalid provider specified"); + if (!process.env.DATABASE_URL) throw new Error("No database URL specified in environment variables"); + if (!process.env.AUTH_SECRET) throw new Error("No auth secret specified in environment variables"); + if (!process.env.DOCKER_NETWORK) throw new Error("No docker network specified in environment variables"); + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("@/lib/session/purge"); + } } diff --git a/src/lib/auth.config.ts b/src/lib/auth.config.ts index 444daa1..e87f9b8 100644 --- a/src/lib/auth.config.ts +++ b/src/lib/auth.config.ts @@ -1,20 +1,52 @@ +import { AppleIcon, DiscordIcon, GitHubIcon, GitLabIcon, GoogleIcon } from "@/components/icons"; +import { LogIn } from "lucide-react"; import type { NextAuthConfig } from "next-auth"; import type { Provider } from "next-auth/providers"; +import Apple from "next-auth/providers/apple"; import Auth0 from "next-auth/providers/auth0"; import Discord from "next-auth/providers/discord"; import GitHub from "next-auth/providers/github"; +import GitLab from "next-auth/providers/gitlab"; +import Google from "next-auth/providers/google"; +import Okta from "next-auth/providers/okta"; +import type { FC, SVGProps } from "react"; const providersArray = process.env.AUTH_PROVIDERS?.split(",").filter(Boolean) || []; -const providerMap: Record = { - auth0: Auth0, - discord: Discord, - github: GitHub, +const providersList: Record> }> = { + auth0: { + provider: Auth0, + Icon: LogIn, + }, + discord: { + provider: Discord, + Icon: DiscordIcon, + }, + github: { + provider: GitHub, + Icon: GitHubIcon, + }, + google: { + provider: Google, + Icon: GoogleIcon, + }, + gitlab: { + provider: GitLab, + Icon: GitLabIcon, + }, + okta: { + provider: Okta, + Icon: LogIn, + }, + apple: { + provider: Apple, + Icon: AppleIcon, + }, }; const providers: Provider[] = []; -for (const [name, provider] of Object.entries(providerMap)) { +for (const [name, { provider }] of Object.entries(providersList)) { if (providersArray.includes(name)) providers.push(provider); } -export const config: NextAuthConfig = { +const config: NextAuthConfig = { trustHost: true, pages: { signIn: "/auth/login", @@ -23,3 +55,5 @@ export const config: NextAuthConfig = { }, providers, }; + +export { providersList as providers, config }; diff --git a/src/lib/providers.ts b/src/lib/providers.ts deleted file mode 100644 index 9f1834e..0000000 --- a/src/lib/providers.ts +++ /dev/null @@ -1 +0,0 @@ -export const providers = ["auth0", "credentials", "discord", "github"]; diff --git a/src/lib/session/get-session.ts b/src/lib/session/get-session.ts index df9ddfc..58ddf69 100644 --- a/src/lib/session/get-session.ts +++ b/src/lib/session/get-session.ts @@ -6,28 +6,27 @@ import type { Session } from "next-auth"; * * @param containerId The id of the container to get * @param userSession An Auth.js `Session` object - * @returns The database query for the session, or `null` if it doesn't exist. + * @returns The database query for the session along with the container IP, or `null` if it doesn't exist. */ async function getSession(containerId: string, userSession: Session | null) { - const [containerSession] = await db.transaction(async (tx) => { - const [{ userId }] = await tx - .select({ - userId: user.id, - }) - .from(user) - .where(eq(user.email, userSession?.user?.email as string)); - return tx - .select() - .from(session) - .where(and(eq(session.id, containerId), eq(session.userId, userId))); - }); - if (!containerSession) return null; - const container = await docker.getContainer(containerId).inspect(); - const ip = container.NetworkSettings.Networks[container.HostConfig.NetworkMode as string].IPAddress; - return { - ip, - ...containerSession, - }; + const [containerSession] = await db.transaction(async (tx) => { + const [{ userId }] = await tx + .select({ + userId: user.id, + }) + .from(user) + .where(eq(user.email, userSession?.user?.email as string)); + return tx + .select() + .from(session) + .where(and(eq(session.id, containerId), eq(session.userId, userId))); + }); + if (!containerSession) return null; + const ip = (await docker.getContainer(containerId).inspect()).NetworkSettings.Networks[process.env.DOCKER_NETWORK as string].IPAddress + return { + ip, + ...containerSession, + }; } export { getSession }; diff --git a/src/lib/session/index.ts b/src/lib/session/index.ts index fdd2f05..6da1e84 100644 --- a/src/lib/session/index.ts +++ b/src/lib/session/index.ts @@ -13,55 +13,52 @@ import { getSession } from "./get-session"; * @param Image Docker image to use for making the session */ async function createSession(Image: string) { - consola.info(`✨ Stardust: Creating session with image ${Image}`); - try { - const userSession = await auth(); - if (!userSession?.user) throw new Error("User not found"); - const id = `stardust-${crypto.randomUUID()}-${Image.split("/")[2]}`; - await docker.createNetwork({ - Name: id, - }); - const container = await docker.createContainer({ - name: id, - Image, - HostConfig: { - NetworkMode: id, - ShmSize: 1024, - }, - }); - await container.start().catch((e) => { - container.remove({ force: true }); - throw new Error(`Container not started ${e.message}`); - }); - const date = new Date(); - date.setDate(date.getDate() + 7); - return db - .transaction(async (tx) => { - return await tx - .insert(session) - .values({ - id: container.id, - dockerImage: Image, - createdAt: Date.now(), - expiresAt: date.getTime(), - userId: ( - await tx - .select({ - userId: user.id, - }) - .from(user) - .where(eq(user.email, userSession.user?.email as string)) - )[0].userId, - }) - .returning(); - }) - .catch(async (e) => { - await container.remove({ force: true }); - throw new Error(e.message); - }); - } catch (error) { - console.error(error); - } + consola.info(`✨ Stardust: Creating session with image ${Image}`); + try { + const userSession = await auth(); + if (!userSession?.user) throw new Error("User not found"); + const id = `stardust-${crypto.randomUUID()}-${Image.split("/")[2]}`; + const container = await docker.createContainer({ + name: id, + Image, + HostConfig: { + ShmSize: 1024, + NetworkMode: process.env.DOCKER_NETWORK, + }, + }); + await container.start().catch((e) => { + container.remove({ force: true }); + throw new Error(`Container not started ${e.message}`); + }); + const date = new Date(); + date.setDate(date.getDate() + 7); + return db + .transaction(async (tx) => { + return await tx + .insert(session) + .values({ + id: container.id, + dockerImage: Image, + createdAt: Date.now(), + expiresAt: date.getTime(), + userId: ( + await tx + .select({ + userId: user.id, + }) + .from(user) + .where(eq(user.email, userSession.user?.email as string)) + )[0].userId, + }) + .returning(); + }) + .catch(async (e) => { + await container.remove({ force: true }); + throw new Error(e.message); + }); + } catch (error) { + console.error(error); + } } /** * Allows you to manage a Stardust session with dockerode @@ -70,28 +67,16 @@ async function createSession(Image: string) { * @param admin If this is triggered by an admin */ async function manageSession(containerId: string, action: keyof Dockerode.Container, admin?: boolean) { - const userSession = await auth(); - if (admin) { - const [{ isAdmin }] = await db - .select({ isAdmin: user.isAdmin }) - .from(user) - .where(eq(user.email, userSession?.user?.email as string)); - if (isAdmin) { - const container = docker.getContainer(containerId); - await container[action](); - revalidatePath("/"); - } - return; - } - const { id } = (await getSession(containerId, userSession)) || {}; - if (!id) throw new Error("Session not found"); - try { - const container = docker.getContainer(id); - await container[action](); - revalidatePath("/"); - } catch (error) { - console.error(error); - } + const userSession = await auth(); + const [{ isAdmin }] = await db + .select({ isAdmin: user.isAdmin }) + .from(user) + .where(eq(user.email, userSession?.user?.email as string)); + const { id } = (await getSession(containerId, userSession)) || {}; + if ((admin && isAdmin) || id) { + const container = docker.getContainer(id || containerId); + await container[action](); + } } /** * Deletes a Stardust session @@ -99,33 +84,17 @@ async function manageSession(containerId: string, action: keyof Dockerode.Contai * @param admin If this is triggered by an admin */ async function deleteSession(containerId: string, admin?: boolean) { - const userSession = await auth(); - if (admin) { - const [{ isAdmin }] = await db - .select({ isAdmin: user.isAdmin }) - .from(user) - .where(eq(user.email, userSession?.user?.email as string)); - if (isAdmin) { - const container = docker.getContainer(containerId); - const network = (await container.inspect()).HostConfig.NetworkMode; - await container.remove({ force: true }); - await docker.getNetwork(network as string).remove({ force: true }); - await db.delete(session).where(eq(session.id, containerId)); - revalidatePath("/"); - } - return; - } - const { id } = (await getSession(containerId, userSession)) || {}; - if (!id) throw new Error("Session not found"); - try { - const container = docker.getContainer(id); - const network = (await container.inspect()).HostConfig.NetworkMode; - await container.remove({ force: true }); - await docker.getNetwork(network as string).remove({ force: true }); - await db.delete(session).where(eq(session.id, id)); - revalidatePath("/"); - } catch (error) { - console.error(error); - } + const userSession = await auth(); + const [{ isAdmin }] = await db + .select({ isAdmin: user.isAdmin }) + .from(user) + .where(eq(user.email, userSession?.user?.email as string)); + const { id } = (await getSession(containerId, userSession)) || {}; + if ((admin && isAdmin) || id) { + const container = docker.getContainer(id || containerId); + await container.remove({ force: true }); + await db.delete(session).where(eq(session.id, id || containerId)); + revalidatePath("/"); + } } export { createSession, deleteSession, manageSession }; diff --git a/src/lib/session/purge.ts b/src/lib/session/purge.ts index 3002e85..c28585c 100644 --- a/src/lib/session/purge.ts +++ b/src/lib/session/purge.ts @@ -9,10 +9,7 @@ setInterval(async () => { const staleSessions = await tx.select().from(session).where(lte(session.expiresAt, Date.now())); await Promise.all( staleSessions.map(async (s) => { - const container = docker.getContainer(s.id); - const network = (await container.inspect()).HostConfig.NetworkMode; - await container.remove({ force: true }); - await docker.getNetwork(network as string).remove({ force: true }); + await docker.getContainer(s.id).remove({ force: true }); await tx.delete(session).where(eq(session.id, s.id)); }), );