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:
1 parent
c35b23a37f
commit
c6fd9ec268
1317 files changed
+306586
-128
No files matched your search
@@ -0,0 +1,4 @@
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/*.so
|
||||
**/*.o
|
||||
@@ -0,0 +1,5 @@
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
@@ -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.
@@ -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?
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+4683
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
@@ -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")],
|
||||
};
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Executable
+10
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
*.o
|
||||
*.so
|
||||
@@ -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
|
||||
@@ -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 } },
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
struct kauth_user {
|
||||
const char name[128];
|
||||
unsigned long long id;
|
||||
unsigned long long exp;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user