mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-21 12:26:40 +00:00
add license system and ui
This commit is contained in:
0
server/db/schemas/hostMeta.ts
Normal file
0
server/db/schemas/hostMeta.ts
Normal file
@@ -1 +1,2 @@
|
||||
export * from "./schema";
|
||||
export * from "./proSchema";
|
||||
|
||||
17
server/db/schemas/proSchema.ts
Normal file
17
server/db/schemas/proSchema.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const licenseKey = sqliteTable("licenseKey", {
|
||||
licenseKeyId: text("licenseKeyId").primaryKey().notNull(),
|
||||
instanceId: text("instanceId").notNull(),
|
||||
token: text("token").notNull()
|
||||
});
|
||||
|
||||
export const hostMeta = sqliteTable("hostMeta", {
|
||||
hostMetaId: text("hostMetaId").primaryKey().notNull(),
|
||||
createdAt: integer("createdAt").notNull()
|
||||
});
|
||||
@@ -12,8 +12,8 @@ import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import stoi from "./stoi";
|
||||
import db from "@server/db";
|
||||
import { SupporterKey, supporterKey } from "@server/db/schemas";
|
||||
import { suppressDeprecationWarnings } from "moment";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { license } from "@server/license/license";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
@@ -267,13 +267,19 @@ export class Config {
|
||||
: "false";
|
||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||
|
||||
if (!this.isDev) {
|
||||
this.checkSupporterKey();
|
||||
}
|
||||
this.checkKeyStatus();
|
||||
|
||||
this.rawConfig = parsedConfig.data;
|
||||
}
|
||||
|
||||
private async checkKeyStatus() {
|
||||
const licenseStatus = await license.check();
|
||||
console.log("License status", licenseStatus);
|
||||
if (!licenseStatus.isHostLicensed) {
|
||||
this.checkSupporterKey();
|
||||
}
|
||||
}
|
||||
|
||||
public getRawConfig() {
|
||||
return this.rawConfig;
|
||||
}
|
||||
|
||||
429
server/license/license.ts
Normal file
429
server/license/license.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import db from "@server/db";
|
||||
import { hostMeta, licenseKey, sites } from "@server/db/schemas";
|
||||
import logger from "@server/logger";
|
||||
import NodeCache from "node-cache";
|
||||
import { validateJWT } from "./licenseJwt";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
|
||||
export type LicenseStatus = {
|
||||
isHostLicensed: boolean; // Are there any license keys?
|
||||
isLicenseValid: boolean; // Is the license key valid?
|
||||
hostId: string; // Host ID
|
||||
maxSites?: number;
|
||||
usedSites?: number;
|
||||
};
|
||||
|
||||
export type LicenseKeyCache = {
|
||||
licenseKey: string;
|
||||
valid: boolean;
|
||||
iat?: Date;
|
||||
type?: "LICENSE" | "SITES";
|
||||
numSites?: number;
|
||||
};
|
||||
|
||||
type ActivateLicenseKeyAPIResponse = {
|
||||
data: {
|
||||
instanceId: string;
|
||||
};
|
||||
success: boolean;
|
||||
error: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type ValidateLicenseAPIResponse = {
|
||||
data: {
|
||||
licenseKeys: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
success: boolean;
|
||||
error: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type TokenPayload = {
|
||||
valid: boolean;
|
||||
type: "LICENSE" | "SITES";
|
||||
quantity: number;
|
||||
terminateAt: string; // ISO
|
||||
iat: number; // Issued at
|
||||
};
|
||||
|
||||
export class License {
|
||||
private phoneHomeInterval = 6 * 60 * 60; // 6 hours = 6 * 60 * 60 = 21600 seconds
|
||||
private validationServerUrl =
|
||||
"https://api.dev.fossorial.io/api/v1/license/professional/validate";
|
||||
private activationServerUrl =
|
||||
"https://api.dev.fossorial.io/api/v1/license/professional/activate";
|
||||
|
||||
private statusCache = new NodeCache({ stdTTL: this.phoneHomeInterval });
|
||||
private licenseKeyCache = new NodeCache();
|
||||
|
||||
private ephemeralKey!: string;
|
||||
private statusKey = "status";
|
||||
|
||||
private publicKey = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF
|
||||
FNkRDggQfYO6Ae+EWHGujZ9WYAZ10spLh9F/zoLhhr3XhsjpoRXwMfgNuO5HstWf
|
||||
CYM20I0l7EUUMWEyWd4tZLd+5XQ4jY5xWOCWyFJAGQSp7flcRmxdfde+l+xg9eKl
|
||||
apbY84aVp09/GqM96hCS+CsQZrhohu/aOqYVB/eAhF01qsbmiZ7Y3WtdhTldveYt
|
||||
h4mZWGmjf8d/aEgePf/tk1gp0BUxf+Ae5yqoAqU+6aiFbjJ7q1kgxc18PWFGfE9y
|
||||
zSk+OZk887N5ThQ52154+oOUCMMR2Y3t5OH1hVZod51vuY2u5LsQXsf+87PwB91y
|
||||
LQIDAQAB
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
constructor(private hostId: string) {
|
||||
this.ephemeralKey = Buffer.from(
|
||||
JSON.stringify({ ts: new Date().toISOString() })
|
||||
).toString("base64");
|
||||
|
||||
setInterval(
|
||||
() => {
|
||||
this.check();
|
||||
},
|
||||
1000 * 60 * 60
|
||||
); // 1 hour = 60 * 60 = 3600 seconds
|
||||
}
|
||||
|
||||
public listKeys(): LicenseKeyCache[] {
|
||||
const keys = this.licenseKeyCache.keys();
|
||||
return keys.map((key) => {
|
||||
return this.licenseKeyCache.get<LicenseKeyCache>(key)!;
|
||||
});
|
||||
}
|
||||
|
||||
public async forceRecheck() {
|
||||
this.statusCache.flushAll();
|
||||
this.licenseKeyCache.flushAll();
|
||||
|
||||
return await this.check();
|
||||
}
|
||||
|
||||
public async isUnlocked(): Promise<boolean> {
|
||||
const status = await this.check();
|
||||
if (status.isHostLicensed) {
|
||||
if (status.isLicenseValid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async check(): Promise<LicenseStatus> {
|
||||
// Set used sites
|
||||
const [siteCount] = await db
|
||||
.select({
|
||||
value: count()
|
||||
})
|
||||
.from(sites);
|
||||
|
||||
const status: LicenseStatus = {
|
||||
hostId: this.hostId,
|
||||
isHostLicensed: true,
|
||||
isLicenseValid: false,
|
||||
maxSites: undefined,
|
||||
usedSites: 150
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.statusCache.has(this.statusKey)) {
|
||||
const res = this.statusCache.get("status") as LicenseStatus;
|
||||
res.usedSites = status.usedSites;
|
||||
return res;
|
||||
}
|
||||
|
||||
// Invalidate all
|
||||
this.licenseKeyCache.flushAll();
|
||||
|
||||
logger.debug("Checking license status...");
|
||||
|
||||
const allKeysRes = await db.select().from(licenseKey);
|
||||
|
||||
if (allKeysRes.length === 0) {
|
||||
status.isHostLicensed = false;
|
||||
return status;
|
||||
}
|
||||
|
||||
// Validate stored license keys
|
||||
for (const key of allKeysRes) {
|
||||
try {
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
key.token,
|
||||
this.publicKey
|
||||
);
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
key.licenseKeyId,
|
||||
{
|
||||
licenseKey: key.licenseKeyId,
|
||||
valid: payload.valid,
|
||||
type: payload.type,
|
||||
numSites: payload.quantity,
|
||||
iat: new Date(payload.iat * 1000)
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Error validating license key: ${key.licenseKeyId}`
|
||||
);
|
||||
logger.error(e);
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
key.licenseKeyId,
|
||||
{
|
||||
licenseKey: key.licenseKeyId,
|
||||
valid: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const keys = allKeysRes.map((key) => ({
|
||||
licenseKey: key.licenseKeyId,
|
||||
instanceId: key.instanceId
|
||||
}));
|
||||
|
||||
let apiResponse: ValidateLicenseAPIResponse | undefined;
|
||||
try {
|
||||
// Phone home to validate license keys
|
||||
apiResponse = await this.phoneHome(keys);
|
||||
|
||||
if (!apiResponse?.success) {
|
||||
throw new Error(apiResponse?.error);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Error communicating with license server:");
|
||||
logger.error(e);
|
||||
}
|
||||
|
||||
logger.debug("Validate response", apiResponse);
|
||||
|
||||
// Check and update all license keys with server response
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
|
||||
key.licenseKey
|
||||
)!;
|
||||
const licenseKeyRes =
|
||||
apiResponse?.data?.licenseKeys[key.licenseKey];
|
||||
|
||||
if (!apiResponse || !licenseKeyRes) {
|
||||
logger.debug(
|
||||
`No response from server for license key: ${key.licenseKey}`
|
||||
);
|
||||
if (cached.iat) {
|
||||
const exp = moment(cached.iat)
|
||||
.add(7, "days")
|
||||
.toDate();
|
||||
if (exp > new Date()) {
|
||||
logger.debug(
|
||||
`Using cached license key: ${key.licenseKey}, valid ${cached.valid}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Can't trust license key: ${key.licenseKey}`
|
||||
);
|
||||
cached.valid = false;
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
key.licenseKey,
|
||||
cached
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
licenseKeyRes,
|
||||
this.publicKey
|
||||
);
|
||||
cached.valid = payload.valid;
|
||||
cached.type = payload.type;
|
||||
cached.numSites = payload.quantity;
|
||||
cached.iat = new Date(payload.iat * 1000);
|
||||
|
||||
await db
|
||||
.update(licenseKey)
|
||||
.set({
|
||||
token: licenseKeyRes
|
||||
})
|
||||
.where(eq(licenseKey.licenseKeyId, key.licenseKey));
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
key.licenseKey,
|
||||
cached
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(`Error validating license key: ${key}`);
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute host status
|
||||
for (const key of keys) {
|
||||
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
|
||||
key.licenseKey
|
||||
)!;
|
||||
|
||||
logger.debug("Checking key", cached);
|
||||
|
||||
if (cached.type === "LICENSE") {
|
||||
status.isLicenseValid = cached.valid;
|
||||
}
|
||||
|
||||
if (!cached.valid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!status.maxSites) {
|
||||
status.maxSites = 0;
|
||||
}
|
||||
|
||||
status.maxSites += cached.numSites || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error checking license status:");
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
this.statusCache.set(this.statusKey, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
public async activateLicenseKey(key: string) {
|
||||
const [existingKey] = await db
|
||||
.select()
|
||||
.from(licenseKey)
|
||||
.where(eq(licenseKey.licenseKeyId, key))
|
||||
.limit(1);
|
||||
|
||||
if (existingKey) {
|
||||
throw new Error("License key already exists");
|
||||
}
|
||||
|
||||
let instanceId: string | undefined;
|
||||
try {
|
||||
// Call activate
|
||||
const apiResponse = await fetch(this.activationServerUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKey: key,
|
||||
instanceName: this.hostId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await apiResponse.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(`${data.message || data.error}`);
|
||||
}
|
||||
|
||||
const response = data as ActivateLicenseKeyAPIResponse;
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("No response from server");
|
||||
}
|
||||
|
||||
if (!response.data.instanceId) {
|
||||
throw new Error("No instance ID in response");
|
||||
}
|
||||
|
||||
instanceId = response.data.instanceId;
|
||||
} catch (error) {
|
||||
throw Error(`Error activating license key: ${error}`);
|
||||
}
|
||||
|
||||
// Phone home to validate license key
|
||||
const keys = [
|
||||
{
|
||||
licenseKey: key,
|
||||
instanceId: instanceId!
|
||||
}
|
||||
];
|
||||
|
||||
let validateResponse: ValidateLicenseAPIResponse;
|
||||
try {
|
||||
validateResponse = await this.phoneHome(keys);
|
||||
|
||||
if (!validateResponse) {
|
||||
throw new Error("No response from server");
|
||||
}
|
||||
|
||||
if (!validateResponse.success) {
|
||||
throw new Error(validateResponse.error);
|
||||
}
|
||||
|
||||
// Validate the license key
|
||||
const licenseKeyRes = validateResponse.data.licenseKeys[key];
|
||||
if (!licenseKeyRes) {
|
||||
throw new Error("Invalid license key");
|
||||
}
|
||||
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
licenseKeyRes,
|
||||
this.publicKey
|
||||
);
|
||||
|
||||
if (!payload.valid) {
|
||||
throw new Error("Invalid license key");
|
||||
}
|
||||
|
||||
// Store the license key in the database
|
||||
await db.insert(licenseKey).values({
|
||||
licenseKeyId: key,
|
||||
token: licenseKeyRes,
|
||||
instanceId: instanceId!
|
||||
});
|
||||
} catch (error) {
|
||||
throw Error(`Error validating license key: ${error}`);
|
||||
}
|
||||
|
||||
// Invalidate the cache and re-compute the status
|
||||
return await this.forceRecheck();
|
||||
}
|
||||
|
||||
private async phoneHome(
|
||||
keys: {
|
||||
licenseKey: string;
|
||||
instanceId: string;
|
||||
}[]
|
||||
): Promise<ValidateLicenseAPIResponse> {
|
||||
const response = await fetch(this.validationServerUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKeys: keys,
|
||||
ephemeralKey: this.ephemeralKey,
|
||||
instanceName: this.hostId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data as ValidateLicenseAPIResponse;
|
||||
}
|
||||
}
|
||||
|
||||
const [info] = await db.select().from(hostMeta).limit(1);
|
||||
|
||||
if (!info) {
|
||||
throw new Error("Host information not found");
|
||||
}
|
||||
|
||||
export const license = new License(info.hostMetaId);
|
||||
|
||||
export default license;
|
||||
114
server/license/licenseJwt.ts
Normal file
114
server/license/licenseJwt.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import * as crypto from "crypto";
|
||||
|
||||
/**
|
||||
* Validates a JWT using a public key
|
||||
* @param token - The JWT to validate
|
||||
* @param publicKey - The public key used for verification (PEM format)
|
||||
* @returns The decoded payload if validation succeeds, throws an error otherwise
|
||||
*/
|
||||
function validateJWT<Payload>(
|
||||
token: string,
|
||||
publicKey: string
|
||||
): Payload {
|
||||
// Split the JWT into its three parts
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT format");
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, signature] = parts;
|
||||
|
||||
// Decode the header to get the algorithm
|
||||
const header = JSON.parse(Buffer.from(encodedHeader, "base64").toString());
|
||||
const algorithm = header.alg;
|
||||
|
||||
// Verify the signature
|
||||
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const isValid = verify(signatureInput, signature, publicKey, algorithm);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error("Invalid signature");
|
||||
}
|
||||
|
||||
// Decode the payload
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(encodedPayload, "base64").toString()
|
||||
);
|
||||
|
||||
// Check if the token has expired
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && payload.exp < now) {
|
||||
throw new Error("Token has expired");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the signature of a JWT
|
||||
*/
|
||||
function verify(
|
||||
input: string,
|
||||
signature: string,
|
||||
publicKey: string,
|
||||
algorithm: string
|
||||
): boolean {
|
||||
let verifyAlgorithm: string;
|
||||
|
||||
// Map JWT algorithm name to Node.js crypto algorithm name
|
||||
switch (algorithm) {
|
||||
case "RS256":
|
||||
verifyAlgorithm = "RSA-SHA256";
|
||||
break;
|
||||
case "RS384":
|
||||
verifyAlgorithm = "RSA-SHA384";
|
||||
break;
|
||||
case "RS512":
|
||||
verifyAlgorithm = "RSA-SHA512";
|
||||
break;
|
||||
case "ES256":
|
||||
verifyAlgorithm = "SHA256";
|
||||
break;
|
||||
case "ES384":
|
||||
verifyAlgorithm = "SHA384";
|
||||
break;
|
||||
case "ES512":
|
||||
verifyAlgorithm = "SHA512";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported algorithm: ${algorithm}`);
|
||||
}
|
||||
|
||||
// Convert base64url signature to standard base64
|
||||
const base64Signature = base64URLToBase64(signature);
|
||||
|
||||
// Verify the signature
|
||||
const verifier = crypto.createVerify(verifyAlgorithm);
|
||||
verifier.update(input);
|
||||
return verifier.verify(publicKey, base64Signature, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts base64url format to standard base64
|
||||
*/
|
||||
function base64URLToBase64(base64url: string): string {
|
||||
// Add padding if needed
|
||||
let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const pad = base64.length % 4;
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error("Invalid base64url string");
|
||||
}
|
||||
base64 += "=".repeat(4 - pad);
|
||||
}
|
||||
|
||||
return base64;
|
||||
}
|
||||
|
||||
export { validateJWT };
|
||||
@@ -11,6 +11,7 @@ import * as role from "./role";
|
||||
import * as supporterKey from "./supporterKey";
|
||||
import * as accessToken from "./accessToken";
|
||||
import * as idp from "./idp";
|
||||
import * as license from "./license";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
verifyAccessTokenAccess,
|
||||
@@ -524,6 +525,30 @@ authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
|
||||
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
|
||||
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||
|
||||
authenticated.post(
|
||||
"/license/activate",
|
||||
verifyUserIsServerAdmin,
|
||||
license.activateLicense
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/license/keys",
|
||||
verifyUserIsServerAdmin,
|
||||
license.listLicenseKeys
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/license/:licenseKey",
|
||||
verifyUserIsServerAdmin,
|
||||
license.deleteLicenseKey
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/license/recheck",
|
||||
verifyUserIsServerAdmin,
|
||||
license.recheckStatus
|
||||
);
|
||||
|
||||
// Auth routes
|
||||
export const authRouter = Router();
|
||||
unauthenticated.use("/auth", authRouter);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import license from "@server/license/license";
|
||||
|
||||
const paramsSchema = z.object({}).strict();
|
||||
|
||||
@@ -67,7 +68,7 @@ export async function createOidcIdp(
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
let {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authUrl,
|
||||
@@ -80,6 +81,10 @@ export async function createOidcIdp(
|
||||
autoProvision
|
||||
} = parsedBody.data;
|
||||
|
||||
if (!(await license.isUnlocked())) {
|
||||
autoProvision = false;
|
||||
}
|
||||
|
||||
const key = config.getRawConfig().server.secret;
|
||||
|
||||
const encryptedSecret = encrypt(clientSecret, key);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import license from "@server/license/license";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -84,7 +85,7 @@ export async function updateOidcIdp(
|
||||
}
|
||||
|
||||
const { idpId } = parsedParams.data;
|
||||
const {
|
||||
let {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authUrl,
|
||||
@@ -99,6 +100,10 @@ export async function updateOidcIdp(
|
||||
defaultOrgMapping
|
||||
} = parsedBody.data;
|
||||
|
||||
if (!(await license.isUnlocked())) {
|
||||
autoProvision = false;
|
||||
}
|
||||
|
||||
// Check if IDP exists and is of type OIDC
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as resource from "./resource";
|
||||
import * as badger from "./badger";
|
||||
import * as auth from "@server/routers/auth";
|
||||
import * as supporterKey from "@server/routers/supporterKey";
|
||||
import * as license from "@server/routers/license";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
verifyResourceAccess,
|
||||
@@ -37,6 +38,11 @@ internalRouter.get(
|
||||
supporterKey.isSupporterKeyVisible
|
||||
);
|
||||
|
||||
internalRouter.get(
|
||||
`/license/status`,
|
||||
license.getLicenseStatus
|
||||
);
|
||||
|
||||
// Gerbil routes
|
||||
const gerbilRouter = Router();
|
||||
internalRouter.use("/gerbil", gerbilRouter);
|
||||
|
||||
62
server/routers/license/activateLicense.ts
Normal file
62
server/routers/license/activateLicense.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib";
|
||||
import license, { LicenseStatus } from "@server/license/license";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
licenseKey: z.string().min(1).max(255)
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ActivateLicenseStatus = LicenseStatus;
|
||||
|
||||
export async function activateLicense(
|
||||
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 { licenseKey } = parsedBody.data;
|
||||
|
||||
try {
|
||||
const status = await license.activateLicenseKey(licenseKey);
|
||||
return sendResponse(res, {
|
||||
data: status,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "License key activated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
76
server/routers/license/deleteLicenseKey.ts
Normal file
76
server/routers/license/deleteLicenseKey.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import db from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { licenseKey } from "@server/db/schemas";
|
||||
import license, { LicenseStatus } from "@server/license/license";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
licenseKey: z.string().min(1).max(255)
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type DeleteLicenseKeyResponse = LicenseStatus;
|
||||
|
||||
export async function deleteLicenseKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { licenseKey: key } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(licenseKey)
|
||||
.where(eq(licenseKey.licenseKeyId, key))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`License key ${key} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(licenseKey).where(eq(licenseKey.licenseKeyId, key));
|
||||
|
||||
const status = await license.forceRecheck();
|
||||
|
||||
return sendResponse(res, {
|
||||
data: status,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "License key deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
36
server/routers/license/getLicenseStatus.ts
Normal file
36
server/routers/license/getLicenseStatus.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib";
|
||||
import license, { LicenseStatus } from "@server/license/license";
|
||||
|
||||
export type GetLicenseStatusResponse = LicenseStatus;
|
||||
|
||||
export async function getLicenseStatus(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const status = await license.check();
|
||||
|
||||
return sendResponse<GetLicenseStatusResponse>(res, {
|
||||
data: status,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Got status",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
10
server/routers/license/index.ts
Normal file
10
server/routers/license/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
export * from "./getLicenseStatus";
|
||||
export * from "./activateLicense";
|
||||
export * from "./listLicenseKeys";
|
||||
export * from "./deleteLicenseKey";
|
||||
export * from "./recheckStatus";
|
||||
36
server/routers/license/listLicenseKeys.ts
Normal file
36
server/routers/license/listLicenseKeys.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib";
|
||||
import license, { LicenseKeyCache } from "@server/license/license";
|
||||
|
||||
export type ListLicenseKeysResponse = LicenseKeyCache[];
|
||||
|
||||
export async function listLicenseKeys(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const keys = license.listKeys();
|
||||
|
||||
return sendResponse<ListLicenseKeysResponse>(res, {
|
||||
data: keys,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Successfully retrieved license keys",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
42
server/routers/license/recheckStatus.ts
Normal file
42
server/routers/license/recheckStatus.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib";
|
||||
import license, { LicenseStatus } from "@server/license/license";
|
||||
|
||||
export type RecheckStatusResponse = LicenseStatus;
|
||||
|
||||
export async function recheckStatus(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
try {
|
||||
const status = await license.forceRecheck();
|
||||
return sendResponse(res, {
|
||||
data: status,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "License status rechecked successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import config from "@server/lib/config";
|
||||
import db from "@server/db";
|
||||
import { count } from "drizzle-orm";
|
||||
import { users } from "@server/db/schemas";
|
||||
import license from "@server/license/license";
|
||||
|
||||
export type IsSupporterKeyVisibleResponse = {
|
||||
visible: boolean;
|
||||
@@ -26,6 +27,12 @@ export async function isSupporterKeyVisible(
|
||||
|
||||
let visible = !hidden && key?.valid !== true;
|
||||
|
||||
const licenseStatus = await license.check();
|
||||
|
||||
if (licenseStatus.isLicenseValid) {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
if (key?.tier === "Limited Supporter") {
|
||||
const [numUsers] = await db.select({ count: count() }).from(users);
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { copyInConfig } from "./copyInConfig";
|
||||
import { setupServerAdmin } from "./setupServerAdmin";
|
||||
import logger from "@server/logger";
|
||||
import { clearStaleData } from "./clearStaleData";
|
||||
import { setHostMeta } from "./setHostMeta";
|
||||
|
||||
export async function runSetupFunctions() {
|
||||
try {
|
||||
await setHostMeta();
|
||||
await copyInConfig(); // copy in the config to the db as needed
|
||||
await setupServerAdmin();
|
||||
await ensureActions(); // make sure all of the actions are in the db and the roles
|
||||
|
||||
17
server/setup/setHostMeta.ts
Normal file
17
server/setup/setHostMeta.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import db from "@server/db";
|
||||
import { hostMeta } from "@server/db/schemas";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export async function setHostMeta() {
|
||||
const [existing] = await db.select().from(hostMeta).limit(1);
|
||||
|
||||
if (existing && existing.hostMetaId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
|
||||
await db
|
||||
.insert(hostMeta)
|
||||
.values({ hostMetaId: id, createdAt: new Date().getTime() });
|
||||
}
|
||||
Reference in New Issue
Block a user