Compare commits

..

5 Commits

Author SHA1 Message Date
miloschwartz 4048fa274a fix non admins cant see private resources details in launcher 2026-08-06 12:32:19 -04:00
miloschwartz 82b86263dc allow chars in 2fa input form closes #3532 2026-08-06 11:39:06 -04:00
Owen 835a30cffe Show the cert status of the namespace domains properly 2026-08-04 17:44:34 -04:00
Owen 18b90da6ab Merge branch 'main' into dev 2026-08-04 17:22:32 -04:00
Owen Schwartz efe22c889c Merge pull request #3522 from fosrl/dev
Move rate linmit to file
2026-08-03 14:35:43 -04:00
12 changed files with 229 additions and 31 deletions
+3 -3
View File
@@ -28,7 +28,7 @@ async function getAccessDays(orgId: string): Promise<number> {
const [org] = await db const [org] = await db
.select({ .select({
settingsLogRetentionDaysAccess: orgs.settingsLogRetentionDaysAccess settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction
}) })
.from(orgs) .from(orgs)
.where(eq(orgs.orgId, orgId)) .where(eq(orgs.orgId, orgId))
@@ -41,11 +41,11 @@ async function getAccessDays(orgId: string): Promise<number> {
// store the result in cache // store the result in cache
await cache.set( await cache.set(
`org_${orgId}_accessDays`, `org_${orgId}_accessDays`,
org.settingsLogRetentionDaysAccess, org.settingsLogRetentionDaysAction,
300 300
); );
return org.settingsLogRetentionDaysAccess; return org.settingsLogRetentionDaysAction;
} }
export async function cleanUpOldLogs(orgId: string, retentionDays: number) { export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
@@ -10,12 +10,12 @@
* *
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { certificates, db, domains, orgDomains } from "@server/db"; import { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { type GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import { type GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq, inArray, or } from "drizzle-orm"; import { and, eq, inArray, isNotNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -63,14 +63,28 @@ async function query(orgId: string, domainList: string[]) {
}) })
.from(certificates) .from(certificates)
.innerJoin(domains, eq(certificates.domainId, domains.domainId)) .innerJoin(domains, eq(certificates.domainId, domains.domainId))
.innerJoin( .leftJoin(
orgDomains, orgDomains,
and( and(
eq(domains.domainId, orgDomains.domainId), eq(domains.domainId, orgDomains.domainId),
eq(orgDomains.orgId, orgId) eq(orgDomains.orgId, orgId)
) )
) )
.where(and(inArray(certificates.domain, domainList))); .leftJoin(
domainNamespaces,
eq(domains.domainId, domainNamespaces.domainId)
)
.where(
and(
inArray(certificates.domain, domainList),
// Namespace domains are shared across all orgs, so they skip
// the org-ownership check (mirrors verifyCertificateAccess).
or(
isNotNull(orgDomains.orgId),
isNotNull(domainNamespaces.domainNamespaceId)
)
)
);
// All non resolved domain certificates might be `ns` or `wildcard`, // All non resolved domain certificates might be `ns` or `wildcard`,
// which means exact domain certificates do not exist // which means exact domain certificates do not exist
@@ -110,19 +124,27 @@ async function query(orgId: string, domainList: string[]) {
}) })
.from(certificates) .from(certificates)
.innerJoin(domains, eq(certificates.domainId, domains.domainId)) .innerJoin(domains, eq(certificates.domainId, domains.domainId))
.innerJoin( .leftJoin(
orgDomains, orgDomains,
and( and(
eq(domains.domainId, orgDomains.domainId), eq(domains.domainId, orgDomains.domainId),
eq(orgDomains.orgId, orgId) eq(orgDomains.orgId, orgId)
) )
) )
.leftJoin(
domainNamespaces,
eq(domains.domainId, domainNamespaces.domainId)
)
.where( .where(
and( and(
eq(certificates.wildcard, true), eq(certificates.wildcard, true),
or( or(
inArray(certificates.domain, [...domainLevelDownSet]), inArray(certificates.domain, [...domainLevelDownSet]),
inArray(certificates.domain, [...wildcardDomainSet]) inArray(certificates.domain, [...wildcardDomainSet])
),
or(
isNotNull(orgDomains.orgId),
isNotNull(domainNamespaces.domainNamespaceId)
) )
) )
); );
+3 -1
View File
@@ -34,7 +34,9 @@ const createRoleSchema = z.strictObject({
export const defaultRoleAllowedActions: ActionsEnum[] = [ export const defaultRoleAllowedActions: ActionsEnum[] = [
ActionsEnum.getOrg, ActionsEnum.getOrg,
ActionsEnum.getResource, ActionsEnum.getResource,
ActionsEnum.listResources ActionsEnum.listResources,
ActionsEnum.getSiteResource,
ActionsEnum.listSiteResources
]; ];
export type CreateRoleBody = z.infer<typeof createRoleSchema>; export type CreateRoleBody = z.infer<typeof createRoleSchema>;
@@ -3,11 +3,13 @@ import {
DB_TYPE, DB_TYPE,
Label, Label,
SiteResource, SiteResource,
roleSiteResources,
siteNetworks, siteNetworks,
siteResourceLabels, siteResourceLabels,
siteResources, siteResources,
sites, sites,
labels labels,
userSiteResources
} from "@server/db"; } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -323,7 +325,48 @@ export async function listAllSiteResourcesByOrg(
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
const conditions = [and(eq(siteResources.orgId, orgId))]; let accessibleSiteResourceIds: number[];
if (req.user) {
const accessibleSiteResources = await db
.select({
siteResourceId: sql<number>`COALESCE(${userSiteResources.siteResourceId}, ${roleSiteResources.siteResourceId})`
})
.from(userSiteResources)
.fullJoin(
roleSiteResources,
eq(
userSiteResources.siteResourceId,
roleSiteResources.siteResourceId
)
)
.where(
or(
eq(userSiteResources.userId, req.user.userId),
inArray(
roleSiteResources.roleId,
req.userOrgRoleIds ?? []
)
)
);
accessibleSiteResourceIds = accessibleSiteResources.map(
(row) => row.siteResourceId
);
} else {
const allOrgSiteResources = await db
.select({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.where(eq(siteResources.orgId, orgId));
accessibleSiteResourceIds = allOrgSiteResources.map(
(row) => row.siteResourceId
);
}
const conditions = [
and(
eq(siteResources.orgId, orgId),
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
)
];
if (siteId != null) { if (siteId != null) {
// Keep inner joins here: filtering by a specific site implies the // Keep inner joins here: filtering by a specific site implies the
@@ -1,11 +1,17 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, networks, siteNetworks } from "@server/db"; import {
db,
networks,
roleSiteResources,
siteNetworks,
userSiteResources
} from "@server/db";
import { siteResources, sites, SiteResource } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { and, asc, desc, eq } from "drizzle-orm"; import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -159,10 +165,47 @@ export async function listSiteResources(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
} }
let accessibleSiteResourceIds: number[];
if (req.user) {
const accessibleSiteResources = await db
.select({
siteResourceId: sql<number>`COALESCE(${userSiteResources.siteResourceId}, ${roleSiteResources.siteResourceId})`
})
.from(userSiteResources)
.fullJoin(
roleSiteResources,
eq(
userSiteResources.siteResourceId,
roleSiteResources.siteResourceId
)
)
.where(
or(
eq(userSiteResources.userId, req.user.userId),
inArray(
roleSiteResources.roleId,
req.userOrgRoleIds ?? []
)
)
);
accessibleSiteResourceIds = accessibleSiteResources.map(
(row) => row.siteResourceId
);
} else {
const allOrgSiteResources = await db
.select({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.where(eq(siteResources.orgId, orgId));
accessibleSiteResourceIds = allOrgSiteResources.map(
(row) => row.siteResourceId
);
}
// Get site resources by joining networks to siteResources via siteNetworks // Get site resources by joining networks to siteResources via siteNetworks
const conditions = [ const conditions = [
eq(siteNetworks.siteId, siteId), eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId),
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
]; ];
if (typeof status !== "undefined") { if (typeof status !== "undefined") {
+3 -1
View File
@@ -28,6 +28,7 @@ import m19 from "./scriptsPg/1.18.4";
import m20 from "./scriptsPg/1.19.0"; import m20 from "./scriptsPg/1.19.0";
import m21 from "./scriptsPg/1.20.0"; import m21 from "./scriptsPg/1.20.0";
import m22 from "./scriptsPg/1.21.0"; import m22 from "./scriptsPg/1.21.0";
import m23 from "./scriptsPg/1.21.1";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -55,7 +56,8 @@ const migrations = [
{ version: "1.18.4", run: m19 }, { version: "1.18.4", run: m19 },
{ version: "1.19.0", run: m20 }, { version: "1.19.0", run: m20 },
{ version: "1.20.0", run: m21 }, { version: "1.20.0", run: m21 },
{ version: "1.21.0", run: m22 } { version: "1.21.0", run: m22 },
{ version: "1.21.1", run: m23 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as { ] as {
version: string; version: string;
+3 -1
View File
@@ -47,6 +47,7 @@ import m41 from "./scriptsSqlite/1.19.0";
import m42 from "./scriptsSqlite/1.19.1"; import m42 from "./scriptsSqlite/1.19.1";
import m43 from "./scriptsSqlite/1.20.0"; import m43 from "./scriptsSqlite/1.20.0";
import m44 from "./scriptsSqlite/1.21.0"; import m44 from "./scriptsSqlite/1.21.0";
import m45 from "./scriptsSqlite/1.21.1";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -91,7 +92,8 @@ const migrations = [
{ version: "1.19.0", run: m41 }, { version: "1.19.0", run: m41 },
{ version: "1.19.1", run: m42 }, { version: "1.19.1", run: m42 },
{ version: "1.20.0", run: m43 }, { version: "1.20.0", run: m43 },
{ version: "1.21.0", run: m44 } { version: "1.21.0", run: m44 },
{ version: "1.21.1", run: m45 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;
+37
View File
@@ -0,0 +1,37 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
const version = "1.21.1";
const actionsToGrant = ["getSiteResource", "listSiteResources"] as const;
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`BEGIN`);
for (const actionId of actionsToGrant) {
await db.execute(sql`
INSERT INTO "roleActions" ("roleId", "actionId", "orgId")
SELECT r."roleId", ${actionId}, r."orgId"
FROM "roles" r
WHERE COALESCE(r."isAdmin", false) = false
AND NOT EXISTS (
SELECT 1 FROM "roleActions" ra
WHERE ra."roleId" = r."roleId"
AND ra."actionId" = ${actionId}
AND ra."orgId" = r."orgId"
);
`);
}
await db.execute(sql`COMMIT`);
console.log(`Finished setup script ${version}`);
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.21.1";
const actionsToGrant = ["getSiteResource", "listSiteResources"] as const;
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.transaction(() => {
const insertRoleAction = db.prepare(`
INSERT INTO 'roleActions' ("roleId", "actionId", "orgId")
SELECT r."roleId", ?, r."orgId"
FROM 'roles' r
WHERE COALESCE(r."isAdmin", 0) = 0
AND NOT EXISTS (
SELECT 1 FROM 'roleActions' ra
WHERE ra."roleId" = r."roleId"
AND ra."actionId" = ?
AND ra."orgId" = r."orgId"
);
`);
for (const actionId of actionsToGrant) {
insertRoleAction.run(actionId, actionId);
}
})();
console.log(`Finished setup script ${version}`);
} catch (e) {
console.log("Unable to migrate database");
console.log(e);
throw e;
} finally {
db.close();
}
}
+2
View File
@@ -24,6 +24,7 @@ export function ContactSalesBanner() {
<ExternalLink className="size-3.5 shrink-0" /> <ExternalLink className="size-3.5 shrink-0" />
</Link> </Link>
{" " + t("contactSalesOr") + " "} {" " + t("contactSalesOr") + " "}
<span className="whitespace-nowrap">
<Link <Link
href="https://pangolin.net/contact" href="https://pangolin.net/contact"
target="_blank" target="_blank"
@@ -35,6 +36,7 @@ export function ContactSalesBanner() {
</Link> </Link>
. .
</span> </span>
</span>
</div> </div>
</div> </div>
</div> </div>
+5 -3
View File
@@ -13,7 +13,7 @@ import {
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { REGEXP_ONLY_DIGITS } from "input-otp"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
const MFA_OTP_INPUT_ID = "mfa-otp-code"; const MFA_OTP_INPUT_ID = "mfa-otp-code";
@@ -82,9 +82,11 @@ export default function MfaInputForm({
maxLength={6} maxLength={6}
{...field} {...field}
autoComplete="one-time-code" autoComplete="one-time-code"
inputMode="numeric" inputMode="text"
autoFocus autoFocus
pattern={REGEXP_ONLY_DIGITS} pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(value: string) => { onChange={(value: string) => {
field.onChange(value); field.onChange(value);
if (value.length === 6) { if (value.length === 6) {
+1 -1
View File
@@ -111,7 +111,7 @@ export function useCertificate({
let certError: string | null = null; let certError: string | null = null;
if (restartCert.isError) { if (restartCert.isError) {
certError = "Failed to restart"; certError = "Failed to restart";
} else if (isError || initialCertValue === null) { } else if (isError || (!isLoading && data === null)) {
// Null value means failed to get the certificate // Null value means failed to get the certificate
certError = "Failed"; certError = "Failed";
} }