Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
miloschwartz
aabdcea3c0
add docs link 2025-03-22 12:37:35 -04:00
miloschwartz
a178faa377
add support links 2025-03-22 12:35:33 -04:00
miloschwartz
edf0ce226f
Merge branch 'main' into dev 2025-03-22 12:25:00 -04:00
miloschwartz
7118ae374d
fix try catch in supporter keys 2025-03-22 12:24:20 -04:00
miloschwartz
f2a14e6a36
append timestamp to cookie name to prevent redirect loops 2025-03-21 21:38:36 -04:00
miloschwartz
f37be774a6
disable limited tier if already used 2025-03-21 18:36:11 -04:00
miloschwartz
0dcfeb3587
add server admin panel to delete users 2025-03-21 18:04:27 -04:00
Milo Schwartz
33e8ed4c93
Update README.md 2025-03-11 21:02:42 -04:00
17 changed files with 571 additions and 83 deletions

View file

@ -18,12 +18,12 @@ _Your own self-hosted zero trust tunnel._
<div align="center">
<h5>
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
Install Guide
<a href="https://fossorial.io">
Website
</a>
<span> | </span>
<a href="https://docs.fossorial.io">
Full Documentation
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
Install Guide
</a>
<span> | </span>
<a href="mailto:numbat@fossorial.io">
@ -136,7 +136,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
## Licensing
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. To see our commercial offerings, please see our [website](https://fossorial.io) for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
## Contributions

View file

@ -170,16 +170,17 @@ export function serializeResourceSessionCookie(
isHttp: boolean = false,
expiresAt?: Date
): string {
const now = new Date().getTime();
if (!isHttp) {
if (expiresAt === undefined) {
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
}
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
} else {
if (expiresAt === undefined) {
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
}
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
}
}

View file

@ -245,13 +245,7 @@ export class Config {
: "false";
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
this.checkSupporterKey()
.then(() => {
console.log("Supporter key checked");
})
.catch((error) => {
console.error("Error checking supporter key:", error);
});
this.checkSupporterKey();
this.rawConfig = parsedConfig.data;
}
@ -299,43 +293,44 @@ export class Config {
const { key: licenseKey, githubUsername } = key;
const response = await fetch(
"https://api.dev.fossorial.io/api/v1/license/validate",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKey,
githubUsername
})
try {
const response = await fetch(
"https://api.dev.fossorial.io/api/v1/license/validate",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKey,
githubUsername
})
}
);
if (!response.ok) {
this.supporterData = key;
return;
}
);
if (!response.ok) {
this.supporterData = key;
return;
}
const data = await response.json();
const data = await response.json();
if (!data.data.valid) {
this.supporterData = {
...key,
valid: false
};
return;
}
if (!data.data.valid) {
this.supporterData = {
...key,
valid: false
tier: data.data.tier,
valid: true
};
return;
}
this.supporterData = {
...key,
tier: data.data.tier,
valid: true
};
// update the supporter key in the database
await db
// update the supporter key in the database
await db
.update(supporterKey)
.set({
tier: data.data.tier || null,
@ -343,6 +338,10 @@ export class Config {
valid: true
})
.where(eq(supporterKey.keyId, key.keyId));
} catch (e) {
this.supporterData = key;
console.error("Failed to validate supporter key", e);
}
}
public getSupporterData() {

View file

@ -14,7 +14,7 @@ export async function verifyUserIsServerAdmin(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
try {
if (!req.user?.serverAdmin) {
return next(
@ -24,7 +24,7 @@ export async function verifyUserIsServerAdmin(
)
);
}
return next();
} catch (e) {
return next(

View file

@ -229,12 +229,10 @@ export async function verifyResourceSession(
return notAllowed(res);
}
const resourceSessionToken =
sessions[
`${config.getRawConfig().server.session_cookie_name}${
resource.ssl ? "_s" : ""
}`
];
const resourceSessionToken = extractResourceSessionToken(
sessions,
resource.ssl
);
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
@ -354,6 +352,50 @@ export async function verifyResourceSession(
}
}
function extractResourceSessionToken(
sessions: Record<string, string>,
ssl: boolean
) {
const prefix = `${config.getRawConfig().server.session_cookie_name}${
ssl ? "_s" : ""
}`;
const all: { cookieName: string; token: string; priority: number }[] =
[];
for (const [key, value] of Object.entries(sessions)) {
const parts = key.split(".");
const timestamp = parts[parts.length - 1];
// check if string is only numbers
if (!/^\d+$/.test(timestamp)) {
continue;
}
// cookie name is the key without the timestamp
const cookieName = key.slice(0, -timestamp.length - 1);
if (cookieName === prefix) {
all.push({
cookieName,
token: value,
priority: parseInt(timestamp)
});
}
}
// sort by priority in desc order
all.sort((a, b) => b.priority - a.priority);
const latest = all[0];
if (!latest) {
return;
}
return latest.token;
}
function notAllowed(res: Response, redirectUrl?: string) {
const data = {
data: { valid: false, redirectUrl },
@ -612,21 +654,21 @@ export function isPathAllowed(pattern: string, path: string): boolean {
logger.debug(
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
);
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
logger.debug(
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
);
return matchSegments(patternIndex + 1, pathIndex + 1);
}
logger.debug(
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
);
@ -651,4 +693,4 @@ export function isPathAllowed(pattern: string, path: string): boolean {
const result = matchSegments(0, 0);
logger.debug(`Final result: ${result}`);
return result;
}
}

View file

@ -10,6 +10,7 @@ import { users } from "@server/db/schema";
export type IsSupporterKeyVisibleResponse = {
visible: boolean;
tier?: string;
};
const USER_LIMIT = 5;
@ -29,16 +30,17 @@ export async function isSupporterKeyVisible(
const [numUsers] = await db.select({ count: count() }).from(users);
if (numUsers.count > USER_LIMIT) {
logger.debug(
`User count ${numUsers.count} exceeds limit ${USER_LIMIT}`
);
visible = true;
}
}
logger.debug(`Supporter key visible: ${visible}`);
logger.debug(JSON.stringify(key));
return sendResponse<IsSupporterKeyVisibleResponse>(res, {
data: {
visible
visible,
tier: key?.tier || undefined
},
success: true,
error: false,

View file

@ -31,6 +31,7 @@ async function queryUsers(limit: number, offset: number) {
id: users.userId,
email: users.email,
dateCreated: users.dateCreated,
serverAdmin: users.serverAdmin
})
.from(users)
.where(eq(users.serverAdmin, false))
@ -60,10 +61,7 @@ export async function adminListUsers(
}
const { limit, offset } = parsedQuery.data;
const allUsers = await queryUsers(
limit,
offset
);
const allUsers = await queryUsers(limit, offset);
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })

View file

@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userOrgs, users } from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import { users } from "@server/db/schema";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -36,13 +36,22 @@ export async function adminRemoveUser(
// get the user first
const user = await db
.select()
.from(userOrgs)
.where(eq(userOrgs.userId, userId));
.from(users)
.where(eq(users.userId, userId));
if (!user || user.length === 0) {
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
}
if (user[0].serverAdmin) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot remove server admin"
)
);
}
await db.delete(users).where(eq(users.userId, userId));
return response(res, {

View file

@ -6,4 +6,4 @@ export * from "./inviteUser";
export * from "./acceptInvite";
export * from "./getOrgUser";
export * from "./adminListUsers";
export * from "./adminRemoveUser";
export * from "./adminRemoveUser";

71
src/app/admin/layout.tsx Normal file
View file

@ -0,0 +1,71 @@
import { Metadata } from "next";
import { TopbarNav } from "@app/components/TopbarNav";
import { Users } from "lucide-react";
import { Header } from "@app/components/Header";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";
import UserProvider from "@app/providers/UserProvider";
import { ListOrgsResponse } from "@server/routers/org";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: `Server Admin - Pangolin`,
description: ""
};
const topNavItems = [
{
title: "All Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />
}
];
interface LayoutProps {
children: React.ReactNode;
}
export default async function SettingsLayout(props: LayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
if (!user || !user.serverAdmin) {
redirect(`/`);
}
const cookie = await authCookieHeader();
let orgs: ListOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(() =>
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch (e) {}
return (
<>
<div className="w-full bg-card sm:px-0 fixed top-0 z-10 border-b">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4 px-3 md:px-0">
<UserProvider user={user}>
<Header orgId={""} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
{props.children}
</div>
</>
);
}

11
src/app/admin/page.tsx Normal file
View file

@ -0,0 +1,11 @@
import { verifySession } from "@app/lib/auth/verifySession";
import { cache } from "react";
import { redirect } from "next/navigation";
type AdminPageProps = {};
export default async function OrgPage(props: AdminPageProps) {
redirect(`/admin/users`);
return <></>;
}

View file

@ -0,0 +1,141 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Search } from "lucide-react";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function UsersDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
initialState: {
pagination: {
pageSize: 20,
pageIndex: 0
}
},
state: {
sorting,
columnFilters
}
});
return (
<div>
<div className="flex items-center justify-between pb-4">
<div className="flex items-center max-w-sm mr-2 w-full relative">
<Input
placeholder="Search server users"
value={
(table
.getColumn("email")
?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table
.getColumn("name")
?.setFilterValue(event.target.value)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
</div>
</div>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() && "selected"
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
This server has no users.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>
</div>
);
}

View file

@ -0,0 +1,151 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { UsersDataTable } from "./AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type GlobalUserRow = {
id: string;
email: string;
dateCreated: string;
};
type Props = {
users: GlobalUserRow[];
};
export default function UsersTable({ users }: Props) {
const router = useRouter();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
const [rows, setRows] = useState<GlobalUserRow[]>(users);
const api = createApiClient(useEnvContext());
const deleteUser = (id: string) => {
api.delete(`/user/${id}`)
.catch((e) => {
console.error("Error deleting user", e);
toast({
variant: "destructive",
title: "Error deleting user",
description: formatAxiosError(e, "Error deleting user")
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== id);
setRows(newRows);
});
};
const columns: ColumnDef<GlobalUserRow>[] = [
{
accessorKey: "id",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
</Button>
);
}
},
{
accessorKey: "email",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center justify-end">
<Button
variant={"outlinePrimary"}
className="ml-2"
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
Delete
</Button>
</div>
</>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to permanently delete{" "}
<b>{selected?.email || selected?.id}</b> from
the server?
</p>
<p>
<b>
The user will be removed from all
organizations and be completely removed from
the server.
</b>
</p>
<p>
To confirm, please type the email of the user
below.
</p>
</div>
}
buttonText="Confirm Delete User"
onConfirm={async () => deleteUser(selected!.id)}
string={selected.email}
title="Delete User from Server"
/>
)}
<UsersDataTable columns={columns} data={rows} />
</>
);
}

