This commit is contained in:
Benjamin Toby 2026-03-09 06:16:36 +01:00
parent ef54906d9a
commit 40dacc0b62
52 changed files with 1922 additions and 143 deletions

3
.gitignore vendored
View File

@ -41,4 +41,5 @@ yarn-error.log*
next-env.d.ts
/src/db/turboci-admin
.backups
/secrets
/secrets
.tmp

View File

@ -1,11 +0,0 @@
import type { BunSQLiteConfig } from "@moduletrace/bun-sqlite/dist/types";
const BunSQLiteConfig: BunSQLiteConfig = {
db_name: "turboci-admin",
db_backup_dir: ".backups",
db_schema_file_name: "db-schema.ts",
db_dir: "./src/db",
typedef_file_path: "./src/db/types.ts",
};
export default BunSQLiteConfig;

355
bun.lock

File diff suppressed because it is too large Load Diff

61
docker-compose.yaml Normal file
View File

@ -0,0 +1,61 @@
name: turboci-admin
services:
nginx:
image: nginx:trixie
env_file: .env
depends_on:
web:
condition: service_healthy
network_mode: host
container_name: turboci-admin-nginx
restart: always
volumes:
- ./docker/services/nginx/conf.d:/etc/nginx/conf.d
- ./docker/services/nginx/nginx.conf:/etc/nginx/nginx.conf
web:
build:
context: ./docker/services/web
dockerfile: Dockerfile
env_file: .env
network_mode: host
container_name: turboci-admin-web
volumes:
- .:/app
- /root/.turboci:/root/.turboci
expose:
- 3772
healthcheck:
test: ["CMD", "curl", "--silent", "--fail", "http://localhost:3772/"]
interval: 20s
timeout: 5s
retries: 5
start_period: 4s
restart: always
websocket:
build:
context: ./docker/services/websocket
dockerfile: Dockerfile
env_file: .env
container_name: turboci-admin-websocket
hostname: turboci-admin-websocket
volumes:
- .:/app
- /root/.turboci:/root/.turboci
network_mode: host
restart: always
cron:
build:
context: ./docker/services/cron
dockerfile: Dockerfile
env_file: .env
container_name: turboci-admin-cron
hostname: turboci-admin-cron
volumes:
- .:/app
- /root/.turboci:/root/.turboci
restart: on-failure:10
network_mode: host

View File

@ -0,0 +1,16 @@
FROM node:lts-trixie
RUN apt update && apt install -y ca-certificates curl zip unzip wget rsync openssh-client zlib1g
RUN update-ca-certificates
WORKDIR /app
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]

View File

@ -0,0 +1,14 @@
#!/bin/bash
cd /app
if [[ -z "$NODE_ENV" ]]; then
echo "NODE_ENV is not set. Defaulting to development."
NODE_ENV="development"
fi
if [[ "$NODE_ENV" == "production" ]]; then
bun src/cron/index.ts
else
bun --watch src/cron/index.ts
fi

View File

