mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-04 10:05:53 +02:00
Pull up downstream changes
This commit is contained in:
parent
c679875273
commit
98a261e38c
108 changed files with 9799 additions and 2038 deletions
|
@ -6,7 +6,8 @@ import logger from "@server/logger";
|
|||
import {
|
||||
errorHandlerMiddleware,
|
||||
notFoundMiddleware,
|
||||
rateLimitMiddleware
|
||||
rateLimitMiddleware,
|
||||
requestTimeoutMiddleware
|
||||
} from "@server/middlewares";
|
||||
import { authenticated, unauthenticated } from "@server/routers/external";
|
||||
import { router as wsRouter, handleWSUpgrade } from "@server/routers/ws";
|
||||
|
@ -19,6 +20,7 @@ const externalPort = config.getRawConfig().server.external_port;
|
|||
|
||||
export function createApiServer() {
|
||||
const apiServer = express();
|
||||
const prefix = `/api/v1`;
|
||||
|
||||
const trustProxy = config.getRawConfig().server.trust_proxy;
|
||||
if (trustProxy) {
|
||||
|
@ -54,6 +56,9 @@ export function createApiServer() {
|
|||
apiServer.use(cookieParser());
|
||||
apiServer.use(express.json());
|
||||
|
||||
// Add request timeout middleware
|
||||
apiServer.use(requestTimeoutMiddleware(60000)); // 60 second timeout
|
||||
|
||||
if (!dev) {
|
||||
apiServer.use(
|
||||
rateLimitMiddleware({
|
||||
|
@ -66,7 +71,6 @@ export function createApiServer() {
|
|||
}
|
||||
|
||||
// API routes
|
||||
const prefix = `/api/v1`;
|
||||
apiServer.use(logIncomingMiddleware);
|
||||
apiServer.use(prefix, unauthenticated);
|
||||
apiServer.use(prefix, authenticated);
|
||||
|
|
|
@ -90,7 +90,10 @@ export enum ActionsEnum {
|
|||
setApiKeyOrgs = "setApiKeyOrgs",
|
||||
listApiKeyActions = "listApiKeyActions",
|
||||
listApiKeys = "listApiKeys",
|
||||
getApiKey = "getApiKey"
|
||||
getApiKey = "getApiKey",
|
||||
createOrgDomain = "createOrgDomain",
|
||||
deleteOrgDomain = "deleteOrgDomain",
|
||||
restartOrgDomain = "restartOrgDomain"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
import { db } from '@server/db';
|
||||
import { limitsTable } from '@server/db';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import createHttpError from 'http-errors';
|
||||
import HttpCode from '@server/types/HttpCode';
|
||||
|
||||
interface CheckLimitOptions {
|
||||
orgId: string;
|
||||
limitName: string;
|
||||
currentValue: number;
|
||||
increment?: number;
|
||||
}
|
||||
|
||||
export async function checkOrgLimit({ orgId, limitName, currentValue, increment = 0 }: CheckLimitOptions): Promise<boolean> {
|
||||
try {
|
||||
const limit = await db.select()
|
||||
.from(limitsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(limitsTable.orgId, orgId),
|
||||
eq(limitsTable.name, limitName)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (limit.length === 0) {
|
||||
throw createHttpError(HttpCode.NOT_FOUND, `Limit "${limitName}" not found for organization`);
|
||||
}
|
||||
|
||||
const limitValue = limit[0].value;
|
||||
|
||||
// Check if the current value plus the increment is within the limit
|
||||
return (currentValue + increment) <= limitValue;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `Error checking limit: ${error.message}`);
|
||||
}
|
||||
throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Unknown error occurred while checking limit');
|
||||
}
|
||||
}
|
|
@ -59,7 +59,7 @@ export async function getUniqueExitNodeEndpointName(): Promise<string> {
|
|||
|
||||
|
||||
export function generateName(): string {
|
||||
return (
|
||||
const name = (
|
||||
names.descriptors[
|
||||
Math.floor(Math.random() * names.descriptors.length)
|
||||
] +
|
||||
|
@ -68,4 +68,7 @@ export function generateName(): string {
|
|||
)
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "-");
|
||||
|
||||
// clean out any non-alphanumeric characters except for dashes
|
||||
return name.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||
import { withReplicas } from "drizzle-orm/pg-core";
|
||||
|
||||
|
@ -20,19 +21,31 @@ function createDb() {
|
|||
);
|
||||
}
|
||||
|
||||
const primary = DrizzlePostgres(connectionString);
|
||||
// Create connection pools instead of individual connections
|
||||
const primaryPool = new Pool({
|
||||
connectionString,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
const replicas = [];
|
||||
|
||||
if (!replicaConnections.length) {
|
||||
replicas.push(primary);
|
||||
replicas.push(DrizzlePostgres(primaryPool));
|
||||
} else {
|
||||
for (const conn of replicaConnections) {
|
||||
const replica = DrizzlePostgres(conn.connection_string);
|
||||
replicas.push(replica);
|
||||
const replicaPool = new Pool({
|
||||
connectionString: conn.connection_string,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
replicas.push(DrizzlePostgres(replicaPool));
|
||||
}
|
||||
}
|
||||
|
||||
return withReplicas(primary, replicas as any);
|
||||
return withReplicas(DrizzlePostgres(primaryPool), replicas as any);
|
||||
}
|
||||
|
||||
export const db = createDb();
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export * from "./driver";
|
||||
export * from "./schema";
|
||||
export * from "./schema";
|
|
@ -14,6 +14,9 @@ export const domains = pgTable("domains", {
|
|||
baseDomain: varchar("baseDomain").notNull(),
|
||||
configManaged: boolean("configManaged").notNull().default(false),
|
||||
type: varchar("type"), // "ns", "cname", "a"
|
||||
verified: boolean("verified").notNull().default(false),
|
||||
failed: boolean("failed").notNull().default(false),
|
||||
tries: integer("tries").notNull().default(0)
|
||||
});
|
||||
|
||||
export const orgs = pgTable("orgs", {
|
||||
|
@ -44,9 +47,9 @@ export const sites = pgTable("sites", {
|
|||
}),
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet").notNull(),
|
||||
megabytesIn: real("bytesIn"),
|
||||
megabytesOut: real("bytesOut"),
|
||||
subnet: varchar("subnet"),
|
||||
megabytesIn: real("bytesIn").default(0),
|
||||
megabytesOut: real("bytesOut").default(0),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
type: varchar("type").notNull(), // "newt" or "wireguard"
|
||||
online: boolean("online").notNull().default(false),
|
||||
|
@ -282,18 +285,6 @@ export const userResources = pgTable("userResources", {
|
|||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const limitsTable = pgTable("limits", {
|
||||
limitId: serial("limitId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
value: bigint("value", { mode: "number" }).notNull(),
|
||||
description: varchar("description")
|
||||
});
|
||||
|
||||
export const userInvites = pgTable("userInvites", {
|
||||
inviteId: varchar("inviteId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
|
@ -520,7 +511,8 @@ export const clients = pgTable("clients", {
|
|||
type: varchar("type").notNull(), // "olm"
|
||||
online: boolean("online").notNull().default(false),
|
||||
endpoint: varchar("endpoint"),
|
||||
lastHolePunch: integer("lastHolePunch")
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
maxConnections: integer("maxConnections")
|
||||
});
|
||||
|
||||
export const clientSites = pgTable("clientSites", {
|
||||
|
@ -590,7 +582,6 @@ export type RoleSite = InferSelectModel<typeof roleSites>;
|
|||
export type UserSite = InferSelectModel<typeof userSites>;
|
||||
export type RoleResource = InferSelectModel<typeof roleResources>;
|
||||
export type UserResource = InferSelectModel<typeof userResources>;
|
||||
export type Limit = InferSelectModel<typeof limitsTable>;
|
||||
export type UserInvite = InferSelectModel<typeof userInvites>;
|
||||
export type UserOrg = InferSelectModel<typeof userOrgs>;
|
||||
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
||||
|
@ -613,3 +604,4 @@ export type Olm = InferSelectModel<typeof olms>;
|
|||
export type OlmSession = InferSelectModel<typeof olmSessions>;
|
||||
export type UserClient = InferSelectModel<typeof userClients>;
|
||||
export type RoleClient = InferSelectModel<typeof roleClients>;
|
||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
|
@ -3,30 +3,25 @@ import logger from "@server/logger";
|
|||
import config from "@server/lib/config";
|
||||
|
||||
class RedisManager {
|
||||
private static instance: RedisManager;
|
||||
public client: Redis | null = null;
|
||||
private subscriber: Redis | null = null;
|
||||
private publisher: Redis | null = null;
|
||||
private isEnabled: boolean = false;
|
||||
private isHealthy: boolean = true;
|
||||
private lastHealthCheck: number = 0;
|
||||
private healthCheckInterval: number = 30000; // 30 seconds
|
||||
private subscribers: Map<
|
||||
string,
|
||||
Set<(channel: string, message: string) => void>
|
||||
> = new Map();
|
||||
|
||||
private constructor() {
|
||||
constructor() {
|
||||
this.isEnabled = config.getRawConfig().flags?.enable_redis || false;
|
||||
if (this.isEnabled) {
|
||||
this.initializeClients();
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): RedisManager {
|
||||
if (!RedisManager.instance) {
|
||||
RedisManager.instance = new RedisManager();
|
||||
}
|
||||
return RedisManager.instance;
|
||||
}
|
||||
|
||||
private getRedisConfig(): RedisOptions {
|
||||
const redisConfig = config.getRawConfig().redis!;
|
||||
const opts: RedisOptions = {
|
||||
|
@ -34,38 +29,78 @@ class RedisManager {
|
|||
port: redisConfig.port!,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
tls: {
|
||||
rejectUnauthorized:
|
||||
redisConfig.tls?.reject_unauthorized || false
|
||||
}
|
||||
// tls: {
|
||||
// rejectUnauthorized:
|
||||
// redisConfig.tls?.reject_unauthorized || false
|
||||
// }
|
||||
};
|
||||
return opts;
|
||||
}
|
||||
|
||||
// Add reconnection logic in initializeClients
|
||||
private initializeClients(): void {
|
||||
const config = this.getRedisConfig();
|
||||
|
||||
try {
|
||||
// Main client for general operations
|
||||
this.client = new Redis(config);
|
||||
this.client = new Redis({
|
||||
...config,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 10000, // 10 seconds
|
||||
commandTimeout: 5000, // 5 seconds
|
||||
});
|
||||
|
||||
// Dedicated publisher client
|
||||
this.publisher = new Redis(config);
|
||||
this.publisher = new Redis({
|
||||
...config,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 10000, // 10 seconds
|
||||
commandTimeout: 5000, // 5 seconds
|
||||
});
|
||||
|
||||
// Dedicated subscriber client
|
||||
this.subscriber = new Redis(config);
|
||||
this.subscriber = new Redis({
|
||||
...config,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 10000, // 10 seconds
|
||||
commandTimeout: 5000, // 5 seconds
|
||||
});
|
||||
|
||||
// Set up error handlers
|
||||
// Add reconnection handlers
|
||||
this.client.on("error", (err) => {
|
||||
logger.error("Redis client error:", err);
|
||||
this.isHealthy = false;
|
||||
});
|
||||
|
||||
this.client.on("reconnecting", () => {
|
||||
logger.info("Redis client reconnecting...");
|
||||
this.isHealthy = false;
|
||||
});
|
||||
|
||||
this.client.on("ready", () => {
|
||||
logger.info("Redis client ready");
|
||||
this.isHealthy = true;
|
||||
});
|
||||
|
||||
this.publisher.on("error", (err) => {
|
||||
logger.error("Redis publisher error:", err);
|
||||
this.isHealthy = false;
|
||||
});
|
||||
|
||||
this.publisher.on("ready", () => {
|
||||
logger.info("Redis publisher ready");
|
||||
});
|
||||
|
||||
this.subscriber.on("error", (err) => {
|
||||
logger.error("Redis subscriber error:", err);
|
||||
this.isHealthy = false;
|
||||
});
|
||||
|
||||
this.subscriber.on("ready", () => {
|
||||
logger.info("Redis subscriber ready");
|
||||
});
|
||||
|
||||
// Set up connection handlers
|
||||
|
@ -102,18 +137,65 @@ class RedisManager {
|
|||
);
|
||||
|
||||
logger.info("Redis clients initialized successfully");
|
||||
|
||||
// Start periodic health monitoring
|
||||
this.startHealthMonitoring();
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize Redis clients:", error);
|
||||
this.isEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public isRedisEnabled(): boolean {
|
||||
return this.isEnabled && this.client !== null;
|
||||
private startHealthMonitoring(): void {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
// Check health every 30 seconds
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await this.checkRedisHealth();
|
||||
} catch (error) {
|
||||
logger.error("Error during Redis health monitoring:", error);
|
||||
}
|
||||
}, this.healthCheckInterval);
|
||||
}
|
||||
|
||||
public getClient(): Redis | null {
|
||||
return this.client;
|
||||
public isRedisEnabled(): boolean {
|
||||
return this.isEnabled && this.client !== null && this.isHealthy;
|
||||
}
|
||||
|
||||
private async checkRedisHealth(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
|
||||
// Only check health every 30 seconds
|
||||
if (now - this.lastHealthCheck < this.healthCheckInterval) {
|
||||
return this.isHealthy;
|
||||
}
|
||||
|
||||
this.lastHealthCheck = now;
|
||||
|
||||
if (!this.client) {
|
||||
this.isHealthy = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
this.client.ping(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Health check timeout')), 2000)
|
||||
)
|
||||
]);
|
||||
this.isHealthy = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Redis health check failed:", error);
|
||||
this.isHealthy = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public getClient(): Redis {
|
||||
return this.client!;
|
||||
}
|
||||
|
||||
public async set(
|
||||
|
@ -247,11 +329,25 @@ class RedisManager {
|
|||
public async publish(channel: string, message: string): Promise<boolean> {
|
||||
if (!this.isRedisEnabled() || !this.publisher) return false;
|
||||
|
||||
// Quick health check before attempting to publish
|
||||
const isHealthy = await this.checkRedisHealth();
|
||||
if (!isHealthy) {
|
||||
logger.warn("Skipping Redis publish due to unhealthy connection");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.publisher.publish(channel, message);
|
||||
// Add timeout to prevent hanging
|
||||
await Promise.race([
|
||||
this.publisher.publish(channel, message),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Redis publish timeout')), 3000)
|
||||
)
|
||||
]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Redis PUBLISH error:", error);
|
||||
this.isHealthy = false; // Mark as unhealthy on error
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -267,13 +363,19 @@ class RedisManager {
|
|||
if (!this.subscribers.has(channel)) {
|
||||
this.subscribers.set(channel, new Set());
|
||||
// Only subscribe to the channel if it's the first subscriber
|
||||
await this.subscriber.subscribe(channel);
|
||||
await Promise.race([
|
||||
this.subscriber.subscribe(channel),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Redis subscribe timeout')), 5000)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
this.subscribers.get(channel)!.add(callback);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Redis SUBSCRIBE error:", error);
|
||||
this.isHealthy = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -330,5 +432,6 @@ class RedisManager {
|
|||
}
|
||||
}
|
||||
|
||||
export const redisManager = RedisManager.getInstance();
|
||||
export const redisManager = new RedisManager();
|
||||
export const redis = redisManager.getClient();
|
||||
export default redisManager;
|
||||
|
|
|
@ -7,7 +7,7 @@ export const domains = sqliteTable("domains", {
|
|||
configManaged: integer("configManaged", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
type: text("type"), // "ns", "cname", "a"
|
||||
type: text("type") // "ns", "cname", "a"
|
||||
});
|
||||
|
||||
export const orgs = sqliteTable("orgs", {
|
||||
|
@ -16,6 +16,15 @@ export const orgs = sqliteTable("orgs", {
|
|||
subnet: text("subnet").notNull(),
|
||||
});
|
||||
|
||||
export const userDomains = sqliteTable("userDomains", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
domainId: text("domainId")
|
||||
.notNull()
|
||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const orgDomains = sqliteTable("orgDomains", {
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
|
@ -38,9 +47,9 @@ export const sites = sqliteTable("sites", {
|
|||
}),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
subnet: text("subnet").notNull(),
|
||||
megabytesIn: integer("bytesIn"),
|
||||
megabytesOut: integer("bytesOut"),
|
||||
subnet: text("subnet"),
|
||||
megabytesIn: integer("bytesIn").default(0),
|
||||
megabytesOut: integer("bytesOut").default(0),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
type: text("type").notNull(), // "newt" or "wireguard"
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
|
@ -48,7 +57,7 @@ export const sites = sqliteTable("sites", {
|
|||
// exit node stuff that is how to connect to the site when it has a wg server
|
||||
address: text("address"), // this is the address of the wireguard interface in newt
|
||||
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
|
||||
publicKey: text("pubicKey"), // TODO: Fix typo in publicKey
|
||||
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
|
@ -626,13 +635,14 @@ export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
|||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type Client = InferSelectModel<typeof clients>;
|
||||
export type ClientSite = InferSelectModel<typeof clientSites>;
|
||||
export type RoleClient = InferSelectModel<typeof roleClients>;
|
||||
export type UserClient = InferSelectModel<typeof userClients>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
export type Idp = InferSelectModel<typeof idp>;
|
||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
|
@ -2,6 +2,7 @@ import { render } from "@react-email/render";
|
|||
import { ReactElement } from "react";
|
||||
import emailClient from "@server/emails";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export async function sendEmail(
|
||||
template: ReactElement,
|
||||
|
@ -24,9 +25,11 @@ export async function sendEmail(
|
|||
|
||||
const emailHtml = await render(template);
|
||||
|
||||
const appName = "Fossorial - Pangolin";
|
||||
|
||||
await emailClient.sendMail({
|
||||
from: {
|
||||
name: opts.name || "Pangolin",
|
||||
name: opts.name || appName,
|
||||
address: opts.from,
|
||||
},
|
||||
to: opts.to,
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import {
|
||||
Body,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
|
@ -22,29 +16,29 @@ interface Props {
|
|||
}
|
||||
|
||||
export const ConfirmPasswordReset = ({ email }: Props) => {
|
||||
const previewText = `Your password has been reset`;
|
||||
const previewText = `Your password has been successfully reset.`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans relative">
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailHeading>Password Reset Confirmation</EmailHeading>
|
||||
{/* <EmailHeading>Password Successfully Reset</EmailHeading> */}
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
This email confirms that your password has just been
|
||||
reset. If you made this change, no further action is
|
||||
required.
|
||||
Your password has been successfully reset. You can
|
||||
now sign in to your account using your new password.
|
||||
</EmailText>
|
||||
|
||||
<EmailText>
|
||||
Thank you for keeping your account secure.
|
||||
If you didn't make this change, please contact our
|
||||
support team immediately to secure your account.
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import {
|
||||
Body,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
|
@ -18,6 +12,7 @@ import {
|
|||
EmailText
|
||||
} from "./components/Email";
|
||||
import CopyCodeBox from "./components/CopyCodeBox";
|
||||
import ButtonLink from "./components/ButtonLink";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
|
@ -26,37 +21,39 @@ interface Props {
|
|||
}
|
||||
|
||||
export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
||||
const previewText = `Your password reset code is ${code}`;
|
||||
const previewText = `Reset your password with code: ${code}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailHeading>Password Reset Request</EmailHeading>
|
||||
{/* <EmailHeading>Reset Your Password</EmailHeading> */}
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
You’ve requested to reset your password. Please{" "}
|
||||
<a href={link} className="text-primary">
|
||||
click here
|
||||
</a>{" "}
|
||||
and follow the instructions to reset your password,
|
||||
or manually enter the following code:
|
||||
You've requested to reset your password. Click the
|
||||
button below to reset your password, or use the
|
||||
verification code provided if prompted.
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
<ButtonLink href={link}>Reset Password</ButtonLink>
|
||||
</EmailSection>
|
||||
|
||||
<EmailSection>
|
||||
<CopyCodeBox text={code} />
|
||||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
If you didn’t request this, you can safely ignore
|
||||
this email.
|
||||
This reset code will expire in 2 hours. If you
|
||||
didn't request a password reset, you can safely
|
||||
ignore this email.
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import {
|
||||
Body,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailLetterHead,
|
||||
|
@ -32,34 +26,40 @@ export const ResourceOTPCode = ({
|
|||
orgName: organizationName,
|
||||
otp
|
||||
}: ResourceOTPCodeProps) => {
|
||||
const previewText = `Your one-time password for ${resourceName} is ${otp}`;
|
||||
const previewText = `Your access code for ${resourceName}: ${otp}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailHeading>
|
||||
Your One-Time Code for {resourceName}
|
||||
</EmailHeading>
|
||||
{/* <EmailHeading> */}
|
||||
{/* Access Code for {resourceName} */}
|
||||
{/* </EmailHeading> */}
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
You’ve requested a one-time password to access{" "}
|
||||
You've requested access to{" "}
|
||||
<strong>{resourceName}</strong> in{" "}
|
||||
<strong>{organizationName}</strong>. Use the code
|
||||
below to complete your authentication:
|
||||
<strong>{organizationName}</strong>. Use the
|
||||
verification code below to complete your
|
||||
authentication.
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
<CopyCodeBox text={otp} />
|
||||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
This code will expire in 15 minutes. If you didn't
|
||||
request this code, please ignore this email.
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import {
|
||||
Body,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
|
@ -41,35 +35,44 @@ export const SendInviteLink = ({
|
|||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailHeading>Invited to Join {orgName}</EmailHeading>
|
||||
{/* <EmailHeading> */}
|
||||
{/* You're Invited to Join {orgName} */}
|
||||
{/* </EmailHeading> */}
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
You’ve been invited to join the organization{" "}
|
||||
You've been invited to join{" "}
|
||||
<strong>{orgName}</strong>
|
||||
{inviterName ? ` by ${inviterName}.` : "."} Please
|
||||
access the link below to accept the invite.
|
||||
</EmailText>
|
||||
|
||||
<EmailText>
|
||||
This invite will expire in{" "}
|
||||
<strong>
|
||||
{expiresInDays}{" "}
|
||||
{expiresInDays === "1" ? "day" : "days"}.
|
||||
</strong>
|
||||
{inviterName ? ` by ${inviterName}` : ""}. Click the
|
||||
button below to accept your invitation and get
|
||||
started.
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
<ButtonLink href={inviteLink}>
|
||||
Accept Invite to {orgName}
|
||||
Accept Invitation
|
||||
</ButtonLink>
|
||||
</EmailSection>
|
||||
|
||||
{/* <EmailText> */}
|
||||
{/* If you're having trouble clicking the button, copy */}
|
||||
{/* and paste the URL below into your web browser: */}
|
||||
{/* <br /> */}
|
||||
{/* <span className="break-all">{inviteLink}</span> */}
|
||||
{/* </EmailText> */}
|
||||
|
||||
<EmailText>
|
||||
This invite expires in {expiresInDays}{" "}
|
||||
{expiresInDays === "1" ? "day" : "days"}. If the
|
||||
link has expired, please contact the owner of the
|
||||
organization to request a new invitation.
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import {
|
||||
Body,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
|
@ -23,44 +17,52 @@ interface Props {
|
|||
}
|
||||
|
||||
export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
|
||||
const previewText = `Two-Factor Authentication has been ${enabled ? "enabled" : "disabled"}`;
|
||||
const previewText = `Two-Factor Authentication ${enabled ? "enabled" : "disabled"} for your account`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailHeading>
|
||||
Two-Factor Authentication{" "}
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</EmailHeading>
|
||||
{/* <EmailHeading> */}
|
||||
{/* Security Update: 2FA{" "} */}
|
||||
{/* {enabled ? "Enabled" : "Disabled"} */}
|
||||
{/* </EmailHeading> */}
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
This email confirms that Two-Factor Authentication
|
||||
has been successfully{" "}
|
||||
{enabled ? "enabled" : "disabled"} on your account.
|
||||
Two-factor authentication has been successfully{" "}
|
||||
<strong>{enabled ? "enabled" : "disabled"}</strong>{" "}
|
||||
on your account.
|
||||
</EmailText>
|
||||
|
||||
{enabled ? (
|
||||
<EmailText>
|
||||
With Two-Factor Authentication enabled, your
|
||||
account is now more secure. Please ensure you
|
||||
keep your authentication method safe.
|
||||
</EmailText>
|
||||
<>
|
||||
<EmailText>
|
||||
Your account is now protected with an
|
||||
additional layer of security. Keep your
|
||||
authentication method safe and accessible.
|
||||
</EmailText>
|
||||
</>
|
||||
) : (
|
||||
<EmailText>
|
||||
With Two-Factor Authentication disabled, your
|
||||
account may be less secure. We recommend
|
||||
enabling it to protect your account.
|
||||
</EmailText>
|
||||
<>
|
||||
<EmailText>
|
||||
We recommend re-enabling two-factor
|
||||
authentication to keep your account secure.
|
||||
</EmailText>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EmailText>
|
||||
If you didn't make this change, please contact our
|
||||
support team immediately.
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
|
@ -24,25 +24,24 @@ export const VerifyEmail = ({
|
|||
verificationCode,
|
||||
verifyLink
|
||||
}: VerifyEmailProps) => {
|
||||
const previewText = `Your verification code is ${verificationCode}`;
|
||||
const previewText = `Verify your email with code: ${verificationCode}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailHeading>Please Verify Your Email</EmailHeading>
|
||||
{/* <EmailHeading>Verify Your Email Address</EmailHeading> */}
|
||||
|
||||
<EmailGreeting>Hi {username || "there"},</EmailGreeting>
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
You’ve requested to verify your email. Please use
|
||||
the code below to complete the verification process
|
||||
upon logging in.
|
||||
Welcome! To complete your account setup, please
|
||||
verify your email address using the code below.
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
|
@ -50,7 +49,8 @@ export const VerifyEmail = ({
|
|||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
If you didn’t request this, you can safely ignore
|
||||
This verification code will expire in 15 minutes. If
|
||||
you didn't create an account, you can safely ignore
|
||||
this email.
|
||||
</EmailText>
|
||||
|
||||
|
|
131
server/emails/templates/WelcomeQuickStart.tsx
Normal file
131
server/emails/templates/WelcomeQuickStart.tsx
Normal file
|
@ -0,0 +1,131 @@
|
|||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailLetterHead,
|
||||
EmailSection,
|
||||
EmailSignature,
|
||||
EmailText,
|
||||
EmailInfoSection
|
||||
} from "./components/Email";
|
||||
import ButtonLink from "./components/ButtonLink";
|
||||
import CopyCodeBox from "./components/CopyCodeBox";
|
||||
|
||||
interface WelcomeQuickStartProps {
|
||||
username?: string;
|
||||
link: string;
|
||||
fallbackLink: string;
|
||||
resourceMethod: string;
|
||||
resourceHostname: string;
|
||||
resourcePort: string | number;
|
||||
resourceUrl: string;
|
||||
cliCommand: string;
|
||||
}
|
||||
|
||||
export const WelcomeQuickStart = ({
|
||||
username,
|
||||
link,
|
||||
fallbackLink,
|
||||
resourceMethod,
|
||||
resourceHostname,
|
||||
resourcePort,
|
||||
resourceUrl,
|
||||
cliCommand
|
||||
}: WelcomeQuickStartProps) => {
|
||||
const previewText = "Welcome! Here's what to do next";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
Thank you for trying out Pangolin! We're excited to
|
||||
have you on board.
|
||||
</EmailText>
|
||||
|
||||
<EmailText>
|
||||
To continue to configure your site, resources, and
|
||||
other features, complete your account setup to
|
||||
access the full dashboard.
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
<ButtonLink href={link}>
|
||||
View Your Dashboard
|
||||
</ButtonLink>
|
||||
{/* <p className="text-sm text-gray-300 mt-2"> */}
|
||||
{/* If the button above doesn't work, you can also */}
|
||||
{/* use this{" "} */}
|
||||
{/* <a href={fallbackLink} className="underline"> */}
|
||||
{/* link */}
|
||||
{/* </a> */}
|
||||
{/* . */}
|
||||
{/* </p> */}
|
||||
</EmailSection>
|
||||
|
||||
<EmailSection>
|
||||
<div className="mb-2 font-semibold text-gray-900 text-base text-left">
|
||||
Connect your site using Newt
|
||||
</div>
|
||||
<div className="inline-block w-full">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto text-left">
|
||||
<span className="text-sm font-mono text-gray-900 tracking-wider">
|
||||
{cliCommand}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
To learn how to use Newt, including more
|
||||
installation methods, visit the{" "}
|
||||
<a
|
||||
href="https://docs.fossorial.io"
|
||||
className="underline"
|
||||
>
|
||||
docs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</EmailSection>
|
||||
|
||||
<EmailInfoSection
|
||||
title="Your Demo Resource"
|
||||
items={[
|
||||
{ label: "Method", value: resourceMethod },
|
||||
{ label: "Hostname", value: resourceHostname },
|
||||
{ label: "Port", value: resourcePort },
|
||||
{
|
||||
label: "Resource URL",
|
||||
value: (
|
||||
<a
|
||||
href={resourceUrl}
|
||||
className="underline text-blue-600"
|
||||
>
|
||||
{resourceUrl}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeQuickStart;
|
|
@ -12,7 +12,11 @@ export default function ButtonLink({
|
|||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`rounded-full bg-primary px-4 py-2 text-center font-semibold text-white text-xl no-underline inline-block ${className}`}
|
||||
className={`inline-block bg-primary hover:bg-primary/90 text-white font-semibold px-8 py-3 rounded-lg text-center no-underline transition-colors ${className}`}
|
||||
style={{
|
||||
backgroundColor: "#F97316",
|
||||
textDecoration: "none"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
|
|
@ -2,10 +2,15 @@ import React from "react";
|
|||
|
||||
export default function CopyCodeBox({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="text-center rounded-lg bg-neutral-100 p-2">
|
||||
<span className="text-2xl font-mono text-neutral-600 tracking-wide">
|
||||
{text}
|
||||
</span>
|
||||
<div className="inline-block">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto">
|
||||
<span className="text-2xl font-mono text-gray-900 tracking-wider font-semibold">
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Copy and paste this code when prompted
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,47 +1,26 @@
|
|||
import { Container } from "@react-email/components";
|
||||
import React from "react";
|
||||
import { Container, Img } from "@react-email/components";
|
||||
|
||||
// EmailContainer: Wraps the entire email layout
|
||||
export function EmailContainer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||
<Container className="bg-white border border-solid border-gray-200 max-w-lg mx-auto my-8 rounded-lg overflow-hidden shadow-sm">
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// EmailLetterHead: For branding or logo at the top
|
||||
// EmailLetterHead: For branding with logo on dark background
|
||||
export function EmailLetterHead() {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<table
|
||||
role="presentation"
|
||||
width="100%"
|
||||
style={{
|
||||
marginBottom: "24px"
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
color: "#F97317"
|
||||
}}
|
||||
>
|
||||
Pangolin
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
textAlign: "right",
|
||||
color: "#6B7280"
|
||||
}}
|
||||
>
|
||||
{new Date().getFullYear()}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div className="px-6 pt-8 pb-2 text-center">
|
||||
<Img
|
||||
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
|
||||
alt="Fossorial"
|
||||
width="120"
|
||||
height="auto"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -49,14 +28,22 @@ export function EmailLetterHead() {
|
|||
// EmailHeading: For the primary message or headline
|
||||
export function EmailHeading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h1 className="text-2xl font-semibold text-gray-800 text-center">
|
||||
{children}
|
||||
</h1>
|
||||
<div className="px-6 pt-4 pb-1">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 text-center leading-tight">
|
||||
{children}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailGreeting({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-base text-gray-700 my-4">{children}</p>;
|
||||
return (
|
||||
<div className="px-6">
|
||||
<p className="text-base text-gray-700 leading-relaxed">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EmailText: For general text content
|
||||
|
@ -68,9 +55,13 @@ export function EmailText({
|
|||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<p className={`my-2 text-base text-gray-700 ${className}`}>
|
||||
{children}
|
||||
</p>
|
||||
<div className="px-6">
|
||||
<p
|
||||
className={`text-base text-gray-700 leading-relaxed ${className}`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -82,20 +73,70 @@ export function EmailSection({
|
|||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={`text-center my-6 ${className}`}>{children}</div>;
|
||||
return (
|
||||
<div className={`px-6 py-6 text-center ${className}`}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EmailFooter: For closing or signature
|
||||
export function EmailFooter({ children }: { children: React.ReactNode }) {
|
||||
return <div className="text-sm text-gray-500 mt-6">{children}</div>;
|
||||
return (
|
||||
<div className="px-6 py-6 border-t border-gray-100 bg-gray-50">
|
||||
{children}
|
||||
<p className="text-xs text-gray-400 mt-4">
|
||||
For any questions or support, please contact us at:
|
||||
<br />
|
||||
support@fossorial.io
|
||||
</p>
|
||||
<p className="text-xs text-gray-300 text-center mt-4">
|
||||
© {new Date().getFullYear()} Fossorial, Inc. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailSignature() {
|
||||
return (
|
||||
<p>
|
||||
Best regards,
|
||||
<br />
|
||||
Fossorial
|
||||
</p>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p className="mb-2">
|
||||
Best regards,
|
||||
<br />
|
||||
<strong>The Fossorial Team</strong>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EmailInfoSection: For structured key-value info (like resource details)
|
||||
export function EmailInfoSection({
|
||||
title,
|
||||
items
|
||||
}: {
|
||||
title?: string;
|
||||
items: { label: string; value: React.ReactNode }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="px-6 py-4">
|
||||
{title && (
|
||||
<div className="mb-2 font-semibold text-gray-900 text-base">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-sm text-left">
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="pr-4 py-1 text-gray-600 align-top whitespace-nowrap">
|
||||
{item.label}
|
||||
</td>
|
||||
<td className="py-1 text-gray-900 break-all">
|
||||
{item.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import React from "react";
|
||||
|
||||
export const themeColors = {
|
||||
theme: {
|
||||
extend: {
|
||||
|
|
|
@ -113,7 +113,9 @@ export class Config {
|
|||
|
||||
private async checkKeyStatus() {
|
||||
const licenseStatus = await license.check();
|
||||
if (!licenseStatus.isHostLicensed) {
|
||||
if (
|
||||
!licenseStatus.isHostLicensed
|
||||
) {
|
||||
this.checkSupporterKey();
|
||||
}
|
||||
}
|
||||
|
|
141
server/lib/ip.ts
141
server/lib/ip.ts
|
@ -14,7 +14,7 @@ type IPVersion = 4 | 6;
|
|||
* Detects IP version from address string
|
||||
*/
|
||||
function detectIpVersion(ip: string): IPVersion {
|
||||
return ip.includes(':') ? 6 : 4;
|
||||
return ip.includes(":") ? 6 : 4;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -24,34 +24,34 @@ function ipToBigInt(ip: string): bigint {
|
|||
const version = detectIpVersion(ip);
|
||||
|
||||
if (version === 4) {
|
||||
return ip.split('.')
|
||||
.reduce((acc, octet) => {
|
||||
const num = parseInt(octet);
|
||||
if (isNaN(num) || num < 0 || num > 255) {
|
||||
throw new Error(`Invalid IPv4 octet: ${octet}`);
|
||||
}
|
||||
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
|
||||
}, BigInt(0));
|
||||
return ip.split(".").reduce((acc, octet) => {
|
||||
const num = parseInt(octet);
|
||||
if (isNaN(num) || num < 0 || num > 255) {
|
||||
throw new Error(`Invalid IPv4 octet: ${octet}`);
|
||||
}
|
||||
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
|
||||
}, BigInt(0));
|
||||
} else {
|
||||
// Handle IPv6
|
||||
// Expand :: notation
|
||||
let fullAddress = ip;
|
||||
if (ip.includes('::')) {
|
||||
const parts = ip.split('::');
|
||||
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
|
||||
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
|
||||
const padding = Array(missing).fill('0').join(':');
|
||||
if (ip.includes("::")) {
|
||||
const parts = ip.split("::");
|
||||
if (parts.length > 2)
|
||||
throw new Error("Invalid IPv6 address: multiple :: found");
|
||||
const missing =
|
||||
8 - (parts[0].split(":").length + parts[1].split(":").length);
|
||||
const padding = Array(missing).fill("0").join(":");
|
||||
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
|
||||
}
|
||||
|
||||
return fullAddress.split(':')
|
||||
.reduce((acc, hextet) => {
|
||||
const num = parseInt(hextet || '0', 16);
|
||||
if (isNaN(num) || num < 0 || num > 65535) {
|
||||
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
|
||||
}
|
||||
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
|
||||
}, BigInt(0));
|
||||
return fullAddress.split(":").reduce((acc, hextet) => {
|
||||
const num = parseInt(hextet || "0", 16);
|
||||
if (isNaN(num) || num < 0 || num > 65535) {
|
||||
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
|
||||
}
|
||||
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
|
||||
}, BigInt(0));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,11 +65,15 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
|
|||
octets.unshift(Number(num & BigInt(255)));
|
||||
num = num >> BigInt(8);
|
||||
}
|
||||
return octets.join('.');
|
||||
return octets.join(".");
|
||||
} else {
|
||||
const hextets: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0'));
|
||||
hextets.unshift(
|
||||
Number(num & BigInt(65535))
|
||||
.toString(16)
|
||||
.padStart(4, "0")
|
||||
);
|
||||
num = num >> BigInt(16);
|
||||
}
|
||||
// Compress zero sequences
|
||||
|
@ -79,7 +83,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
|
|||
let currentZeroLength = 0;
|
||||
|
||||
for (let i = 0; i < hextets.length; i++) {
|
||||
if (hextets[i] === '0000') {
|
||||
if (hextets[i] === "0000") {
|
||||
if (currentZeroStart === -1) currentZeroStart = i;
|
||||
currentZeroLength++;
|
||||
if (currentZeroLength > maxZeroLength) {
|
||||
|
@ -93,12 +97,14 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
|
|||
}
|
||||
|
||||
if (maxZeroLength > 1) {
|
||||
hextets.splice(maxZeroStart, maxZeroLength, '');
|
||||
if (maxZeroStart === 0) hextets.unshift('');
|
||||
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
|
||||
hextets.splice(maxZeroStart, maxZeroLength, "");
|
||||
if (maxZeroStart === 0) hextets.unshift("");
|
||||
if (maxZeroStart + maxZeroLength === 8) hextets.push("");
|
||||
}
|
||||
|
||||
return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':');
|
||||
return hextets
|
||||
.map((h) => (h === "0000" ? "0" : h.replace(/^0+/, "")))
|
||||
.join(":");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -106,7 +112,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
|
|||
* Converts CIDR to IP range
|
||||
*/
|
||||
export function cidrToRange(cidr: string): IPRange {
|
||||
const [ip, prefix] = cidr.split('/');
|
||||
const [ip, prefix] = cidr.split("/");
|
||||
const version = detectIpVersion(ip);
|
||||
const prefixBits = parseInt(prefix);
|
||||
const ipBigInt = ipToBigInt(ip);
|
||||
|
@ -118,7 +124,10 @@ export function cidrToRange(cidr: string): IPRange {
|
|||
}
|
||||
|
||||
const shiftBits = BigInt(maxPrefix - prefixBits);
|
||||
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
|
||||
const mask = BigInt.asUintN(
|
||||
version === 4 ? 64 : 128,
|
||||
(BigInt(1) << shiftBits) - BigInt(1)
|
||||
);
|
||||
const start = ipBigInt & ~mask;
|
||||
const end = start | mask;
|
||||
|
||||
|
@ -142,59 +151,66 @@ export function findNextAvailableCidr(
|
|||
}
|
||||
|
||||
// If no existing CIDRs, use the IP version from startCidr
|
||||
const version = startCidr
|
||||
? detectIpVersion(startCidr.split('/')[0])
|
||||
: 4; // Default to IPv4 if no startCidr provided
|
||||
const version = startCidr ? detectIpVersion(startCidr.split("/")[0]) : 4; // Default to IPv4 if no startCidr provided
|
||||
|
||||
// Use appropriate default startCidr if none provided
|
||||
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
|
||||
|
||||
// If there are existing CIDRs, ensure all are same version
|
||||
if (existingCidrs.length > 0 &&
|
||||
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
|
||||
throw new Error('All CIDRs must be of the same IP version');
|
||||
if (
|
||||
existingCidrs.length > 0 &&
|
||||
existingCidrs.some(
|
||||
(cidr) => detectIpVersion(cidr.split("/")[0]) !== version
|
||||
)
|
||||
) {
|
||||
throw new Error("All CIDRs must be of the same IP version");
|
||||
}
|
||||
|
||||
|
||||
// Extract the network part from startCidr to ensure we stay in the right subnet
|
||||
const startCidrRange = cidrToRange(startCidr);
|
||||
|
||||
|
||||
// Convert existing CIDRs to ranges and sort them
|
||||
const existingRanges = existingCidrs
|
||||
.map(cidr => cidrToRange(cidr))
|
||||
.map((cidr) => cidrToRange(cidr))
|
||||
.sort((a, b) => (a.start < b.start ? -1 : 1));
|
||||
|
||||
|
||||
// Calculate block size
|
||||
const maxPrefix = version === 4 ? 32 : 128;
|
||||
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
|
||||
|
||||
|
||||
// Start from the beginning of the given CIDR
|
||||
let current = startCidrRange.start;
|
||||
const maxIp = startCidrRange.end;
|
||||
|
||||
|
||||
// Iterate through existing ranges
|
||||
for (let i = 0; i <= existingRanges.length; i++) {
|
||||
const nextRange = existingRanges[i];
|
||||
|
||||
|
||||
// Align current to block size
|
||||
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
|
||||
|
||||
const alignedCurrent =
|
||||
current +
|
||||
((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
|
||||
|
||||
// Check if we've gone beyond the maximum allowed IP
|
||||
if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// If we're at the end of existing ranges or found a gap
|
||||
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
|
||||
if (
|
||||
!nextRange ||
|
||||
alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start
|
||||
) {
|
||||
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
|
||||
}
|
||||
|
||||
|
||||
// If next range overlaps with our search space, move past it
|
||||
if (nextRange.end >= startCidrRange.start && nextRange.start <= maxIp) {
|
||||
// Move current pointer to after the current range
|
||||
current = nextRange.end + BigInt(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -206,7 +222,7 @@ export function findNextAvailableCidr(
|
|||
*/
|
||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||
const ipVersion = detectIpVersion(ip);
|
||||
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
|
||||
const cidrVersion = detectIpVersion(cidr.split("/")[0]);
|
||||
|
||||
// If IP versions don't match, the IP cannot be in the CIDR range
|
||||
if (ipVersion !== cidrVersion) {
|
||||
|
@ -219,11 +235,10 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
|
|||
return ipBigInt >= range.start && ipBigInt <= range.end;
|
||||
}
|
||||
|
||||
export async function getNextAvailableClientSubnet(orgId: string): Promise<string> {
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
export async function getNextAvailableClientSubnet(
|
||||
orgId: string
|
||||
): Promise<string> {
|
||||
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
||||
|
||||
const existingAddressesSites = await db
|
||||
.select({
|
||||
|
@ -240,15 +255,15 @@ export async function getNextAvailableClientSubnet(orgId: string): Promise<strin
|
|||
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
|
||||
|
||||
const addresses = [
|
||||
...existingAddressesSites.map((site) => `${site.address?.split("/")[0]}/32`), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
||||
...existingAddressesClients.map((client) => `${client.address.split("/")}/32`)
|
||||
...existingAddressesSites.map(
|
||||
(site) => `${site.address?.split("/")[0]}/32`
|
||||
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
||||
...existingAddressesClients.map(
|
||||
(client) => `${client.address.split("/")}/32`
|
||||
)
|
||||
].filter((address) => address !== null) as string[];
|
||||
|
||||
let subnet = findNextAvailableCidr(
|
||||
addresses,
|
||||
32,
|
||||
org.subnet
|
||||
); // pick the sites address in the org
|
||||
let subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
||||
if (!subnet) {
|
||||
throw new Error("No available subnets remaining in space");
|
||||
}
|
||||
|
|
|
@ -133,7 +133,10 @@ export const configSchema = z
|
|||
db: z.number().int().nonnegative().optional().default(0),
|
||||
tls: z
|
||||
.object({
|
||||
reject_unauthorized: z.boolean().optional().default(true)
|
||||
reject_unauthorized: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
@ -226,9 +229,9 @@ export const configSchema = z
|
|||
disable_local_sites: z.boolean().optional(),
|
||||
disable_basic_wireguard_sites: z.boolean().optional(),
|
||||
disable_config_managed_domains: z.boolean().optional(),
|
||||
enable_clients: z.boolean().optional()
|
||||
enable_clients: z.boolean().optional(),
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -14,10 +14,17 @@ export * from "./verifyAdmin";
|
|||
export * from "./verifySetResourceUsers";
|
||||
export * from "./verifyUserInRole";
|
||||
export * from "./verifyAccessTokenAccess";
|
||||
export * from "./requestTimeout";
|
||||
export * from "./verifyClientAccess";
|
||||
export * from "./verifyUserHasAction";
|
||||
export * from "./verifyUserIsServerAdmin";
|
||||
export * from "./verifyIsLoggedInUser";
|
||||
export * from "./verifyIsLoggedInUser";
|
||||
export * from "./verifyClientAccess";
|
||||
export * from "./integration";
|
||||
export * from "./verifyValidLicense";
|
||||
export * from "./verifyUserHasAction";
|
||||
export * from "./verifyApiKeyAccess";
|
||||
export * from "./verifyDomainAccess";
|
||||
export * from "./verifyClientsEnabled";
|
||||
export * from "./verifyUserIsOrgOwner";
|
||||
|
|
35
server/middlewares/requestTimeout.ts
Normal file
35
server/middlewares/requestTimeout.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import logger from '@server/logger';
|
||||
import createHttpError from 'http-errors';
|
||||
import HttpCode from '@server/types/HttpCode';
|
||||
|
||||
export function requestTimeoutMiddleware(timeoutMs: number = 30000) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
// Set a timeout for the request
|
||||
const timeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
logger.error(`Request timeout: ${req.method} ${req.url} from ${req.ip}`);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.REQUEST_TIMEOUT,
|
||||
'Request timeout - operation took too long to complete'
|
||||
)
|
||||
);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Clear timeout when response finishes
|
||||
res.on('finish', () => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
// Clear timeout when response closes
|
||||
res.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export default requestTimeoutMiddleware;
|
93
server/middlewares/verifyDomainAccess.ts
Normal file
93
server/middlewares/verifyDomainAccess.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, domains, orgDomains } from "@server/db";
|
||||
import { userOrgs, apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyDomainAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const domainId =
|
||||
req.params.domainId || req.body.apiKeyId || req.query.apiKeyId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [domain] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
|
||||
.where(
|
||||
and(
|
||||
eq(orgDomains.domainId, domainId),
|
||||
eq(orgDomains.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!domain.orgDomains) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Domain with ID ${domainId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
const userOrgRole = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, apiKeyOrg.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
req.userOrg = userOrgRole[0];
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying domain access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -14,5 +14,6 @@ export enum OpenAPITags {
|
|||
AccessToken = "Access Token",
|
||||
Idp = "Identity Provider",
|
||||
Client = "Client",
|
||||
ApiKey = "API Key"
|
||||
ApiKey = "API Key",
|
||||
Domain = "Domain"
|
||||
}
|
||||
|
|
|
@ -11,4 +11,4 @@ export * from "./requestPasswordReset";
|
|||
export * from "./resetPassword";
|
||||
export * from "./checkResourceSession";
|
||||
export * from "./setServerAdmin";
|
||||
export * from "./initialSetupComplete";
|
||||
export * from "./initialSetupComplete";
|
|
@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
|
|||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import config from "@server/lib/config";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const requestTotpSecretBody = z
|
||||
|
@ -73,7 +74,11 @@ export async function requestTotpSecret(
|
|||
|
||||
const hex = crypto.getRandomValues(new Uint8Array(20));
|
||||
const secret = encodeHex(hex);
|
||||
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
|
||||
const uri = createTOTPKeyURI(
|
||||
"Pangolin",
|
||||
user.email!,
|
||||
hex
|
||||
);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
|
|
|
@ -57,8 +57,6 @@ export async function signup(
|
|||
|
||||
const { email, password, inviteToken, inviteId } = parsedBody.data;
|
||||
|
||||
logger.debug("signup", { email, password, inviteToken, inviteId });
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const userId = generateId(15);
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { z } from "zod";
|
|||
import { fromError } from "zod-validation-error";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { response } from "@server/lib";
|
||||
import { db } from "@server/db";
|
||||
import { db, userOrgs } from "@server/db";
|
||||
import { User, emailVerificationCodes, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { isWithinExpirationDate } from "oslo";
|
||||
|
|
|
@ -94,7 +94,7 @@ export async function createClient(
|
|||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (!req.userOrgRoleId) {
|
||||
if (req.user && !req.userOrgRoleId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
|
@ -208,7 +208,7 @@ export async function createClient(
|
|||
clientId: newClient.clientId
|
||||
});
|
||||
|
||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
||||
// make sure the user can access the site
|
||||
trx.insert(userClients).values({
|
||||
userId: req.user?.userId!,
|
||||
|
|
|
@ -126,7 +126,7 @@ export async function listClients(
|
|||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (orgId && orgId !== req.userOrgId) {
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
|
@ -135,21 +135,29 @@ export async function listClients(
|
|||
);
|
||||
}
|
||||
|
||||
const accessibleClients = await db
|
||||
.select({
|
||||
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
|
||||
})
|
||||
.from(userClients)
|
||||
.fullJoin(
|
||||
roleClients,
|
||||
eq(userClients.clientId, roleClients.clientId)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(userClients.userId, req.user!.userId),
|
||||
eq(roleClients.roleId, req.userOrgRoleId!)
|
||||
let accessibleClients;
|
||||
if (req.user) {
|
||||
accessibleClients = await db
|
||||
.select({
|
||||
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
|
||||
})
|
||||
.from(userClients)
|
||||
.fullJoin(
|
||||
roleClients,
|
||||
eq(userClients.clientId, roleClients.clientId)
|
||||
)
|
||||
);
|
||||
.where(
|
||||
or(
|
||||
eq(userClients.userId, req.user!.userId),
|
||||
eq(roleClients.roleId, req.userOrgRoleId!)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
accessibleClients = await db
|
||||
.select({ clientId: clients.clientId })
|
||||
.from(clients)
|
||||
.where(eq(clients.orgId, orgId));
|
||||
}
|
||||
|
||||
const accessibleClientIds = accessibleClients.map(
|
||||
(client) => client.clientId
|
||||
|
|
|
@ -7,6 +7,7 @@ import { generateId } from "@server/auth/sessions/app";
|
|||
import { getNextAvailableClientSubnet } from "@server/lib/ip";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
export type PickClientDefaultsResponse = {
|
||||
olmId: string;
|
||||
|
@ -20,6 +21,17 @@ const pickClientDefaultsSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/site/{siteId}/pick-client-defaults",
|
||||
description: "Return pre-requisite data for creating a client.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Site],
|
||||
request: {
|
||||
params: pickClientDefaultsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function pickClientDefaults(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
|
252
server/routers/domain/createOrgDomain.ts
Normal file
252
server/routers/domain/createOrgDomain.ts
Normal file
|
@ -0,0 +1,252 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, Domain, domains, OrgDomains, orgDomains } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { isValidDomain } from "@server/lib/validators";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
type: z.enum(["ns", "cname"]),
|
||||
baseDomain: subdomainSchema
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateDomainResponse = {
|
||||
domainId: string;
|
||||
nsRecords?: string[];
|
||||
cnameRecords?: { baseDomain: string; value: string }[];
|
||||
txtRecords?: { baseDomain: string; value: string }[];
|
||||
};
|
||||
|
||||
// Helper to check if a domain is a subdomain or equal to another domain
|
||||
function isSubdomainOrEqual(a: string, b: string): boolean {
|
||||
const aParts = a.toLowerCase().split(".");
|
||||
const bParts = b.toLowerCase().split(".");
|
||||
if (aParts.length < bParts.length) return false;
|
||||
return aParts.slice(-bParts.length).join(".") === bParts.join(".");
|
||||
}
|
||||
|
||||
export async function createOrgDomain(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { type, baseDomain } = parsedBody.data;
|
||||
|
||||
// Validate organization exists
|
||||
if (!isValidDomain(baseDomain)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain format")
|
||||
);
|
||||
}
|
||||
|
||||
let numOrgDomains: OrgDomains[] | undefined;
|
||||
let cnameRecords: CreateDomainResponse["cnameRecords"];
|
||||
let txtRecords: CreateDomainResponse["txtRecords"];
|
||||
let nsRecords: CreateDomainResponse["nsRecords"];
|
||||
let returned: Domain | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const [existing] = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(
|
||||
and(
|
||||
eq(domains.baseDomain, baseDomain),
|
||||
eq(domains.type, type)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
orgDomains,
|
||||
eq(orgDomains.domainId, domains.domainId)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const {
|
||||
domains: existingDomain,
|
||||
orgDomains: existingOrgDomain
|
||||
} = existing;
|
||||
|
||||
// user alrady added domain to this account
|
||||
// always reject
|
||||
if (existingOrgDomain?.orgId === orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Domain is already added to this org"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// domain already exists elsewhere
|
||||
// check if it's already fully verified
|
||||
if (existingDomain.verified) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Domain is already verified to an org"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Domain overlap logic ---
|
||||
// Only consider existing verified domains
|
||||
const verifiedDomains = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.verified, true));
|
||||
|
||||
if (type === "cname") {
|
||||
// Block if a verified CNAME exists at the same name
|
||||
const cnameExists = verifiedDomains.some(
|
||||
(d) => d.type === "cname" && d.baseDomain === baseDomain
|
||||
);
|
||||
if (cnameExists) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`A CNAME record already exists for ${baseDomain}. Only one CNAME record is allowed per domain.`
|
||||
)
|
||||
);
|
||||
}
|
||||
// Block if a verified NS exists at or below (same or subdomain)
|
||||
const nsAtOrBelow = verifiedDomains.some(
|
||||
(d) =>
|
||||
d.type === "ns" &&
|
||||
(isSubdomainOrEqual(baseDomain, d.baseDomain) ||
|
||||
baseDomain === d.baseDomain)
|
||||
);
|
||||
if (nsAtOrBelow) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`A nameserver (NS) record exists at or below ${baseDomain}. You cannot create a CNAME record here.`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (type === "ns") {
|
||||
// Block if a verified NS exists at or below (same or subdomain)
|
||||
const nsAtOrBelow = verifiedDomains.some(
|
||||
(d) =>
|
||||
d.type === "ns" &&
|
||||
(isSubdomainOrEqual(baseDomain, d.baseDomain) ||
|
||||
baseDomain === d.baseDomain)
|
||||
);
|
||||
if (nsAtOrBelow) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`A nameserver (NS) record already exists at or below ${baseDomain}. You cannot create another NS record here.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const domainId = generateId(15);
|
||||
|
||||
const [insertedDomain] = await trx
|
||||
.insert(domains)
|
||||
.values({
|
||||
domainId,
|
||||
baseDomain,
|
||||
type
|
||||
})
|
||||
.returning();
|
||||
|
||||
returned = insertedDomain;
|
||||
|
||||
// add domain to account
|
||||
await trx
|
||||
.insert(orgDomains)
|
||||
.values({
|
||||
orgId,
|
||||
domainId
|
||||
})
|
||||
.returning();
|
||||
|
||||
// TODO: This needs to be cross region and not hardcoded
|
||||
if (type === "ns") {
|
||||
nsRecords = ["ns-east.fossorial.io", "ns-west.fossorial.io"];
|
||||
} else if (type === "cname") {
|
||||
cnameRecords = [
|
||||
{
|
||||
value: `${domainId}.cname.fossorial.io`,
|
||||
baseDomain: baseDomain
|
||||
},
|
||||
{
|
||||
value: `_acme-challenge.${domainId}.cname.fossorial.io`,
|
||||
baseDomain: `_acme-challenge.${baseDomain}`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
numOrgDomains = await trx
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.orgId, orgId));
|
||||
});
|
||||
|
||||
if (!returned) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create domain"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<CreateDomainResponse>(res, {
|
||||
data: {
|
||||
domainId: returned.domainId,
|
||||
cnameRecords,
|
||||
txtRecords,
|
||||
nsRecords
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Domain created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
72
server/routers/domain/deleteOrgDomain.ts
Normal file
72
server/routers/domain/deleteOrgDomain.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, domains, OrgDomains, orgDomains } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
domainId: z.string(),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type DeleteAccountDomainResponse = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export async function deleteAccountDomain(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsed = paramsSchema.safeParse(req.params);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsed.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { domainId, orgId } = parsed.data;
|
||||
|
||||
let numOrgDomains: OrgDomains[] | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(orgDomains)
|
||||
.where(
|
||||
and(
|
||||
eq(orgDomains.orgId, orgId),
|
||||
eq(orgDomains.domainId, domainId)
|
||||
)
|
||||
);
|
||||
|
||||
await trx.delete(domains).where(eq(domains.domainId, domainId));
|
||||
|
||||
numOrgDomains = await trx
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.orgId, orgId));
|
||||
});
|
||||
|
||||
return response<DeleteAccountDomainResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Domain deleted from account successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1 +1,4 @@
|
|||
export * from "./listDomains";
|
||||
export * from "./createOrgDomain";
|
||||
export * from "./deleteOrgDomain";
|
||||
export * from "./restartOrgDomain";
|
|
@ -37,7 +37,11 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
|
|||
const res = await db
|
||||
.select({
|
||||
domainId: domains.domainId,
|
||||
baseDomain: domains.baseDomain
|
||||
baseDomain: domains.baseDomain,
|
||||
verified: domains.verified,
|
||||
type: domains.type,
|
||||
failed: domains.failed,
|
||||
tries: domains.tries,
|
||||
})
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.orgId, orgId))
|
||||
|
@ -112,7 +116,7 @@ export async function listDomains(
|
|||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Users retrieved successfully",
|
||||
message: "Domains retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
57
server/routers/domain/restartOrgDomain.ts
Normal file
57
server/routers/domain/restartOrgDomain.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, domains } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
domainId: z.string(),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type RestartOrgDomainResponse = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export async function restartOrgDomain(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsed = paramsSchema.safeParse(req.params);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsed.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { domainId, orgId } = parsed.data;
|
||||
|
||||
await db
|
||||
.update(domains)
|
||||
.set({ failed: false, tries: 0 })
|
||||
.where(and(eq(domains.domainId, domainId)));
|
||||
|
||||
return response<RestartOrgDomainResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Domain restarted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -33,15 +33,16 @@ import {
|
|||
verifyClientAccess,
|
||||
verifyApiKeyAccess,
|
||||
createStore,
|
||||
verifyDomainAccess,
|
||||
verifyClientsEnabled,
|
||||
verifyUserHasAction,
|
||||
verifyUserIsOrgOwner
|
||||
} from "@server/middlewares";
|
||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
|
||||
import { createNewt, getNewtToken } from "./newt";
|
||||
import { getOlmToken } from "./olm";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import createHttpError from "http-errors";
|
||||
import { verifyClientsEnabled } from "@server/middlewares/verifyClintsEnabled";
|
||||
|
||||
// Root routes
|
||||
export const unauthenticated = Router();
|
||||
|
@ -54,10 +55,7 @@ unauthenticated.get("/", (_, res) => {
|
|||
export const authenticated = Router();
|
||||
authenticated.use(verifySessionUserMiddleware);
|
||||
|
||||
authenticated.get(
|
||||
"/pick-org-defaults",
|
||||
org.pickOrgDefaults
|
||||
);
|
||||
authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
|
||||
authenticated.get("/org/checkId", org.checkId);
|
||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||
|
||||
|
@ -750,6 +748,29 @@ authenticated.get(
|
|||
apiKeys.getApiKey
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
`/org/:orgId/domain`,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createOrgDomain),
|
||||
domain.createOrgDomain
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/org/:orgId/domain/:domainId/restart`,
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyUserHasAction(ActionsEnum.restartOrgDomain),
|
||||
domain.restartOrgDomain
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
`/org/:orgId/domain/:domainId`,
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrgDomain),
|
||||
domain.deleteAccountDomain
|
||||
);
|
||||
|
||||
// Auth routes
|
||||
export const authRouter = Router();
|
||||
unauthenticated.use("/auth", authRouter);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { eq, and, lt, inArray } from "drizzle-orm";
|
||||
import { eq, and, lt, inArray, sql } from "drizzle-orm";
|
||||
import { sites } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
|
@ -7,6 +7,9 @@ import createHttpError from "http-errors";
|
|||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
|
||||
// Track sites that are already offline to avoid unnecessary queries
|
||||
const offlineSites = new Set<string>();
|
||||
|
||||
interface PeerBandwidth {
|
||||
publicKey: string;
|
||||
bytesIn: number;
|
||||
|
@ -28,43 +31,62 @@ export const receiveBandwidth = async (
|
|||
const currentTime = new Date();
|
||||
const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago
|
||||
|
||||
logger.debug(`Received data: ${JSON.stringify(bandwidthData)}`);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// First, handle sites that are actively reporting bandwidth
|
||||
const activePeers = bandwidthData.filter(peer => peer.bytesIn > 0 || peer.bytesOut > 0);
|
||||
|
||||
const activePeers = bandwidthData.filter(peer => peer.bytesIn > 0); // Bytesout will have data as it tries to send keep alive messages
|
||||
|
||||
if (activePeers.length > 0) {
|
||||
// Get all active sites in one query
|
||||
const activeSites = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(inArray(sites.pubKey, activePeers.map(p => p.publicKey)));
|
||||
// Remove any active peers from offline tracking since they're sending data
|
||||
activePeers.forEach(peer => offlineSites.delete(peer.publicKey));
|
||||
|
||||
// Create a map for quick lookup
|
||||
const siteMap = new Map();
|
||||
activeSites.forEach(site => {
|
||||
siteMap.set(site.pubKey, site);
|
||||
});
|
||||
// Aggregate usage data by organization
|
||||
const orgUsageMap = new Map<string, number>();
|
||||
const orgUptimeMap = new Map<string, number>();
|
||||
|
||||
// Update sites with actual bandwidth usage
|
||||
// Update all active sites with bandwidth data and get the site data in one operation
|
||||
const updatedSites = [];
|
||||
for (const peer of activePeers) {
|
||||
const site = siteMap.get(peer.publicKey);
|
||||
if (!site) continue;
|
||||
|
||||
await trx
|
||||
const updatedSite = await trx
|
||||
.update(sites)
|
||||
.set({
|
||||
megabytesOut: (site.megabytesOut || 0) + peer.bytesIn,
|
||||
megabytesIn: (site.megabytesIn || 0) + peer.bytesOut,
|
||||
megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`,
|
||||
megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`,
|
||||
lastBandwidthUpdate: currentTime.toISOString(),
|
||||
online: true
|
||||
})
|
||||
.where(eq(sites.siteId, site.siteId));
|
||||
.where(eq(sites.pubKey, peer.publicKey))
|
||||
.returning({
|
||||
online: sites.online,
|
||||
orgId: sites.orgId,
|
||||
siteId: sites.siteId,
|
||||
lastBandwidthUpdate: sites.lastBandwidthUpdate,
|
||||
});
|
||||
|
||||
if (updatedSite.length > 0) {
|
||||
updatedSites.push({ ...updatedSite[0], peer });
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate org usage aggregations using the updated site data
|
||||
for (const { peer, ...site } of updatedSites) {
|
||||
// Aggregate bandwidth usage for the org
|
||||
const totalBandwidth = peer.bytesIn + peer.bytesOut;
|
||||
const currentOrgUsage = orgUsageMap.get(site.orgId) || 0;
|
||||
orgUsageMap.set(site.orgId, currentOrgUsage + totalBandwidth);
|
||||
|
||||
// Add 10 seconds of uptime for each active site
|
||||
const currentOrgUptime = orgUptimeMap.get(site.orgId) || 0;
|
||||
orgUptimeMap.set(site.orgId, currentOrgUptime + 10 / 60); // Store in minutes and jut add 10 seconds
|
||||
}
|
||||
}
|
||||
|
||||
// Handle sites that reported zero bandwidth but need online status updated
|
||||
const zeroBandwidthPeers = bandwidthData.filter(peer => peer.bytesIn === 0 && peer.bytesOut === 0);
|
||||
|
||||
const zeroBandwidthPeers = bandwidthData.filter(peer =>
|
||||
peer.bytesIn === 0 && !offlineSites.has(peer.publicKey) // Bytesout will have data as it tries to send keep alive messages
|
||||
);
|
||||
|
||||
if (zeroBandwidthPeers.length > 0) {
|
||||
const zeroBandwidthSites = await trx
|
||||
.select()
|
||||
|
@ -91,18 +113,14 @@ export const receiveBandwidth = async (
|
|||
await trx
|
||||
.update(sites)
|
||||
.set({
|
||||
lastBandwidthUpdate: currentTime.toISOString(),
|
||||
online: newOnlineStatus
|
||||
})
|
||||
.where(eq(sites.siteId, site.siteId));
|
||||
} else {
|
||||
// Just update the heartbeat timestamp
|
||||
await trx
|
||||
.update(sites)
|
||||
.set({
|
||||
lastBandwidthUpdate: currentTime.toISOString()
|
||||
})
|
||||
.where(eq(sites.siteId, site.siteId));
|
||||
|
||||
// If site went offline, add it to our tracking set
|
||||
if (!newOnlineStatus && site.pubKey) {
|
||||
offlineSites.add(site.pubKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
idpOidcConfig,
|
||||
idpOrg,
|
||||
orgs,
|
||||
Role,
|
||||
roles,
|
||||
userOrgs,
|
||||
users
|
||||
|
@ -307,6 +308,8 @@ export async function validateOidcCallback(
|
|||
|
||||
let existingUserId = existingUser?.userId;
|
||||
|
||||
let orgUserCounts: { orgId: string; userCount: number }[] = [];
|
||||
|
||||
// sync the user with the orgs and roles
|
||||
await db.transaction(async (trx) => {
|
||||
let userId = existingUser?.userId;
|
||||
|
@ -410,6 +413,19 @@ export async function validateOidcCallback(
|
|||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Loop through all the orgs and get the total number of users from the userOrgs table
|
||||
for (const org of currentUserOrgs) {
|
||||
const userCount = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, org.orgId));
|
||||
|
||||
orgUserCounts.push({
|
||||
orgId: org.orgId,
|
||||
userCount: userCount.length
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const token = generateSessionToken();
|
||||
|
|
|
@ -87,7 +87,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||
|
||||
let siteSubnet = oldSite.subnet;
|
||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||
if (exitNodeId && oldSite.exitNodeId !== exitNodeId) {
|
||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||
// This effectively moves the exit node to the new one
|
||||
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
|
||||
|
||||
|
@ -105,7 +105,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||
.limit(1);
|
||||
|
||||
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
||||
const subnets = sitesQuery.map((site) => site.subnet);
|
||||
const subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null);
|
||||
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
||||
const newSubnet = findNextAvailableCidr(
|
||||
subnets,
|
||||
|
|
|
@ -49,7 +49,7 @@ export async function createNewt(
|
|||
|
||||
const { newtId, secret } = parsedBody.data;
|
||||
|
||||
if (!req.userOrgRoleId) {
|
||||
if (req.user && !req.userOrgRoleId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
|
|
|
@ -33,8 +33,6 @@ const createOrgSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
// const MAX_ORGS = 5;
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org",
|
||||
|
@ -80,16 +78,6 @@ export async function createOrg(
|
|||
);
|
||||
}
|
||||
|
||||
// const userOrgIds = req.userOrgIds;
|
||||
// if (userOrgIds && userOrgIds.length > MAX_ORGS) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.FORBIDDEN,
|
||||
// `Maximum number of organizations reached.`
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
|
||||
const { orgId, name, subnet } = parsedBody.data;
|
||||
|
||||
if (!isValidCIDR(subnet)) {
|
||||
|
@ -147,7 +135,7 @@ export async function createOrg(
|
|||
.values({
|
||||
orgId,
|
||||
name,
|
||||
subnet,
|
||||
subnet
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
@ -182,15 +170,13 @@ export async function createOrg(
|
|||
const actionIds = await trx.select().from(actions).execute();
|
||||
|
||||
if (actionIds.length > 0) {
|
||||
await trx
|
||||
.insert(roleActions)
|
||||
.values(
|
||||
actionIds.map((action) => ({
|
||||
roleId,
|
||||
actionId: action.actionId,
|
||||
orgId: newOrg[0].orgId
|
||||
}))
|
||||
);
|
||||
await trx.insert(roleActions).values(
|
||||
actionIds.map((action) => ({
|
||||
roleId,
|
||||
actionId: action.actionId,
|
||||
orgId: newOrg[0].orgId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (allDomains.length) {
|
||||
|
@ -227,7 +213,7 @@ export async function createOrg(
|
|||
orgId: newOrg[0].orgId,
|
||||
roleId: roleId,
|
||||
isOwner: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const memberRole = await trx
|
||||
|
|
|
@ -89,6 +89,8 @@ export async function deleteOrg(
|
|||
.where(eq(sites.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
const deletedNewtIds: string[] = [];
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (sites) {
|
||||
for (const site of orgSites) {
|
||||
|
@ -102,11 +104,7 @@ export async function deleteOrg(
|
|||
.where(eq(newts.siteId, site.siteId))
|
||||
.returning();
|
||||
if (deletedNewt) {
|
||||
const payload = {
|
||||
type: `newt/terminate`,
|
||||
data: {}
|
||||
};
|
||||
sendToClient(deletedNewt.newtId, payload);
|
||||
deletedNewtIds.push(deletedNewt.newtId);
|
||||
|
||||
// delete all of the sessions for the newt
|
||||
await trx
|
||||
|
@ -131,6 +129,18 @@ export async function deleteOrg(
|
|||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||
});
|
||||
|
||||
// Send termination messages outside of transaction to prevent blocking
|
||||
for (const newtId of deletedNewtIds) {
|
||||
const payload = {
|
||||
type: `newt/terminate`,
|
||||
data: {}
|
||||
};
|
||||
// Don't await this to prevent blocking the response
|
||||
sendToClient(newtId, payload).catch(error => {
|
||||
logger.error("Failed to send termination message to newt:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
|
|
@ -69,7 +69,8 @@ function queryResources(
|
|||
http: resources.http,
|
||||
protocol: resources.protocol,
|
||||
proxyPort: resources.proxyPort,
|
||||
enabled: resources.enabled
|
||||
enabled: resources.enabled,
|
||||
domainId: resources.domainId
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||
|
@ -103,7 +104,8 @@ function queryResources(
|
|||
http: resources.http,
|
||||
protocol: resources.protocol,
|
||||
proxyPort: resources.proxyPort,
|
||||
enabled: resources.enabled
|
||||
enabled: resources.enabled,
|
||||
domainId: resources.domainId
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||
|
|
|
@ -38,7 +38,7 @@ const createSiteSchema = z
|
|||
subnet: z.string().optional(),
|
||||
newtId: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
// address: z.string().optional(),
|
||||
type: z.enum(["newt", "wireguard", "local"])
|
||||
})
|
||||
.strict()
|
||||
|
@ -97,7 +97,7 @@ export async function createSite(
|
|||
subnet,
|
||||
newtId,
|
||||
secret,
|
||||
address
|
||||
// address
|
||||
} = parsedBody.data;
|
||||
|
||||
const parsedParams = createSiteParamsSchema.safeParse(req.params);
|
||||
|
@ -129,58 +129,58 @@ export async function createSite(
|
|||
);
|
||||
}
|
||||
|
||||
let updatedAddress = null;
|
||||
if (address) {
|
||||
if (!isValidIP(address)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid subnet format. Please provide a valid CIDR notation."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!isIpInCidr(address, org.subnet)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"IP is not in the CIDR range of the subnet."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
|
||||
|
||||
// make sure the subnet is unique
|
||||
const addressExistsSites = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.address, updatedAddress))
|
||||
.limit(1);
|
||||
|
||||
if (addressExistsSites.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`Subnet ${subnet} already exists`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const addressExistsClients = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.subnet, updatedAddress))
|
||||
.limit(1);
|
||||
if (addressExistsClients.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`Subnet ${subnet} already exists`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
// let updatedAddress = null;
|
||||
// if (address) {
|
||||
// if (!isValidIP(address)) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.BAD_REQUEST,
|
||||
// "Invalid subnet format. Please provide a valid CIDR notation."
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// if (!isIpInCidr(address, org.subnet)) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.BAD_REQUEST,
|
||||
// "IP is not in the CIDR range of the subnet."
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
|
||||
//
|
||||
// // make sure the subnet is unique
|
||||
// const addressExistsSites = await db
|
||||
// .select()
|
||||
// .from(sites)
|
||||
// .where(eq(sites.address, updatedAddress))
|
||||
// .limit(1);
|
||||
//
|
||||
// if (addressExistsSites.length > 0) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.CONFLICT,
|
||||
// `Subnet ${subnet} already exists`
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// const addressExistsClients = await db
|
||||
// .select()
|
||||
// .from(sites)
|
||||
// .where(eq(sites.subnet, updatedAddress))
|
||||
// .limit(1);
|
||||
// if (addressExistsClients.length > 0) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.CONFLICT,
|
||||
// `Subnet ${subnet} already exists`
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
const niceId = await getUniqueSiteName(orgId);
|
||||
|
||||
|
@ -205,7 +205,7 @@ export async function createSite(
|
|||
exitNodeId,
|
||||
name,
|
||||
niceId,
|
||||
address: updatedAddress || null,
|
||||
// address: updatedAddress || null,
|
||||
subnet,
|
||||
type,
|
||||
dockerSocketEnabled: type == "newt",
|
||||
|
@ -221,7 +221,7 @@ export async function createSite(
|
|||
orgId,
|
||||
name,
|
||||
niceId,
|
||||
address: updatedAddress || null,
|
||||
// address: updatedAddress || null,
|
||||
type,
|
||||
dockerSocketEnabled: type == "newt",
|
||||
subnet: "0.0.0.0/0"
|
||||
|
|
|
@ -62,6 +62,8 @@ export async function deleteSite(
|
|||
);
|
||||
}
|
||||
|
||||
let deletedNewtId: string | null = null;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (site.pubKey) {
|
||||
if (site.type == "wireguard") {
|
||||
|
@ -73,11 +75,7 @@ export async function deleteSite(
|
|||
.where(eq(newts.siteId, siteId))
|
||||
.returning();
|
||||
if (deletedNewt) {
|
||||
const payload = {
|
||||
type: `newt/terminate`,
|
||||
data: {}
|
||||
};
|
||||
sendToClient(deletedNewt.newtId, payload);
|
||||
deletedNewtId = deletedNewt.newtId;
|
||||
|
||||
// delete all of the sessions for the newt
|
||||
await trx
|
||||
|
@ -90,6 +88,18 @@ export async function deleteSite(
|
|||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||
});
|
||||
|
||||
// Send termination message outside of transaction to prevent blocking
|
||||
if (deletedNewtId) {
|
||||
const payload = {
|
||||
type: `newt/terminate`,
|
||||
data: {}
|
||||
};
|
||||
// Don't await this to prevent blocking the response
|
||||
sendToClient(deletedNewtId, payload).catch(error => {
|
||||
logger.error("Failed to send termination message to newt:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
|
|
@ -20,10 +20,10 @@ export type PickSiteDefaultsResponse = {
|
|||
name: string;
|
||||
listenPort: number;
|
||||
endpoint: string;
|
||||
subnet: string;
|
||||
subnet: string; // TODO: make optional?
|
||||
newtId: string;
|
||||
newtSecret: string;
|
||||
clientAddress: string;
|
||||
clientAddress?: string;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
|
@ -86,7 +86,7 @@ export async function pickSiteDefaults(
|
|||
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
|
||||
|
||||
// TODO: we need to lock this subnet for some time so someone else does not take it
|
||||
let subnets = sitesQuery.map((site) => site.subnet);
|
||||
let subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null);
|
||||
// exclude the exit node address by replacing after the / with a site block size
|
||||
subnets.push(
|
||||
exitNode.address.replace(
|
||||
|
@ -108,17 +108,17 @@ export async function pickSiteDefaults(
|
|||
);
|
||||
}
|
||||
|
||||
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
||||
if (!newClientAddress) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"No available subnet found"
|
||||
)
|
||||
);
|
||||
}
|
||||
// const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
||||
// if (!newClientAddress) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.INTERNAL_SERVER_ERROR,
|
||||
// "No available subnet found"
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
|
||||
const clientAddress = newClientAddress.split("/")[0];
|
||||
// const clientAddress = newClientAddress.split("/")[0];
|
||||
|
||||
const newtId = generateId(15);
|
||||
const secret = generateId(48);
|
||||
|
@ -133,7 +133,7 @@ export async function pickSiteDefaults(
|
|||
endpoint: exitNode.endpoint,
|
||||
// subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet
|
||||
subnet: newSubnet,
|
||||
clientAddress: clientAddress,
|
||||
// clientAddress: clientAddress,
|
||||
newtId,
|
||||
newtSecret: secret
|
||||
},
|
||||
|
|
|
@ -56,12 +56,8 @@ export async function traefikConfigProvider(
|
|||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
subdomain: resources.subdomain,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
blockAccess: resources.blockAccess,
|
||||
sso: resources.sso,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
|
@ -74,10 +70,6 @@ export async function traefikConfigProvider(
|
|||
subnet: sites.subnet,
|
||||
exitNodeId: sites.exitNodeId,
|
||||
},
|
||||
// Org fields
|
||||
org: {
|
||||
orgId: orgs.orgId
|
||||
},
|
||||
enabled: resources.enabled,
|
||||
stickySession: resources.stickySession,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
|
@ -85,7 +77,6 @@ export async function traefikConfigProvider(
|
|||
})
|
||||
.from(resources)
|
||||
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
|
||||
.where(eq(sites.exitNodeId, currentExitNodeId));
|
||||
|
||||
// Get all resource IDs from the first query
|
||||
|
@ -179,7 +170,6 @@ export async function traefikConfigProvider(
|
|||
for (const resource of allResources) {
|
||||
const targets = resource.targets as Target[];
|
||||
const site = resource.site;
|
||||
const org = resource.org;
|
||||
|
||||
const routerName = `${resource.resourceId}-router`;
|
||||
const serviceName = `${resource.resourceId}-service`;
|
||||
|
@ -203,11 +193,6 @@ export async function traefikConfigProvider(
|
|||
continue;
|
||||
}
|
||||
|
||||
// HTTP configuration remains the same
|
||||
if (!resource.subdomain && !resource.isBaseDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add routers and services empty objects if they don't exist
|
||||
if (!config_output.http.routers) {
|
||||
config_output.http.routers = {};
|
||||
|
@ -299,7 +284,7 @@ export async function traefikConfigProvider(
|
|||
} else if (site.type === "newt") {
|
||||
if (
|
||||
!target.internalPort ||
|
||||
!target.method
|
||||
!target.method || !site.subnet
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
@ -315,7 +300,7 @@ export async function traefikConfigProvider(
|
|||
url: `${target.method}://${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (site.type === "newt") {
|
||||
const ip = site.subnet.split("/")[0];
|
||||
const ip = site.subnet!.split("/")[0];
|
||||
return {
|
||||
url: `${target.method}://${ip}:${target.internalPort}`
|
||||
};
|
||||
|
@ -409,7 +394,7 @@ export async function traefikConfigProvider(
|
|||
return false;
|
||||
}
|
||||
} else if (site.type === "newt") {
|
||||
if (!target.internalPort) {
|
||||
if (!target.internalPort || !site.subnet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -424,7 +409,7 @@ export async function traefikConfigProvider(
|
|||
address: `${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (site.type === "newt") {
|
||||
const ip = site.subnet.split("/")[0];
|
||||
const ip = site.subnet!.split("/")[0];
|
||||
return {
|
||||
address: `${ip}:${target.internalPort}`
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, UserOrg } from "@server/db";
|
||||
import { roles, userInvites, userOrgs, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
|
@ -92,6 +92,7 @@ export async function acceptInvite(
|
|||
}
|
||||
|
||||
let roleId: number;
|
||||
let totalUsers: UserOrg[] | undefined;
|
||||
// get the role to make sure it exists
|
||||
const existingRole = await db
|
||||
.select()
|
||||
|
@ -122,6 +123,12 @@ export async function acceptInvite(
|
|||
await trx
|
||||
.delete(userInvites)
|
||||
.where(eq(userInvites.inviteId, inviteId));
|
||||
|
||||
// Get the total number of users in the org now
|
||||
totalUsers = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, existingInvite.orgId));
|
||||
});
|
||||
|
||||
return response<AcceptInviteResponse>(res, {
|
||||
|
|
|
@ -6,7 +6,7 @@ import createHttpError from "http-errors";
|
|||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { db } from "@server/db";
|
||||
import { db, UserOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
|
@ -135,65 +135,76 @@ export async function createOrgUser(
|
|||
);
|
||||
}
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
let orgUsers: UserOrg[] | undefined;
|
||||
|
||||
if (existingUser) {
|
||||
const [existingOrgUser] = await db
|
||||
await db.transaction(async (trx) => {
|
||||
const [existingUser] = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, existingUser.userId)
|
||||
)
|
||||
);
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
|
||||
if (existingOrgUser) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User already exists in this organization"
|
||||
)
|
||||
);
|
||||
if (existingUser) {
|
||||
const [existingOrgUser] = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, existingUser.userId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingOrgUser) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User already exists in this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await trx
|
||||
.insert(userOrgs)
|
||||
.values({
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
roleId: role.roleId
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
const userId = generateId(15);
|
||||
|
||||
const [newUser] = await trx
|
||||
.insert(users)
|
||||
.values({
|
||||
userId: userId,
|
||||
email,
|
||||
username,
|
||||
name,
|
||||
type: "oidc",
|
||||
idpId,
|
||||
dateCreated: new Date().toISOString(),
|
||||
emailVerified: true
|
||||
})
|
||||
.returning();
|
||||
|
||||
await trx
|
||||
.insert(userOrgs)
|
||||
.values({
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
roleId: role.roleId
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(userOrgs)
|
||||
.values({
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
roleId: role.roleId
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
const userId = generateId(15);
|
||||
// List all of the users in the org
|
||||
orgUsers = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, orgId));
|
||||
});
|
||||
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
userId: userId,
|
||||
email,
|
||||
username,
|
||||
name,
|
||||
type: "oidc",
|
||||
idpId,
|
||||
dateCreated: new Date().toISOString(),
|
||||
emailVerified: true
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.insert(userOrgs)
|
||||
.values({
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
roleId: role.roleId
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
||||
|
|
|
@ -99,6 +99,7 @@ export async function inviteUser(
|
|||
regenerate
|
||||
} = parsedBody.data;
|
||||
|
||||
|
||||
// Check if the organization exists
|
||||
const org = await db
|
||||
.select()
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resources, sites } from "@server/db";
|
||||
import { db, resources, sites, UserOrg } from "@server/db";
|
||||
import { userOrgs, userResources, users, userSites } from "@server/db";
|
||||
import { and, eq, exists } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
|
@ -65,6 +65,8 @@ export async function removeUserOrg(
|
|||
);
|
||||
}
|
||||
|
||||
let userCount: UserOrg[] | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(userOrgs)
|
||||
|
@ -108,6 +110,11 @@ export async function removeUserOrg(
|
|||
)
|
||||
)
|
||||
);
|
||||
|
||||
userCount = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, orgId));
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue