feat: challenge registry-driven platform + XVI/XVII imports + admin toggle + domain rename

- Rename repo/domain: attack-defense-platform / attackdefense.imrnes.team (all refs replaced)
- challenge_registry.json: single source of truth (28 challs across gemastik18/xvi/xvii)
- teams.py: registry-driven CHALLENGES, set_challenge_enabled, sync_challenge_runtime
  (apply enable/disable to live teams: build/up or stop/remove + receiver restart)
- compose_gen.py: render per-team compose from canonical per-challenge templates
  (image reuse, per-team ports 30xxx, flag mounts, passwords)
- gen_canonical_composes.py: canonical docker-compose.yml for all services
- import_new_challenges.py: import XVI/XVII services + EOL base image fixes
  (debian:buster→bookworm, node:14→20, python:3.7-slim→3.11)
- receiver: xvi package (10 checkers) + xvii package (12 generic checkers),
  Challenge base reads PASSWORD_<team_port> from env; gen_receiver_main.py
  generates per-team main.py from registry
- main.py: /api/challenges returns full registry; PATCH /api/challenges/<name>
  toggles enabled + applies to live teams
- index.html: 🏗️ Challenge Manager tab (toggle per challenge, grouped by set)
- SLA bonus now dynamic (all enabled challenges, not hardcoded 6)
This commit is contained in:
MythEclipse committed 2026-09-25 14:04:33 +08:00
1 parent c35b23a37f
commit c6fd9ec268
1317 files changed
+306586 -128

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
**/node_modules/
**/dist/
**/*.so
**/*.o
+5
View File
@@ -0,0 +1,5 @@
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
+67
View File
@@ -0,0 +1,67 @@
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml /app/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY ./frontend/ /app/
RUN pnpm run build
FROM ghcr.io/circleous/httpd:latest@sha256:8a353b1208a4871233845d9a84eb4f40c4581a7acaed505de4ccdd52c8d56615
WORKDIR /app
COPY ./kauth /app/kauth/
RUN set eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
openssh-server \
pkg-config \
gcc \
curl \
make \
lua5.3 \
liblua5.3-dev \
libsodium-dev \
libsqlite3-dev \
luarocks \
; \
luarocks-5.3 install lua-cjson; \
luarocks-5.3 install lsqlite3; \
luarocks-5.3 install bcrypt; \
cd kauth; \
luarocks-5.3 make; \
cd ..; \
rm -rf kauth;\
apt-get remove -y \
pkg-config \
gcc \
make \
lua5.3 \
liblua5.3-dev; \
echo root:${PASSWORD} | chpasswd \
echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config; \
echo "PermitRootLogin yes" >> /etc/ssh/sshd_config; \
service ssh start; \
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false;
COPY httpd-foreground /usr/local/bin/
COPY ./httpd.conf /usr/local/apache2/conf/httpd.conf
COPY --chown=www-data:www-data ./fjb.db /app/data/fjb.db
COPY --from=build /app/dist /usr/local/apache2/htdocs
COPY ./src/ /app/lua/
RUN sed -ri "s/SECRETSECRETSECRETSECRETSECRETSE/$(openssl rand -hex 16)/g" /app/lua/secret.lua && \
chmod 644 /app/lua/secret.lua
EXPOSE 80
CMD ["httpd-foreground"]
+3
View File
@@ -0,0 +1,3 @@
Acquire::AllowInsecureRepositories "true";
Acquire::AllowDowngradeToInsecureRepositories "true";
Apt::Get::AllowUnauthenticated "true";
+18
View File
@@ -0,0 +1,18 @@
services:
fjb:
container_name: fjb_container
hostname: fjb
restart: always
build:
context: .
args:
- PASSWORD=$PASSWORD_17000
volumes:
- ../receiver/flags/fjb.txt:/flag.txt:ro
- ../utils/bashrc:/root/.bashrc:ro
- ../utils/preexec.sh:/root/.preexec.sh:ro
ports:
- "17000:80"
- "17022:22"
extra_hosts:
- "host.docker.internal:host-gateway"
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+39
View File
@@ -0,0 +1,39 @@
import js from "@eslint/js";
import globals from "globals";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
export default [
{ ignores: ["dist"] },
{
files: ["**/*.{js,jsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: "latest",
ecmaFeatures: { jsx: true },
sourceType: "module",
},
},
settings: { react: { version: "18.3" } },
plugins: {
react,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs["jsx-runtime"].rules,
...reactHooks.configs.recommended.rules,
"react/jsx-no-target-blank": "off",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"react/prop-types": "off",
},
},
];
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FJB</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-router": "^1.58.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"ky": "^1.7.2",
"lucide-react": "^0.441.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@tanstack/router-devtools": "^1.58.3",
"@tanstack/router-plugin": "^1.58.4",
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.12",
"vite": "^5.4.1"
}
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f38ba8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-store"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"/><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"/><path d="M2 7h20"/><path d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 Line truncated

After

Width:  |  Height:  |  Size: 690 B

@@ -0,0 +1,238 @@
import { useCallback, useState, useEffect } from "react";
import {
Loader2,
ShoppingBag,
ShoppingCart,
LogOut,
LogIn,
Plus,
Eye,
EyeOff,
} from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useLocation } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import useAuth from "@/hooks/useAuth";
import router from "@/lib/router";
import httpClient from "@/lib/httpClient";
function AuthButton() {
const navigate = useNavigate();
const { isPending, authSuccess } = useAuth();
const queryClient = useQueryClient();
const logout = useCallback(
(e) => {
e.preventDefault();
document.cookie = "session=";
queryClient.invalidateQueries({ queryKey: ["user"] });
router.invalidate();
navigate({ to: "/login" });
},
[queryClient, navigate],
);
if (isPending) {
return (
<Button className="flex items-center gap-2" disabled>
<Loader2 className="h-5 w-5 animate-spin" />
<span className="hidden sm:inline">Loading</span>
</Button>
);
}
if (authSuccess) {
return (
<Button className="flex items-center gap-2" onClick={logout}>
<LogOut className="h-5 w-5" />
<span className="hidden sm:inline">Logout</span>
</Button>
);
}
return (
<Link to="/login">
<Button className="flex items-center gap-2">
<LogIn className="h-5 w-5" />
<span className="hidden sm:inline">Login</span>
</Button>
</Link>
);
}
export default function Navigation() {
const [data, setData] = useState({
name: "",
description: "",
valuable: "",
price: "",
});
const [valueHidden, setValueHidden] = useState(true);
const { pathname } = useLocation();
const [dialogClass, setDialogClass] = useState(null);
const [open, setOpen] = useState(false);
const { isSuccess: authSuccess } = useAuth();
const queryClient = useQueryClient();
const { isPending, mutate } = useMutation({
mutationKey: ["post-add-item"],
mutationFn: (data) => httpClient.post("/api/catalog", { json: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
setOpen(false);
},
});
useEffect(() => {
if (pathname === "/" && authSuccess) {
setDialogClass("flex items-center gap-2");
} else {
setDialogClass("hidden");
}
}, [pathname, authSuccess]);
const handleChange = (event) => {
const { name, value } = event.target;
setData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (event) => {
event.preventDefault();
mutate(data);
};
const toggleValuableVisibility = () => {
setValueHidden((prev) => !prev);
};
return (
<div className="flex justify-between items-center mb-6 flex-wrap gap-4">
<Link to="/">
<h1 className="text-3xl font-bold">FJB</h1>
</Link>
<div className="flex gap-2">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className={dialogClass}>
<Plus className="h-5 w-5" />
<span className="hidden sm:inline">Add Item</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Item</DialogTitle>
<DialogDescription>
Enter the details of the item you want to add to the
marketplace.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
name="name"
value={data.name}
onChange={handleChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
</Label>
<Textarea
id="description"
name="description"
value={data.description}
onChange={handleChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="valuable" className="text-right">
Valuable
</Label>
<div className="col-span-3 flex">
<Input
id="valuable"
name="valuable"
type={valueHidden ? "text" : "password"}
value={data.valuable}
onChange={handleChange}
className="flex-grow"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={toggleValuableVisibility}
className="ml-2"
>
{valueHidden ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="price" className="text-right">
Price
</Label>
<Input
id="price"
name="price"
value={data.price}
onChange={handleChange}
type="number"
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending && (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary border-r-transparent" />
)}
Submit
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Link to="/">
<Button variant="outline" className="flex items-center gap-2">
<ShoppingBag className="h-5 w-5" />
<span className="hidden sm:inline">Marketplace</span>
</Button>
</Link>
<Link to="/cart">
<Button variant="outline" className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5" />
<span className="hidden sm:inline">Cart</span>
</Button>
</Link>
<AuthButton />
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }
@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />)
);
})
Button.displayName = "Button"
export { Button, buttonVariants }
@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,94 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[4Line truncated
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }
@@ -0,0 +1,16 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,41 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
(<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props} />)
);
})
Textarea.displayName = "Textarea"
export { Textarea }
@@ -0,0 +1,24 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+9
View File
@@ -0,0 +1,9 @@
const PAGE = {
LOGIN: 0,
MARKETPLACE: 1,
CART: 2,
};
const TOKEN_KEY = "TOKEN";
export { PAGE, TOKEN_KEY };
@@ -0,0 +1,9 @@
import httpClient from "@/lib/httpClient";
import { useQuery } from "@tanstack/react-query";
export default function useAuth() {
return useQuery({
queryKey: ["user"],
queryFn: () => httpClient.get("/api/user").json(),
});
}
+67
View File
@@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import router from "@/lib/router";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
createRoot(document.getElementById("root")).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
@@ -0,0 +1,159 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { Loader2, Trash2 } from "lucide-react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import httpClient from "@/lib/httpClient";
import useAuth from "@/hooks/useAuth";
export default function CartCheckout() {
const auth = useAuth();
const queryClient = useQueryClient();
const { isLoading, data: cart } = useQuery({
queryKey: ["cart"],
queryFn: () => httpClient("/api/cart").json(),
enabled: auth.isSuccess,
});
const { mutate } = useMutation({
mutationKey: ["delete-cart"],
mutationFn: (id) =>
httpClient
.delete("/api/cart", {
searchParams: new URLSearchParams([["id", id]]),
})
.json(),
onSuccess: async () => {
queryClient.invalidateQueries({
queryKey: ["cart"],
});
},
});
const calculateTotal = () => {
return cart ? cart.items.reduce((total, item) => total + item.price, 0) : 0;
};
const handleDelete = useCallback(
(index) => {
mutate(index);
},
[mutate],
);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<>
{!cart || !cart.items || cart.items.length === 0 ? (
<div className="flex justify-center items-center">
Your cart is empty
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Your Items</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Price</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cart.items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>${item.price.toFixed(2)}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle>Order Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span>Subtotal</span>
<span>${calculateTotal().toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Shipping</span>
<span>Free</span>
</div>
<div className="flex justify-between font-bold">
<span>Total</span>
<span>${calculateTotal().toFixed(2)}</span>
</div>
</div>
</CardContent>
<CardFooter>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<Button
variant="outline"
disabled
className="cursor-not-allowed w-full"
>
{/* TODO: create checkout */}
<span>Proceed to Payment</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>This action is currently unavailable</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardFooter>
</Card>
</div>
)}
</>
);
}
@@ -0,0 +1,142 @@
import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import httpClient from "@/lib/httpClient";
export default function LoginRegister() {
const usernameRef = useRef();
const passwordRef = useRef();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { isLoading, mutate } = useMutation({
mutationKey: ["login"],
mutationFn: (data) => httpClient.post("/api/login", { json: data }).json(),
onSettled(data, err) {
if (err || data?.error) {
queryClient.setQueryData(["user"], null);
return;
}
queryClient.setQueryData(["user"], data);
navigate({ to: "/" });
},
retry: false,
});
async function onSubmit(event) {
event.preventDefault();
const username = usernameRef.current.value;
const password = passwordRef.current.value;
mutate({ username, password });
}
return (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Login or create a new account.</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="login" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">Login</TabsTrigger>
<TabsTrigger value="register">Register</TabsTrigger>
</TabsList>
<TabsContent value="login">
<form onSubmit={onSubmit}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder=""
type="username"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
disabled={isLoading}
ref={usernameRef}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="password">Password</Label>
<Input
id="password"
placeholder="********"
type="password"
autoCapitalize="none"
autoComplete="current-password"
disabled={isLoading}
ref={passwordRef}
/>
</div>
<Button disabled={isLoading}>
{isLoading && (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary border-r-transparent" />
)}
Sign In
</Button>
</div>
</form>
</TabsContent>
<TabsContent value="register">
<form onSubmit={onSubmit}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder=""
type="username"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
disabled={isLoading}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="password">Password</Label>
<Input
id="password"
placeholder="********"
type="password"
autoCapitalize="none"
autoComplete="new-password"
disabled={isLoading}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
placeholder="********"
type="password"
autoCapitalize="none"
autoComplete="new-password"
disabled={isLoading}
/>
</div>
<Button disabled={isLoading}>
{isLoading && (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary border-r-transparent" />
)}
Create Account
</Button>
</div>
</form>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
@@ -0,0 +1,94 @@
import { useCallback } from "react";
import { formatDistanceToNow } from "date-fns";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShoppingBasket, Loader2 } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import httpClient from "@/lib/httpClient";
export default function MarketplaceListing() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["items"],
queryFn: () => httpClient.get("/api/catalog").json(),
});
const { mutate } = useMutation({
mutationKey: ["post-cart"],
mutationFn: (data) => httpClient.post("/api/cart", { json: data }).json(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
const handleAddToCart = useCallback(
(id) => {
mutate({ item: id });
},
[mutate],
);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!data) {
return (
<div className="flex justify-center items-center h-screen">
No data available
</div>
);
}
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.items.map((item) => (
<Card key={item.id} className="flex flex-col">
<CardHeader>
<CardTitle>{item.name}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<p className="text-2xl font-bold">${item.price.toFixed(2)}</p>
<p className="text-sm text-gray-500">
Posted {formatDistanceToNow(new Date(item.date_posted))} ago
</p>
</CardContent>
<CardFooter className="flex justify-between items-center">
<Badge variant="secondary">Owner ID: {item.owner}</Badge>
<Button
className="flex items-center gap-2"
onClick={(e) => {
e.preventDefault();
console.log(e.target);
e.target.setAttribute("disabled", "true");
handleAddToCart(item.id);
setTimeout(() => {
e.target.removeAttribute("disabled");
}, 1000);
}}
>
<ShoppingBasket className="h-5 w-5" />
<span className="hidden sm:inline">Add to Cart</span>
</Button>
</CardFooter>
</Card>
))}
</div>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
/* prettier-ignore-start */
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file is auto-generated by TanStack Router
import { createFileRoute } from '@tanstack/react-router'
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as CartImport } from './routes/cart'
// Create Virtual Routes
const LoginLazyImport = createFileRoute('/login')()
const IndexLazyImport = createFileRoute('/')()
// Create/Update Routes
const LoginLazyRoute = LoginLazyImport.update({
path: '/login',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/login.lazy').then((d) => d.Route))
const CartRoute = CartImport.update({
path: '/cart',
getParentRoute: () => rootRoute,
} as any)
const IndexLazyRoute = IndexLazyImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexLazyImport
parentRoute: typeof rootRoute
}
'/cart': {
id: '/cart'
path: '/cart'
fullPath: '/cart'
preLoaderRoute: typeof CartImport
parentRoute: typeof rootRoute
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginLazyImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export interface FileRoutesByFullPath {
'/': typeof IndexLazyRoute
'/cart': typeof CartRoute
'/login': typeof LoginLazyRoute
}
export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/cart': typeof CartRoute
'/login': typeof LoginLazyRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexLazyRoute
'/cart': typeof CartRoute
'/login': typeof LoginLazyRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/cart' | '/login'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/cart' | '/login'
id: '__root__' | '/' | '/cart' | '/login'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexLazyRoute: typeof IndexLazyRoute
CartRoute: typeof CartRoute
LoginLazyRoute: typeof LoginLazyRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexLazyRoute: IndexLazyRoute,
CartRoute: CartRoute,
LoginLazyRoute: LoginLazyRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* prettier-ignore-end */
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.jsx",
"children": [
"/",
"/cart",
"/login"
]
},
"/": {
"filePath": "index.lazy.jsx"
},
"/cart": {
"filePath": "cart.jsx"
},
"/login": {
"filePath": "login.lazy.jsx"
}
}
}
ROUTE_MANIFEST_END */
@@ -0,0 +1,15 @@
import Navigation from "@/components/navigation";
import { createRootRoute, Outlet } from "@tanstack/react-router";
function RootComponent() {
return (
<div className="container mx-auto py-8 px-4">
<Navigation />
<Outlet />
</div>
);
}
export const Route = createRootRoute({
component: () => <RootComponent />,
});
@@ -0,0 +1,6 @@
import CartCheckout from '@/pages/cart-checkout'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/cart')({
component: () => <CartCheckout />,
})
@@ -0,0 +1,6 @@
import MarketplaceListing from '@/pages/marketplace-lisiting'
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/')({
component: () => <MarketplaceListing />,
})
@@ -0,0 +1,10 @@
import LoginRegister from "@/pages/login-register";
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/login")({
component: () => (
<div className="flex justify-center bg-background">
<LoginRegister />
</div>
),
});
+57
View File
@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
},
},
plugins: [require("tailwindcss-animate")],
};
+19
View File
@@ -0,0 +1,19 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
server: {
proxy: {
"/api": "http://localhost:17000",
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
# start sshd
/usr/sbin/sshd -D &
# Apache gets grumpy about PID files pre-existing
rm -f /usr/local/apache2/logs/httpd.pid
exec httpd -DFOREGROUND "$@"
+116
View File
@@ -0,0 +1,116 @@
ServerRoot "/usr/local/apache2"
Listen 80
LoadModule mpm_event_module modules/mod_mpm_event.so
LoadModule authn_file_module modules/mod_authn_file.so
#LoadModule authn_dbm_module modules/mod_authn_dbm.so
#LoadModule authn_anon_module modules/mod_authn_anon.so
#LoadModule authn_dbd_module modules/mod_authn_dbd.so
#LoadModule authn_socache_module modules/mod_authn_socache.so
LoadModule authn_core_module modules/mod_authn_core.so
LoadModule authz_host_module modules/mod_authz_host.so
LoadModule authz_groupfile_module modules/mod_authz_groupfile.so
LoadModule authz_user_module modules/mod_authz_user.so
#LoadModule authz_dbm_module modules/mod_authz_dbm.so
#LoadModule authz_owner_module modules/mod_authz_owner.so
#LoadModule authz_dbd_module modules/mod_authz_dbd.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule access_compat_module modules/mod_access_compat.so
LoadModule auth_basic_module modules/mod_auth_basic.so
LoadModule dbd_module modules/mod_dbd.so
LoadModule reqtimeout_module modules/mod_reqtimeout.so
LoadModule filter_module modules/mod_filter.so
LoadModule mime_module modules/mod_mime.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule lua_module modules/mod_lua.so
LoadModule env_module modules/mod_env.so
LoadModule headers_module modules/mod_headers.so
LoadModule setenvif_module modules/mod_setenvif.so
LoadModule version_module modules/mod_version.so
LoadModule unixd_module modules/mod_unixd.so
LoadModule dir_module modules/mod_dir.so
LoadModule alias_module modules/mod_alias.so
LoadModule rewrite_module modules/mod_rewrite.so
<IfModule unixd_module>
User www-data
Group www-data
</IfModule>
ServerAdmin hermit@kaos.engineer
ServerName fjb.kaos.engineer:80
<Directory />
AllowOverride none
Require all denied
</Directory>
#
# Note that from this point forward you must specifically allow
# particular features to be enabled - so if something's not working as
# you might expect, make sure that you have specifically enabled it
# below.
#
#
# DocumentRoot: The directory out of which you will serve your
# documents. By default, all requests are taken from this directory, but
# symbolic links and aliases may be used to point to other locations.
#
DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
AllowOverride None
Options Indexes FollowSymLinks
Require all granted
RewriteEngine on
# Don't rewrite files or directories
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Rewrite everything else to index.html to allow html5 state links
RewriteRule ^ index.html [L]
</Directory>
LuaMapHandler /api/(\w+) /app/lua/handler/api.lua
LuaPackageCPath /usr/local/lib/lua/5.3/?.so
LuaPackagePath /app/lua/?.lua
<IfModule dir_module>
DirectoryIndex index.html
</IfModule>
<Files ".ht*">
Require all denied
</Files>
ErrorLog /proc/self/fd/2
LogLevel warn
<IfModule log_config_module>
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %b" common
CustomLog /proc/self/fd/1 common
</IfModule>
<IfModule alias_module>
#
</IfModule>
<IfModule headers_module>
RequestHeader unset Proxy early
</IfModule>
<IfModule mime_module>
TypesConfig conf/mime.types
AddType application/x-compress .Z
AddType application/x-gzip .gz .tgz
</IfModule>
# Various default settings
#Include conf/extra/httpd-default.conf
+2
View File
@@ -0,0 +1,2 @@
*.o
*.so
+12
View File
@@ -0,0 +1,12 @@
# CFLAGS += -c -O2 -fPIC $$(pkg-config --cflags lua-5.3 libsodium)
# LDFLAGS += -shared -fPIC $$(pkg-config --libs libsodium lua-5.3)
all: default
kauth.so: kauth.c
luarocks make --no-install
clean:
rm kauth.o kauth.so
.PHONY: clean
+26
View File
@@ -0,0 +1,26 @@
package = "kauth"
version = "1.0-1"
source = {
url = "https://kaos.engineer"
}
dependencies = {
"lua >= 5.3",
}
local srcs = {
"kauth.c",
}
build = {
type = "builtin",
modules = {
kauth = {
sources = srcs,
libraries = {"sodium"},
},
},
platforms = {
unix = { modules = { kauth = srcs } },
},
}
+164
View File
@@ -0,0 +1,164 @@
#include <lauxlib.h>
#include <lua.h>
#include <lualib.h>
#include <sodium.h>
#include <string.h>
#include <time.h>
#include "kauth.h"
#define kauth_token_claim_BYTES \
(sodium_base64_ENCODED_LEN(sizeof(struct kauth_user), \
sodium_base64_VARIANT_URLSAFE_NO_PADDING))
#define kauth_token_sign_BYTES \
(sodium_base64_ENCODED_LEN(crypto_auth_hmacsha256_BYTES, \
sodium_base64_VARIANT_URLSAFE_NO_PADDING))
#define kauth_token_BYTES (kauth_token_claim_BYTES + kauth_token_sign_BYTES) - 1
static int encode(lua_State *L) {
int typ;
struct kauth_user claim;
unsigned char h[crypto_auth_hmacsha256_BYTES];
unsigned char token[kauth_token_BYTES];
memset(h, 0, crypto_auth_hmacsha256_BYTES);
memset(token, 0, kauth_token_BYTES);
memset(&claim, 0, sizeof(struct kauth_user));
size_t keylen = 0;
const char *key = luaL_checklstring(L, 1, &keylen);
if (keylen != crypto_auth_hmacsha256_KEYBYTES) {
lua_pushstring(L, "invalid key len");
return lua_error(L);
}
luaL_checktype(L, 2, LUA_TTABLE);
if ((typ = lua_getfield(L, 2, "id")) != LUA_TNUMBER) {
lua_pop(L, 1);
lua_pushstring(L, "invalid type id (number expected)");
return lua_error(L);
}
lua_Integer id = lua_tointeger(L, -1);
lua_pop(L, 1);
claim.id = id;
if ((typ = lua_getfield(L, 2, "name")) != LUA_TSTRING) {
lua_pop(L, 1);
lua_pushstring(L, "invalid type name (string expected)");
return lua_error(L);
}
const char *name = lua_tostring(L, -1);
lua_pop(L, 1);
strcpy((char *)claim.name, name);
claim.exp = time(0) + 1800; // 30 min
crypto_auth_hmacsha256(h, (const unsigned char *)&claim,
sizeof(struct kauth_user), (const unsigned char *)key);
sodium_bin2base64((char *)token, kauth_token_claim_BYTES,
(const unsigned char *)&claim, sizeof(struct kauth_user),
sodium_base64_VARIANT_URLSAFE_NO_PADDING);
sodium_bin2base64((char *)(token + kauth_token_claim_BYTES),
kauth_token_sign_BYTES, h, crypto_auth_hmacsha256_BYTES,
sodium_base64_VARIANT_URLSAFE_NO_PADDING);
token[kauth_token_claim_BYTES - 1] = '.';
lua_pushlstring(L, (char *)token, kauth_token_BYTES);
return 1;
}
static int decode(lua_State *L) {
struct kauth_user claim;
const char name[129];
const unsigned char h[crypto_auth_hmacsha256_BYTES];
memset(&claim, 0, sizeof(struct kauth_user));
memset((void *)name, 0, sizeof(name));
memset((void *)h, 0, sizeof(h));
size_t keylen = 0;
const char *key = luaL_checklstring(L, 1, &keylen);
if (keylen != crypto_auth_hmacsha256_BYTES) {
lua_pushstring(L, "invalid key len");
return lua_error(L);
}
size_t tokenlen = 0;
const char *token = luaL_checklstring(L, 2, &tokenlen);
if (tokenlen != kauth_token_BYTES) {
lua_pushstring(L, "invalid token len");
return lua_error(L);
}
if (token[kauth_token_claim_BYTES - 1] != '.') {
lua_pushstring(L, "invalid token");
return lua_error(L);
}
size_t clen = 0;
const char *b64_end = NULL;
if (sodium_base642bin((unsigned char *)&claim, sizeof(struct kauth_user),
token, kauth_token_claim_BYTES, NULL, &clen, &b64_end,
sodium_base64_VARIANT_URLSAFE_NO_PADDING) != 0) {
lua_pushstring(L, "invalid token: failed to decode claim");
return lua_error(L);
}
strncpy((char *)name, claim.name, sizeof(name));
clen = 0;
b64_end = NULL;
if (sodium_base642bin((unsigned char *)h, crypto_auth_hmacsha256_BYTES,
token + kauth_token_claim_BYTES, kauth_token_sign_BYTES,
NULL, &clen, &b64_end,
sodium_base64_VARIANT_URLSAFE_NO_PADDING) != 0) {
lua_pushstring(L, "invalid token: failed to decode hash");
return lua_error(L);
}
if (crypto_auth_hmacsha256_verify(h, (const unsigned char *)&claim,
sizeof(struct kauth_user),
(const unsigned char *)key) != 0) {
lua_pushstring(L, "invalid token: failed to verify");
return lua_error(L);
}
if (claim.exp <= time(0)) {
lua_pushstring(L, "invalid token: expired");
return lua_error(L);
}
lua_newtable(L);
lua_pushstring(L, name);
lua_setfield(L, -2, "name");
lua_pushinteger(L, claim.id);
lua_setfield(L, -2, "id");
return 1;
}
static const struct luaL_Reg luakauth_lib[] = {
{"encode", encode},
{"decode", decode},
{NULL, NULL},
};
LUALIB_API int luaopen_kauth(lua_State *L) {
if (sodium_init() == -1) {
lua_pushstring(L, "crypto init failed");
return lua_error(L);
}
luaL_newlib(L, luakauth_lib);
return 1;
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <stddef.h>
struct kauth_user {
const char name[128];
unsigned long long id;
unsigned long long exp;
};
+256
View File
@@ -0,0 +1,256 @@
local sqlite3 = require "lsqlite3"
local bcrypt = require "bcrypt"
local _M = {}
function _M.register(username, password)
local user = nil
local err = nil
local conn = sqlite3.open("/app/data/fjb.db", sqlite3.OPEN_READWRITE)
local hashed_password = bcrypt.digest(password, 9)
local stmt = conn:prepare("INSERT INTO users (username, password) VALUES (?, ?)")
stmt:bind_values(username, hashed_password)
local result = stmt:step()
if result == sqlite3.DONE then
user = conn:last_insert_rowid()
elseif result == sqlite3.CONSTRAINT then
else
err = conn:errmsg()
end
conn:close()
return user, err
end
function _M.login(username, password)
local user = nil
local err = nil
local conn = sqlite3.open("/app/data/fjb.db")
local stmt = conn:prepare("SELECT id, username, password FROM users WHERE username = ?")
stmt:bind_values(username)
local result = stmt:step()
if result == sqlite3.ROW then
local hashed_password = stmt:get_value(2)
if bcrypt.verify(password, hashed_password) then
user = {
name = stmt:get_value(1),
id = stmt:get_value(0),
}
end
elseif result == sqlite3.DONE then
else
err = conn:errmsg()
end
conn:close()
return user, err
end
function _M.get_user(id)
local user = nil
local err = nil
local conn = sqlite3.open("/app/data/fjb.db")
local stmt = conn:prepare("SELECT username FROM users WHERE id = ?")
stmt:bind_values(id)
local result = stmt:step()
if result == sqlite3.ROW then
user = stmt:get_value(0)
else
err = conn:errmsg()
end
conn:close()
return user, err
end
function _M.get_wallet(id)
local wallet = nil
local err = nil
local conn = sqlite3.open("/app/data/fjb.db")
local stmt = conn:prepare("SELECT wallet FROM users WHERE id = ?")
stmt:bind_values(id)
local result = stmt:step()
if result == sqlite3.ROW then
wallet = stmt:get_value(0)
else
err = conn:errmsg()
end
conn:close()
return wallet, err
end
function _M.add_item(uid, item_name, description, valuable, price)
local id = nil
local err = nil
local conn = sqlite3.open("/app/data/fjb.db", sqlite3.OPEN_READWRITE)
local created_at = os.date("!%Y-%m-%dT%H:%M:%S+00:00")
local stmt = conn:prepare [[
INSERT INTO catalog
(user_id, item_name, description, value, price, created_at)
VALUES (?, ?, ?, ?, ?, ?)
]]
stmt:bind_values(uid, item_name, description, valuable, price, created_at)
local result = stmt:step()
if result == sqlite3.DONE then
id = conn:last_insert_rowid()
else
err = conn:errmsg()
end
conn:close()
return id, err
end
function _M.get_all_items(uid)
local conn = sqlite3.open("/app/data/fjb.db")
local query = "SELECT * FROM catalog"
if uid then
query = query .. " WHERE user_id = ?"
end
local stmt = conn:prepare(query)
if uid then
stmt:bind_values(uid)
end
local items = {}
local result = stmt:step()
while result == sqlite3.ROW do
local id = stmt:get_value(0)
local user_id = stmt:get_value(1)
local item_name = stmt:get_value(2)
local description = stmt:get_value(3)
local value = stmt:get_value(4)
local price = stmt:get_value(5)
local date_posted = stmt:get_value(6)
table.insert(items, {
id = id,
name = item_name,
description = description,
owner = user_id,
value = uid == user_id and value or nil,
price = price,
date_posted = date_posted,
})
result = stmt:step()
end
conn:close()
return items
end
function _M.get_item(uid, item_id)
local conn = sqlite3.open("/app/data/fjb.db")
local stmt = conn:prepare("SELECT * FROM catalog WHERE id = ?")
stmt:bind_values(item_id)
local result = stmt:step()
if result ~= sqlite3.ROW then
return nil
end
local user_id = stmt:get_value(1)
if user_id ~= uid then
return nil
end
local id = stmt:get_value(0)
local item_name = stmt:get_value(2)
local description = stmt:get_value(3)
local value = stmt:get_value(4)
local price = stmt:get_value(5)
local date_posted = stmt:get_value(6)
local item = {
id = id,
name = item_name,
description = description,
value = value,
price = price,
date_posted = date_posted,
}
conn:close()
return item
end
function _M.add_to_cart(uid, item_id)
local id = nil
local err = nil
local conn = sqlite3.open("/app/data/fjb.db", sqlite3.OPEN_READWRITE)
local stmt = conn:prepare("SELECT user_id FROM catalog WHERE id = ?")
stmt:bind_values(item_id)
local result = stmt:step()
if result ~= sqlite3.ROW then
return nil, "invalid item_id"
end
if stmt:get_value(0) == uid then
return nil, "cant add your own item to cart"
end
stmt = conn:prepare("INSERT INTO cart (user_id, item_id) VALUES (?, ?)")
stmt:bind_values(uid, item_id)
local result = stmt:step()
if result == sqlite3.DONE then
id = conn:last_insert_rowid()
else
err = conn:errmsg()
end
conn:close()
return id, err
end
function _M.get_items_from_cart(uid, checkout)
local pay = checkout ~= nil
local conn = sqlite3.open("/app/data/fjb.db")
local items = {}
local stmt = conn:prepare [[
SELECT car.id, cat.id, cat.item_name, cat.price, cat.value
FROM cart car
JOIN catalog cat ON car.item_id = cat.id
WHERE car.user_id = ?
]]
stmt:bind_values(uid)
local result = stmt:step()
while result == sqlite3.ROW do
local id = stmt:get_value(0)
local item_id = stmt:get_value(1)
local name = stmt:get_value(2)
local price = stmt:get_value(3)
local valuable = stmt:get_value(4)
table.insert(items, {
id = id,
item_id = item_id,
name = name,
price = price,
valuable = pay and valuable or nil,
})
result = stmt:step()
end
conn:close()
return items
end
function _M.delete_item_from_cart(uid, id)
local err = nil
local conn = sqlite3.open("/app/data/fjb.db")
local stmt = conn:prepare [[
DELETE FROM
cart
WHERE
user_id = ? AND id = ?
]]
stmt:bind_values(uid, id)
local result = stmt:step()
if result ~= sqlite3.DONE then
err = conn:errmsg()
end
conn:close()
return err
end
return _M
+40
View File
@@ -0,0 +1,40 @@
local cjson = require "cjson"
local middleware = require "middleware"
local login = require "handler.login"
local register = require "handler.register"
local user = require "handler.user"
local catalog = require "handler.catalog"
local cart = require "handler.cart"
local checkout = require "handler.checkout"
function handle(r)
if r.uri == "/api/login" and r.method == "POST" then
return login.handler(r)
elseif r.uri == "/api/register" and r.method == "POST" then
return register.handler(r)
elseif r.uri == "/api/user" and r.method == "GET" then
return middleware.auth(r, user.handler)
elseif r.uri == "/api/catalog" and r.method == "GET" then
return catalog.get(r)
elseif r.uri == "/api/catalog/me" and r.method == "GET" then
return middleware.auth(r, catalog.get_me)
elseif r.uri == "/api/catalog" and r.method == "POST" then
return middleware.auth(r, catalog.post)
elseif r.uri == "/api/cart" and r.method == "GET" then
return middleware.auth(r, cart.get)
elseif r.uri == "/api/cart" and r.method == "POST" then
return middleware.auth(r, cart.post)
elseif r.uri == "/api/cart" and r.method == "DELETE" then
return middleware.auth(r, cart.delete)
elseif r.uri == "/api/checkout" and r.method == "POST" then
return middleware.auth(r, checkout.post)
else
r.status = 404
r.content_type = "application/json"
r:write(cjson.encode({
error = "Not found"
}))
return apache2.OK
end
end
+73
View File
@@ -0,0 +1,73 @@
local cjson = require "cjson"
cjson.encode_empty_table_as_object(false)
local db = require "database"
local util = require "lib.utils"
local _M = {}
function _M.get(r)
local items = db.get_items_from_cart(tonumber(r.notes.kauth_id))
r.content_type = "application/json"
r:write(cjson.encode({
items = items,
}))
return apache2.OK
end
function _M.delete(r)
local spec = r:parseargs()
if util.isempty(spec.id) then
r.status = 400
r:write(cjson.encode({
error = "Bad Request: missing id"
}))
return apache2.OK
end
r.content_type = "application/json"
local err = db.delete_item_from_cart(tonumber(r.notes.kauth_id), tonumber(spec.id))
if err then
r:write(cjson.encode({
error = "Internal error: " .. err,
}))
return apache2.OK
end
r:write(cjson.encode({
message = "Successfully removed from cart",
}))
return apache2.OK
end
function _M.post(r)
local input = r:requestbody()
local spec = cjson.decode(input or "{}")
if util.isempty(spec.item) then
r.status = 400
r:write(cjson.encode({
error = "Bad Request: missing name"
}))
return apache2.OK
end
r.content_type = "application/json"
local _, err = db.add_to_cart(tonumber(r.notes.kauth_id), tonumber(spec.item))
if err then
r:write(cjson.encode({
error = "Internal error: " .. err,
}))
return apache2.OK
end
r:write(cjson.encode({
message = "Successfully added to cart",
}))
return apache2.OK
end
return _M
Loaded 50 of 1317 files, more files were not shown because too many files have changed in this diff. Show more