@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
client_max_body_size 20M;
location /ws {
proxy_pass http://localhost:3773;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
proxy_buffering off;
keepalive_timeout 75s;
tcp_nodelay on;
}
location / {
proxy_pass http://localhost:3772;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}

View File

@ -0,0 +1,33 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
#gzip on;
include /etc/nginx/conf.d/*.conf;
}

View File

@ -0,0 +1,16 @@
FROM node:lts-trixie
RUN apt update && apt install -y ca-certificates curl zip unzip wget rsync openssh-client zlib1g wget
RUN update-ca-certificates
RUN mkdir /app
WORKDIR /app
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]

View File

@ -0,0 +1,17 @@
#!/bin/bash
cd /app
rm -rf /app/node_modules
rm -rf /app/.next
bun install
npm rebuild better-sqlite3
if [ $NODE_ENV = "production" ]; then
echo "Production Environment"
bun next start -p ${PORT}
else
echo "Development Environment"
bun next dev -p ${PORT}
fi

View File

@ -0,0 +1,6 @@
FROM oven/bun:debian
COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]

View File

@ -0,0 +1,14 @@
#!/bin/bash
cd /app
if [[ -z "$NODE_ENV" ]]; then
echo "NODE_ENV is not set. Defaulting to development."
NODE_ENV="development"
fi
if [[ "$NODE_ENV" == "production" ]]; then
bun src/websocket/index.ts
else
bun --watch src/websocket/index.ts
fi

5
next.config.js Normal file
View File

@ -0,0 +1,5 @@
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;

View File

@ -1,8 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactStrictMode: true,
};
export default nextConfig;

9
nsqlite.config.js Normal file
View File

@ -0,0 +1,9 @@
const NSQLiteConfig = {
db_name: "turboci-admin",
db_backup_dir: ".backups",
db_schema_file_name: "db-schema.js",
db_dir: "./src/db",
typedef_file_path: "./src/db/types.ts",
};
export default NSQLiteConfig;

View File

@ -1,24 +1,34 @@
{
"name": "turboci-admin",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"pm2:dev": "pm2 start --name turboci-web 'bunx next dev' && pm2 start --name turboci-cron 'bun --watch src/cron/index.ts'",
"pm2:start": "pm2 start --name turboci-web 'bunx next start' && pm2 start --name turboci-cron 'bun src/cron/index.ts'",
"pm2:kill": "pm2 kill",
"twui:update": "git submodule update --init twui",
"twui:init": "git submodule update twui --init",
"twui:update": "git submodule update --remote",
"twui:add": "git submodule add https://git.tben.me/Moduletrace/tailwind-ui-library.git twui",
"db:schema": "bunx bun-sqlite schema -t",
"docker:start": "docker compose down && docker compose up --build -d",
"docker:logs": "docker compose logs -f -n 50",
"rebuild:better-sqlite3": "npm rebuild better-sqlite3",
"chown:root-dir": "sudo chown -R archben:archben .",
"build": "next build"
},
"dependencies": {
"@moduletrace/bun-sqlite": "^1.0.5",
"@moduletrace/datasquirel": "^5.7.57",
"@moduletrace/nsqlite": "^1.0.9",
"better-sqlite3": "^12.6.2",
"bun": "^1.3.10",
"dayjs": "^1.11.19",
"gray-matter": "^4.0.3",
"html-to-react": "^1.7.0",
"lodash": "^4.17.23",
"lucide-react": "^0.577.0",
"mdx": "^0.3.1",
"next": "16.1.6",
"next": "14^",
"next-mdx-remote": "^6.0.0",
"openai": "^6.25.0",
"react": "19.2.3",
@ -27,7 +37,8 @@
"react-responsive-modal": "^7.1.0",
"rehype-prism-plus": "^2.0.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0"
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@next/mdx": "^16.1.6",

View File

@ -1,7 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,59 @@
import Link from "next/link";
import { useId } from "react";
export default function Logo() {
const mainGradientId = useId();
const foldGradientId = useId();
return (
<Link
href="/"
aria-label="TurboCI home"
className="inline-flex items-center gap-3"
>
<svg
viewBox="0 0 48 48"
aria-hidden="true"
className="h-10 w-10 shrink-0"
>
<defs>
<linearGradient
id={mainGradientId}
x1="9"
y1="9"
x2="31"
y2="34"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#9cf0c0" />
<stop offset="100%" stopColor="#42d392" />
</linearGradient>
<linearGradient
id={foldGradientId}
x1="22"
y1="22"
x2="36"
y2="40"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#2bc67e" />
<stop offset="100%" stopColor="#1f8458" />
</linearGradient>
</defs>
<path
d="M35 6H23.6c-2 0-3.8 1-4.8 2.7L7.5 28.3c-1.4 2.5.4 5.7 3.2 5.7h9.1c2 0 3.8-1 4.8-2.7l11.2-19.6C39.6 9.2 37.8 6 35 6Z"
fill={`url(#${mainGradientId})`}
/>
<path
d="M23.9 24h13.4c2.8 0 4.6 3.1 3.2 5.6l-4.1 7.1c-1 1.7-2.8 2.7-4.8 2.7H18.2c-2.8 0-4.6-3.1-3.2-5.6l4.1-7.1c1-1.7 2.8-2.7 4.8-2.7Z"
fill={`url(#${foldGradientId})`}
/>
</svg>
<span className="font-display text-[1.02rem] font-semibold tracking-tight text-foreground">
TurboCI
</span>
</Link>
);
}

View File

@ -1,5 +1,85 @@
import useLoginForm from "@/src/hooks/use-login-form";
import Tag from "@/twui/components/elements/Tag";
import Form from "@/twui/components/form/Form";
import Input from "@/twui/components/form/Input";
import Button from "@/twui/components/layout/Button";
import Stack from "@/twui/components/layout/Stack";
import z from "zod";
export default function LoginForm() {
return <Stack></Stack>;
const { loading, setLoginData, submitLogin, alert } = useLoginForm();
return (
<Form
className="w-full"
submitHandler={() => {
submitLogin();
}}
>
<Stack className="w-full items-stretch gap-6">
{alert?.text ? (
<Tag
color="error"
variant="outlined"
className="py-2 px-6 opacity-70"
>
{alert.text}
</Tag>
) : null}
<Input
placeholder="Email Address or Username"
title="Email/Username"
changeHandler={(v) => {
const email_schema = z.email();
const is_email = email_schema.safeParse(v);
setLoginData((prev) => ({
...prev,
email: is_email.success ? v : undefined,
username: is_email.success ? undefined : v,
}));
}}
validity={
alert?.field_name == "email-username"
? {
isValid: false,
msg: alert?.text,
}
: {
isValid: true,
}
}
showLabel
/>
<Input
placeholder="Password"
title="Password"
type="password"
changeHandler={(v) => {
setLoginData((prev) => ({
...prev,
password: v,
}));
}}
validity={
alert?.field_name == "password"
? {
isValid: false,
msg: alert?.text,
}
: {
isValid: true,
}
}
showLabel
/>
<Button title="Login" loading={loading} onClick={submitLogin}>
Login
</Button>
</Stack>{" "}
</Form>
);
}

View File

@ -1,13 +1,16 @@
import Paper from "@/twui/components/elements/Paper";
import H2 from "@/twui/components/layout/H2";
import Stack from "@/twui/components/layout/Stack";
import LoginForm from "./(partials)/login-form";
import Span from "@/twui/components/layout/Span";
export default function Main() {
return (
<Paper>
<Stack>
<H2>Home</H2>
<Stack className="w-full items-center max-w-lg gap-10">
<Stack className="gap-4">
<H2>Login</H2>
<Span>Welcome Back</Span>
</Stack>
</Paper>
<LoginForm />
</Stack>
);
}

View File

@ -0,0 +1,18 @@
import { TurboCISignupFormObject } from "@/src/types";
import useStatus from "@/twui/components/hooks/useStatus";
import { useState } from "react";
export default function useSignupForm() {
const [newUser, setNewUser] = useState<TurboCISignupFormObject>({});
const { loading, setLoading } = useStatus();
const [isPasswordConfirmed, setIsPasswordConfirmed] = useState(false);
return {
newUser,
setNewUser,
loading,
setLoading,
isPasswordConfirmed,
setIsPasswordConfirmed,
};
}

View File

@ -0,0 +1,133 @@
import Input from "@/twui/components/form/Input";
import Button from "@/twui/components/layout/Button";
import Stack from "@/twui/components/layout/Stack";
import useSignupForm from "../(hooks)/use-signup-form";
import fetchApi from "@/twui/components/utils/fetch/fetchApi";
import { APIReqObject } from "@/src/types";
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
import { useEffect } from "react";
export default function SignupForm() {
const {
newUser,
setNewUser,
loading,
setLoading,
isPasswordConfirmed,
setIsPasswordConfirmed,
} = useSignupForm();
const is_password_valid = Boolean(
isPasswordConfirmed &&
Boolean(newUser.password?.match(/./)) &&
Boolean(newUser.confirmed_password?.match(/./)),
);
return (
<Stack className="w-full items-stretch">
<Input
placeholder="Eg. John"
title="First Name"
changeHandler={(v) => {
setNewUser((prev) => ({
...prev,
first_name: v,
}));
}}
showLabel
/>
<Input
placeholder="Eg. Doe"
title="Last Name"
changeHandler={(v) => {
setNewUser((prev) => ({
...prev,
last_name: v,
}));
}}
showLabel
/>
<Input
placeholder="Email Address or Username"
title="Email/Username"
type="email"
changeHandler={(v) => {
setNewUser((prev) => ({
...prev,
email: v,
}));
}}
showLabel
/>
<Input
placeholder="Password"
title="Password"
type="password"
changeHandler={(v) => {
setNewUser((prev) => ({
...prev,
password: v,
}));
}}
validity={{
isValid:
!Boolean(newUser.password?.match(/./)) ||
!Boolean(newUser.confirmed_password?.match(/./))
? true
: is_password_valid,
msg: `Passwords don't match`,
}}
showLabel
/>
<Input
placeholder="Confirm Password"
title="Confirm Password"
type="password"
changeHandler={(v) => {
setNewUser((prev) => ({
...prev,
confirmed_password: v,
}));
setIsPasswordConfirmed(v == newUser.password);
}}
showLabel
/>
<Button
title="Login"
onClick={() => {
if (!is_password_valid) {
return;
}
if (!window.confirm(`Create Super Admin Account?`)) {
return;
}
setLoading(true);
fetchApi<APIReqObject, APIResponseObject>(
`/api/auth/signup`,
{
method: "POST",
body: {
new_user: newUser,
},
},
)
.then((res) => {
console.log("res", res);
if (res.success) {
window.location.reload();
}
})
.finally(() => {});
}}
loading={loading}
>
Signup
</Button>
</Stack>
);
}

View File

@ -0,0 +1,12 @@
import H2 from "@/twui/components/layout/H2";
import Stack from "@/twui/components/layout/Stack";
import SignupForm from "./(partials)/signup-form";
export default function Main() {
return (
<Stack className="w-full items-center max-w-lg">
<H2>Create Super Admin Account</H2>
<SignupForm />
</Stack>
);
}

View File

@ -4,7 +4,9 @@ import cronCheckServices from "./functions/check-services";
while (true) {
console.log(`Running Cron Services ...`);
await cronCheckServices();
try {
await cronCheckServices();
} catch (error) {}
await Bun.sleep(AppData["CronInterval"]);
}

View File

@ -1,5 +1,6 @@
export const AppData = {
TerminalBinName: "ttyd",
AppName: "turboci-admin",
CronInterval: 30000,
max_instances: 200,
max_clusters: 1000,
@ -15,4 +16,8 @@ export const AppData = {
private_server_batch_exec_size: 50,
ssh_max_tries: 50,
ssh_try_timeout_milliseconds: 5000,
AuthCookieName: `x-turboci-admin-auth-key`,
AuthCSRFCookieName: `x-turboci-admin-csrf`,
CookieExpirationTime: 1000 * 60 * 60 * 24 * 7, // One Week
} as const;

View File

@ -1,6 +1,9 @@
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/dist/types";
// @ts-check
const schema: BUN_SQLITE_DatabaseSchemaType = {
/**
* @type {import("@moduletrace/nsqlite/dist/types").NSQLITE_DatabaseSchemaType}
*/
const schema = {
dbName: "test-db",
tables: [
{
@ -18,10 +21,22 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fieldName: "email",
dataType: "TEXT",
},
{
fieldName: "username",
dataType: "TEXT",
},
{
fieldName: "image",
dataType: "TEXT",
},
{
fieldName: "password",
dataType: "TEXT",
},
{
fieldName: "is_super_admin",
dataType: "INTEGER",
},
],
},
],