View file

@ -0,0 +1,44 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
import UsersTable, { GlobalUserRow } from "./AdminUsersTable";
type PageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function UsersPage(props: PageProps) {
let rows: AdminListUsersResponse["users"] = [];
try {
const res = await internal.get<AxiosResponse<AdminListUsersResponse>>(
`/users`,
await authCookieHeader()
);
rows = res.data.data.users;
} catch (e) {
console.error(e);
}
const userRows: GlobalUserRow[] = rows.map((row) => {
return {
id: row.id,
email: row.email,
dateCreated: row.dateCreated,
serverAdmin: row.serverAdmin
};
});
return (
<>
<SettingsSectionTitle
title="Manage All Users"
description="View and manage all users in the system"
/>
<UsersTable users={userRows} />
</>
);
}

View file

@ -32,12 +32,13 @@ export default async function RootLayout({
let supporterData = {
visible: true
};
} as any;
const res = await priv.get<
AxiosResponse<IsSupporterKeyVisibleResponse>
>("supporter-key/visible");
supporterData.visible = res.data.data.visible;
supporterData.tier = res.data.data.tier;
const version = env.app.version;

View file

@ -167,7 +167,7 @@ export default function SupporterStatus() {
</Link>{" "}
and redeem it here.{" "}
<Link
href="https://supporters.dev.fossorial.io/"
href="https://docs.fossorial.io/supporter-program"
target="_blank"
rel="noopener noreferrer"
className="underline"
@ -208,7 +208,7 @@ export default function SupporterStatus() {
</CardContent>
<CardFooter>
<Link
href="https://www.google.com"
href="https://github.com/sponsors/fosrl/sponsorships?tier_id=474929"
target="_blank"
rel="noopener noreferrer"
className="w-full"
@ -218,7 +218,9 @@ export default function SupporterStatus() {
</CardFooter>
</Card>
<Card>
<Card
className={`${supporterStatus?.tier === "Limited Supporter" ? "opacity-50" : ""}`}
>
<CardHeader>
<CardTitle>Limited Supporter</CardTitle>
</CardHeader>
@ -246,14 +248,29 @@ export default function SupporterStatus() {
</ul>
</CardContent>
<CardFooter>
<Link
href="https://www.google.com"
target="_blank"
rel="noopener noreferrer"
className="w-full"
>
<Button className="w-full">Buy</Button>
</Link>
{supporterStatus?.tier !==
"Limited Supporter" ? (
<Link
href="https://github.com/sponsors/fosrl/sponsorships?tier_id=463100"
target="_blank"
rel="noopener noreferrer"
className="w-full"
>
<Button className="w-full">
Buy
</Button>
</Link>
) : (
<Button
className="w-full"
disabled={
supporterStatus?.tier ===
"Limited Supporter"
}
>
Buy
</Button>
)}
</CardFooter>
</Card>
</div>

View file

@ -2,6 +2,7 @@ import { createContext } from "react";
export type SupporterStatus = {
visible: boolean;
tier?: string;
};
type SupporterStatusContextType = {