View File

@ -1,8 +1,8 @@
export const BunSQLiteTables = [
export const NSQLiteTables = [
"users",
] as const
export type BUN_SQLITE_TEST_DB_USERS = {
export type NSQLITE_TEST_DB_USERS = {
/**
* The unique identifier of the record.
*/
@ -18,7 +18,10 @@ export type BUN_SQLITE_TEST_DB_USERS = {
first_name?: string;
last_name?: string;
email?: string;
username?: string;
image?: string;
password?: string;
is_super_admin?: number;
}
export type BUN_SQLITE_TEST_DB_ALL_TYPEDEFS = BUN_SQLITE_TEST_DB_USERS
export type NSQLITE_TEST_DB_ALL_TYPEDEFS = NSQLITE_TEST_DB_USERS

View File

@ -0,0 +1,10 @@
import dsqlClient from "@moduletrace/datasquirel/dist/client";
import EJSON from "@moduletrace/datasquirel/dist/package-shared/utils/ejson";
import slugify from "@moduletrace/datasquirel/dist/package-shared/utils/slugify";
import numberfy from "@moduletrace/datasquirel/dist/package-shared/utils/numberfy";
const _n = numberfy;
const serializeQuery = dsqlClient.utils.serializeQuery;
const deserializeQuery = dsqlClient.utils.deserializeQuery;
export default dsqlClient;
export { EJSON, slugify, numberfy, _n, serializeQuery, deserializeQuery };

View File

@ -0,0 +1,74 @@
import { EJSON } from "@/src/exports/client-exports";
import { PagePropsType, URLQueryType, User } from "@/src/types";
import parsePageUrl from "@/src/utils/parse-page-url";
import userAuth from "@/src/utils/user-auth";
import _ from "lodash";
import { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
type PropsFnParams = {
user: User;
props?: PagePropsType;
query?: URLQueryType;
};
type Params = {
ctx: GetServerSidePropsContext;
props?: PagePropsType;
propsFn?: (
params: PropsFnParams,
) => Promise<PagePropsType | false | string>;
};
export default async function defaultAdminProps({
ctx,
props,
propsFn,
}: Params): Promise<GetServerSidePropsResult<PagePropsType>> {
const { req, query, res } = ctx;
const { singleRes: user } = await userAuth({ req });
if (!user?.id) {
return {
redirect: {
destination: "/auth/login",
permanent: false,
},
};
}
const propsFnProps = propsFn
? await propsFn?.({ user, query, props })
: undefined;
if (typeof propsFnProps == "boolean" && !propsFnProps) {
return {
redirect: {
destination: "/admin",
permanent: false,
},
};
}
if (typeof propsFnProps == "string") {
return {
redirect: {
destination: propsFnProps,
permanent: false,
},
};
}
const finalAdminUrl = parsePageUrl(req.url, true);
const defaultPageProps: PagePropsType = {
query,
user,
pageUrl: finalAdminUrl,
};
let finalProps = _.merge(props, propsFnProps, defaultPageProps);
return {
props: { ...finalProps },
};
}

View File

@ -0,0 +1,73 @@
import useStatus from "@/twui/components/hooks/useStatus";
import { useCallback, useEffect, useState } from "react";
import { AlertObject, APIReqObject, LoginFormData } from "../types";
import fetchApi from "@moduletrace/datasquirel/dist/client/fetch";
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
import z, { ZodError } from "zod";
import { EJSON } from "../exports/client-exports";
export default function useLoginForm() {
const [loginData, setLoginData] = useState<LoginFormData>({});
const [alert, setAlert] = useState<AlertObject>();
const { loading, setLoading } = useStatus();
useEffect(() => {
setAlert(undefined);
}, [loginData]);
const submitLogin = useCallback(async () => {
if (!loginData.email && !loginData.username) {
setAlert({ text: `Please Enter a username or email` });
return;
}
const password_schema = z.string().min(6);
const is_password = password_schema.safeParse(loginData.password || "");
if (!is_password.success) {
const error_text =
`Invalid Password: ` +
(EJSON.parse(is_password.error.message) as ZodError[])?.[0]
.message;
setAlert({
text: error_text,
field_name: "password",
});
return;
}
setLoading(true);
try {
const res = await fetchApi<APIReqObject, APIResponseObject>(
`/api/auth/login`,
{
method: "POST",
body: {
...loginData,
},
},
);
if (res.success) {
window.location.reload();
} else {
setAlert({ text: res.msg || `Login Failed` });
setLoading(false);
}
} catch (error) {
setLoading(false);
}
}, [loginData]);
return {
loginData,
setLoginData,
loading,
setLoading,
submitLogin,
alert,
setAlert,
};
}

View File

@ -0,0 +1,17 @@
import { TWUI_LINK_LIST_LINK_OBJECT } from "@/twui/components/elements/LinkList";
export const AdminAsideLinks: (
| TWUI_LINK_LIST_LINK_OBJECT
| TWUI_LINK_LIST_LINK_OBJECT[]
| undefined
)[] = [
{
title: "Dashboard",
url: "/admin",
strict: true,
},
{
title: "Deployments",
url: "/admin/deployments",
},
];

View File

@ -0,0 +1,22 @@
import Logo from "@/src/components/general/logo";
import Row from "@/twui/components/layout/Row";
import { PropsWithChildren } from "react";
type Props = PropsWithChildren & {};
export default function Header({ children }: Props) {
return (
<header className="col-span-6">
<Row className="w-full grid grid-cols-6 grid-frame nested-grid-frame">
<Row className="h-full items-stretch grid-cell col-span-1 w-full justify-between">
<Row className="px-4">
<Logo />
</Row>
{/* <Divider vertical className="-mr-[7px]" /> */}
</Row>
<Row className="grid-cell col-span-4"></Row>
<Row className="grid-cell col-span-1"></Row>
</Row>
</header>
);
}

View File

@ -0,0 +1,29 @@
import LinkList from "@/twui/components/elements/LinkList";
import Main from "@/twui/components/layout/Main";
import Row from "@/twui/components/layout/Row";
import Stack from "@/twui/components/layout/Stack";
import { PropsWithChildren } from "react";
import { AdminAsideLinks } from "./(data)/links";
import Header from "./header";
type Props = PropsWithChildren & {};
export default function Layout({ children }: Props) {
return (
<Main className="w-screen h-screen overflow-hidden p-4 lg:p-10">
<div className="grid-frame grid-cols-6 w-full h-full grid-rows-[64px_1fr]">
<Header />
<Stack className="grid-cell col-span-1">
<LinkList
links={AdminAsideLinks}
className="w-full flex-col"
linkProps={{
className: "turboci-admin-aside-link",
}}
/>
</Stack>
<Stack className="grid-cell col-span-5">{children}</Stack>
</div>
</Main>
);
}

View File

@ -1,5 +1,12 @@
import Logo from "@/src/components/general/logo";
import Center from "@/twui/components/layout/Center";
import Container from "@/twui/components/layout/Container";
import Divider from "@/twui/components/layout/Divider";
import Main from "@/twui/components/layout/Main";
import Row from "@/twui/components/layout/Row";
import Section from "@/twui/components/layout/Section";
import Spacer from "@/twui/components/layout/Spacer";
import Stack from "@/twui/components/layout/Stack";
import { PropsWithChildren } from "react";
type Props = PropsWithChildren & {};
@ -7,7 +14,26 @@ type Props = PropsWithChildren & {};
export default function Layout({ children }: Props) {
return (
<Main className="w-screen h-screen overflow-hidden">
<Center>{children}</Center>
<Section className="w-full h-full">
<Container className="grid-frame section-grid grid-cols-1 h-full">
<Stack className="w-full justify-between h-full">
<Stack className="gap-0">
<Row>
<Row className="p-6">
<Logo />
</Row>
<Divider vertical />
</Row>
<Divider />
</Stack>
<Center className="p-10">{children}</Center>
<Stack>
<Divider />
<Spacer className="h-20 w-full" />
</Stack>
</Stack>
</Container>
</Section>
</Main>
);
}

View File

@ -1,5 +1,4 @@
import "@/src/styles/globals.css";
import type { AppProps } from "next/app";
export default function App({ Component, pageProps }: AppProps) {

18
src/pages/admin/index.tsx Normal file
View File

@ -0,0 +1,18 @@
import Main from "@/src/components/pages/home";
import defaultAdminProps from "@/src/functions/pages/admin/default-admin-props";
import Layout from "@/src/layouts/admin";
import { GetServerSideProps } from "next";
export default function AdminDashboard() {
return (
<Layout>
<Main />
</Layout>
);
}
export const getServerSideProps: GetServerSideProps = async (ctx) => {
return await defaultAdminProps({
ctx,
});
};

View File

@ -0,0 +1,37 @@
import { APIReqObject } from "@/src/types";
import loginUser from "@/src/utils/login-user";
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<APIResponseObject>,
) {
try {
if (req.method !== "POST") {
return res.json({
success: false,
msg: `Wrong Method`,
});
}
const { email, username, password } = req.body as APIReqObject;
if (typeof password !== "string" || !password?.match(/./)) {
throw new Error(`Password is required!`);
}
const logged_in_user = await loginUser({
res,
email_or_username: email || username,
password,
});
return res.json(logged_in_user);
} catch (error: any) {
return res.json({
success: false,
msg: error.message,
});
}
}

View File

@ -0,0 +1,103 @@
import { NSQLITE_TEST_DB_USERS, NSQLiteTables } from "@/src/db/types";
import { APIReqObject } from "@/src/types";
import loginUser from "@/src/utils/login-user";
import hashPassword from "@moduletrace/datasquirel/dist/package-shared/functions/dsql/hashPassword";
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
import NSQLite from "@moduletrace/nsqlite";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<APIResponseObject>,
) {
try {
if (req.method !== "POST") {
return res.json({
success: false,
msg: `Wrong Method`,
});
}
const { new_user } = req.body as APIReqObject;
if (!new_user) {
throw new Error(`No new User Object Passed!`);
}
if (!new_user.password) {
throw new Error(`New User Password is required.`);
}
const existing_users_res = await NSQLite.select<
NSQLITE_TEST_DB_USERS,
(typeof NSQLiteTables)[number]
>({
table: "users",
});
if (existing_users_res.payload?.[0]?.id) {
return res.json({
success: false,
msg: `Super Admin User already exists. Other Users can be created by this user.`,
});
}
const { first_name, email, last_name, password } = new_user;
const new_user_password = hashPassword({ password });
const new_user_insert_res = await NSQLite.insert<
NSQLITE_TEST_DB_USERS,
(typeof NSQLiteTables)[number]
>({
data: [
{
first_name,
last_name,
email,
password: new_user_password,
is_super_admin: 1,
},
],
table: "users",
});
console.log("new_user_insert_res", new_user_insert_res);
const new_user_id = new_user_insert_res.postInsertReturn?.insertId;
if (!new_user_id) {
throw new Error(`Couldn't create New User.`);
}
const newly_inserted_user_res = await NSQLite.select<
NSQLITE_TEST_DB_USERS,
(typeof NSQLiteTables)[number]
>({
table: "users",
query: {
query: {
is_super_admin: { value: "1" },
},
},
});
const newly_inserted_user = newly_inserted_user_res.singleRes;
if (!newly_inserted_user?.id) {
throw new Error(`Couldn't Find Newly inserted user.`);
}
const logged_in_user = await loginUser({
res,
user_id: newly_inserted_user.id,
});
return res.json(logged_in_user);
} catch (error: any) {
return res.json({
success: false,
msg: error.message,
});
}
}

View File

@ -1,5 +1,8 @@
import Main from "@/src/components/pages/auth/login";
import Layout from "@/src/layouts/login";
import userAuth from "@/src/utils/user-auth";
import NSQLite from "@moduletrace/nsqlite";
import { GetServerSideProps } from "next";
export default function LoginPage() {
return (
@ -8,3 +11,31 @@ export default function LoginPage() {
</Layout>
);
}
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const users = await NSQLite.select({ table: "users" });
if (!users.payload?.[0]) {
return {
redirect: {
destination: `/auth/signup`,
statusCode: 307,
},
};
}
const { singleRes: user } = await userAuth({ req: ctx.req });
if (user?.logged_in_status) {
return {
redirect: {
destination: `/admin`,
statusCode: 307,
},
};
}
return {
props: {},
};
};

29
src/pages/auth/signup.tsx Normal file
View File

@ -0,0 +1,29 @@
import Main from "@/src/components/pages/auth/signup";
import Layout from "@/src/layouts/login";
import NSQLite from "@moduletrace/nsqlite";
import { GetServerSideProps } from "next";
export default function LoginPage() {
return (
<Layout>
<Main />
</Layout>
);
}
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const users = await NSQLite.select({ table: "users" });
if (users.payload?.[0]) {
return {
redirect: {
destination: `/auth/login`,
statusCode: 307,
},
};
}
return {
props: {},
};
};

View File

@ -1,5 +1,6 @@
import Main from "@/src/components/pages/home";
import Layout from "@/src/layouts/login";
import { GetServerSideProps } from "next";
export default function Home() {
return (
@ -8,3 +9,12 @@ export default function Home() {
</Layout>
);
}
export const getServerSideProps: GetServerSideProps = async () => {
return {
redirect: {
destination: "/admin",
statusCode: 307,
},
};
};

31
src/server/index.ts Normal file
View File

@ -0,0 +1,31 @@
import { createServer } from "http";
import next from "next";
const port = parseInt(process.env.PORT || "3000", 10);
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
createServer((req, res) => {
if (!req.url) {
return;
}
const full_href = `${process.env.NEXT_PUBLIC_HOST}${req.url}`;
const url = new URL(full_href);
if (url.pathname.match(/^\/api\//)) {
return;
}
handle(req, res);
}).listen(port);
console.log(
`> Server listening at http://localhost:${port} as ${
dev ? "development" : process.env.NODE_ENV
}`,
);
});

View File

@ -1,13 +1,87 @@
@import "../../twui/components/base.css";
@theme inline {
--breakpoint-xs: 350px;
--font-sans: var(--font-body);
--font-display: var(--font-space-grotesk);
--font-mono: var(--font-geist-mono);
--color-background-dark: #05070b;
--color-foreground-dark: #f4f7fb;
--color-background-light: #ffffff;
--color-foreground-light: #171717;
--color-background-dark: #0c0e11;
--color-foreground-dark: #ededed;
--color-price: #16946a;
--color-price-dark: #77ceb1;
--color-dark: #000000;
--color-primary: #3ecf8e;
--color-primary-hover: #3ecf8e;
--color-primary-outline: #3ecf8e;
--color-primary-text: #000000;
--color-primary-dark: #3ecf8e;
--color-primary-dark-hover: #3ecf8e;
--color-primary-dark-outline: #3ecf8e;
--color-primary-dark-text: #000000;
--radius-default: 3px;
}
.grid-frame {
@apply grid bg-foreground-light/10 dark:bg-foreground-dark/10;
@apply gap-px p-px;
}
.grid-frame.nested-grid-frame {
@apply grid bg-transparent! dark:bg-transparent!;
@apply gap-px p-0! h-full;
}
.grid-frame.nested-grid-frame .grid-cell {
@apply h-full;
}
.grid-frame > .grid-cell {
@apply bg-background-light dark:bg-background-dark;
}
.grid-cell-content {
@apply p-10;
}
.twui-button,
.twui-button .twui-button-content-wrapper {
@apply font-semibold;
}
.twui-section {
@apply p-0!;
}
.twui-stack {
@apply w-full items-stretch;
}
.twui-button .twui-loading .text-gray {
@apply text-primary!;
}
.twui-button-primary .twui-loading .fill-primary {
@apply text-primary-text;
}
.twui-h1,
.twui-h2,
.twui-h3 {
@apply m-0!;
}
.turboci-admin-aside-link {
@apply w-full border-foreground-light/10 dark:border-r-foreground-dark/10 py-4 px-6;
@apply text-foreground-light;
}
.turboci-admin-aside-link.active {
@apply bg-foreground-light/5 dark:bg-foreground-dark/5;
}
.turboci-admin-aside-link.active * {
@apply font-semibold;
}

View File

@ -1,3 +1,7 @@
import { DATASQUIREL_LoggedInUser } from "@moduletrace/datasquirel/dist/package-shared/types";
export type User = DATASQUIREL_LoggedInUser & {};
export const CloudProviders = [
{
title: "Hetzner",
@ -164,3 +168,36 @@ export type ServiceScriptObject = {
deployment_name: string;
work_dir?: string;
};
export type PagePropsType = {};
export type URLQueryType = {};
export type APIReqObject = {
username?: string;
email?: string;
password?: string;
new_user?: TurboCISignupFormObject;
};
export type LoginFormData = {
username?: string;
email?: string;
password?: string;
};
export type AlertObject = {
text?: string;
field_name?: string;
};
export type WebSocketData = {
user: User;
};
export type TurboCISignupFormObject = {
first_name?: string;
last_name?: string;
email?: string;
password?: string;
confirmed_password?: string;
};

View File

@ -0,0 +1,93 @@
import { CookieOptions } from "@moduletrace/datasquirel/dist/package-shared/types";
import dayjs, { type Dayjs } from "dayjs";
import * as http from "http";
import { NextApiResponse } from "next";
type FinalCookieOpts = Omit<CookieOptions, "expires"> & { expires?: Dayjs };
type Cookie = { name: string; value: string; options: FinalCookieOpts };
export function setCookie(
res: http.ServerResponse | NextApiResponse,
cookies: Cookie[],
): void {
const AllCookieParts: string[][] = [];
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const { name, options, value } = cookie;
const cookieParts: string[] = [
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
];
if (options.expires) {
cookieParts.push(
`Expires=${options.expires.toDate().toUTCString()}`,
);
}
if (options.maxAge !== undefined) {
cookieParts.push(`Max-Age=${options.maxAge}`);
}
if (options.path) {
cookieParts.push(`Path=${options.path}`);
}
if (options.domain) {
cookieParts.push(`Domain=${options.domain}`);
}
if (options.secure) {
cookieParts.push("Secure");
}
if (options.httpOnly) {
cookieParts.push("HttpOnly");
}
AllCookieParts.push(cookieParts);
}
const final_cookie_string = AllCookieParts.map((ck) => ck.join("; "));
res.setHeader("Set-Cookie", final_cookie_string);
}
export function getCookie(
req: http.IncomingMessage,
name: string,
): string | null {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) return null;
const cookies = cookieHeader
.split(";")
.reduce((acc: { [key: string]: string }, cookie: string) => {
const [key, val] = cookie.trim().split("=").map(decodeURIComponent);
acc[key] = val;
return acc;
}, {});
return cookies[name] || null;
}
export function updateCookie(
res: http.ServerResponse,
cookies: Cookie[],
): void {
setCookie(res, cookies);
}
export function deleteCookie(
res: http.ServerResponse,
cookies: Cookie[],
): void {
setCookie(
res,
cookies.map((ck) => ({
...ck,
value: "",
options: {
...ck.options,
expires: dayjs().subtract(1, "day"),
maxAge: 0,
},
})),
);
}

140
src/utils/login-user.ts Normal file
View File

@ -0,0 +1,140 @@
import { NextApiResponse } from "next";
import { ServerResponse } from "http";
import NSQLite from "@moduletrace/nsqlite";
import { NSQLITE_TEST_DB_USERS, NSQLiteTables } from "../db/types";
import { User } from "../types";
import { AppData } from "../data/app-data";
import { setCookie } from "./cookies-actions";
import { EJSON } from "../exports/client-exports";
import encrypt from "@moduletrace/datasquirel/dist/package-shared/functions/dsql/encrypt";
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
import hashPassword from "@moduletrace/datasquirel/dist/package-shared/functions/dsql/hashPassword";
import dayjs from "dayjs";
type Params = {
res: NextApiResponse | ServerResponse;
user_id?: string | number;
password?: string;
email_or_username?: string;
};
export default async function loginUser({
res,
user_id,
password,
email_or_username,
}: Params): Promise<APIResponseObject> {
let fetched_user: NSQLITE_TEST_DB_USERS | undefined;
if (user_id) {
const user_res = await NSQLite.select<
NSQLITE_TEST_DB_USERS,
(typeof NSQLiteTables)[number]
>({
table: "users",
targetId: user_id,
});
if (!user_res.singleRes?.id) {
throw new Error(`Couldn't Find user for login`);
}
fetched_user = user_res.singleRes;
}
if (email_or_username) {
const user_res = await NSQLite.select<
NSQLITE_TEST_DB_USERS,
(typeof NSQLiteTables)[number]
>({
table: "users",
query: {
query: {
email: {
value: email_or_username,
},
username: {
value: email_or_username,
},
},
searchOperator: "OR",
},
});
if (!user_res.singleRes?.id) {
throw new Error(`Couldn't Find user for login`);
}
fetched_user = user_res.singleRes;
}
if (!fetched_user) {
return {
success: false,
msg: `User Not Found!`,
};
}
if (password) {
const hashed_password = hashPassword({ password });
if (hashed_password !== fetched_user.password) {
return {
success: false,
msg: `Invalid Password.`,
};
}
}
const now = Date.now();
const csrf_k =
Math.random().toString(36).substring(2) +
"-" +
Math.random().toString(36).substring(2);
const logged_in_user_payload: User = {
first_name: fetched_user.first_name!,
last_name: fetched_user.last_name!,
date: now,
email: fetched_user.email!,
csrf_k,
id: fetched_user.id!,
logged_in_status: true,
image: fetched_user.image,
image_thumbnail: fetched_user.image,
};
const payload_string = EJSON.stringify(logged_in_user_payload);
const encrypted_payload = encrypt({ data: payload_string || "" });
const expiration_date = dayjs(Date.now()).add(7, "days");
expiration_date.add(7, "days");
setCookie(res, [
{
name: AppData["AuthCookieName"],
value: encrypted_payload || "",
options: {
secure: process.env.DOMAIN !== "localhost",
path: "/",
expires: expiration_date,
domain: process.env.DOMAIN,
},
},
{
name: AppData["AuthCSRFCookieName"],
value: csrf_k,
options: {
path: "/",
expires: expiration_date,
domain: process.env.DOMAIN,
},
},
]);
return {
success: true,
singleRes: logged_in_user_payload,
};
}

View File

@ -0,0 +1,21 @@
export default function parsePageUrl(url?: string, admin?: boolean) {
if (!url) return null;
let finalAdminUrlArray = url?.match(/_next/)
? null
: url
?.split("?")[0]
.split("#")[0]
.split("/")
.filter((item) => item !== "");
if (admin) {
finalAdminUrlArray?.splice(1, 1);
}
const finalAdminUrl = finalAdminUrlArray
? "/" + finalAdminUrlArray?.join("/") || ""
: null;
return finalAdminUrl;
}

View File

@ -1,12 +1,62 @@
import datasquirel from "@moduletrace/datasquirel";
import { NextApiRequest } from "next";
import { User } from "../types";
import { IncomingMessage } from "http";
import { AppData } from "../data/app-data";
import { getCookie } from "./cookies-actions";
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
import decrypt from "@moduletrace/datasquirel/dist/package-shared/functions/dsql/decrypt";
import { EJSON } from "../exports/client-exports";
type Params = {
req: NextApiRequest;
req:
| NextApiRequest
| (IncomingMessage & { cookies: Partial<{ [key: string]: string }> });
};
export default async function userAuth({ req }: Params) {
const auth = datasquirel.user.auth.auth({ req });
const user = auth.payload;
return { user };
export default async function userAuth({
req,
}: Params): Promise<APIResponseObject<User>> {
try {
const key = getCookie(req, AppData["AuthCookieName"]);
if (!key) {
return {
success: false,
msg: `No ${AppData["AuthCookieName"]} found in request object.`,
};
}
const decrypted_key = decrypt({ encryptedString: key });
const decrypted_object = EJSON.parse(decrypted_key) as User | undefined;
if (!decrypted_object?.id) {
return {
success: false,
msg: `Invalid Auth Key`,
};
}
const csrf = getCookie(req, AppData["AuthCSRFCookieName"]);
if (!csrf) {
return {
success: false,
msg: `No ${AppData["AuthCSRFCookieName"]} found in request object.`,
};
}
if (csrf !== decrypted_object.csrf_k) {
return {
success: false,
msg: `CSRF mismatch`,
};
}
return {
success: true,
singleRes: decrypted_object,
};
} catch (error) {
return { success: false };
}
}

35
src/websocket/index.ts Normal file
View File

@ -0,0 +1,35 @@
import { WebSocketData } from "../types";
import socketInit from "./socket-init";
const server = Bun.serve<WebSocketData>({
async fetch(req, server) {
const { user } = await socketInit({ req });
if (!user?.logged_in_status) {
return new Response("Unauthorized!");
}
const success = server.upgrade(req, {
data: { user },
});
if (success) {
return undefined;
}
return new Response("Web Socket Connection Failed!");
},
websocket: {
async message(ws, message) {
if (typeof message == "string") {
}
},
async open(ws) {},
async close(ws, code, message) {},
idleTimeout: 600,
maxPayloadLength: 1024 * 1024 * 10,
},
port: process.env.WEB_SOCKET_PORT,
});
console.log(`Websocket Listening on http://${server.hostname}:${server.port}`);

View File

@ -0,0 +1,39 @@
import datasquirel from "@moduletrace/datasquirel";
import { User } from "../types";
type Param = {
req: Request;
debug?: boolean;
};
type Return = {
user: User | null;
};
export default async function socketInit({
req,
debug,
}: Param): Promise<Return> {
const cookieString = req.headers.get("Cookie") || undefined;
if (debug) {
console.log("DEBUG:::socketInit:cookieString", cookieString);
}
if (!cookieString)
return {
user: null,
};
const user = datasquirel.user.auth.auth({
cookieString,
database: process.env.DSQL_DB_NAME || "",
skipFileCheck: true,
});
if (debug) {
console.log("DEBUG:::socketInit:user", user);
}
return { user: user.payload as User | null };
}

View File

@ -1,29 +1,37 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"exclude": ["node_modules"]
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}

2
twui

@ -1 +1 @@
Subproject commit 89e673bb59809ba2be04a43ed8c9b5775c580237
Subproject commit 9f8527fc4d851c1fecd6600bd60c490de998676f