diff --git a/README.md b/README.md index a1829db4..657e7994 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ Pangolin is a self-hosted tunneled reverse proxy management server with identity - [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install) - [Full Documentation](https://docs.fossorial.io) +### Authors and Maintainers + +- [Milo Schwartz](https://github.com/miloschwartz) +- [Owen Schwartz](https://github.com/oschwartz10612) + ## Preview Preview diff --git a/config/config.example.yml b/config/config.example.yml index 827a2c49..69a0e06e 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -12,6 +12,7 @@ server: secure_cookies: false session_cookie_name: p_session resource_session_cookie_name: p_resource_session + resource_access_token_param: p_token traefik: cert_resolver: letsencrypt diff --git a/install/fs/config.yml b/install/fs/config.yml index 21a8c0ff..985b8b62 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -9,9 +9,10 @@ server: internal_port: 3001 next_port: 3002 internal_hostname: pangolin - secure_cookies: false + secure_cookies: true session_cookie_name: p_session resource_session_cookie_name: p_resource_session + resource_access_token_param: p_token traefik: cert_resolver: letsencrypt diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml index 47fd82f8..ab6528d0 100644 --- a/install/fs/docker-compose.yml +++ b/install/fs/docker-compose.yml @@ -1,6 +1,6 @@ services: pangolin: - image: fosrl/pangolin:latest + image: fosrl/pangolin:{{.PangolinVersion}} container_name: pangolin restart: unless-stopped volumes: @@ -11,8 +11,9 @@ services: timeout: "3s" retries: 5 +{{if .InstallGerbil}} gerbil: - image: fosrl/gerbil:latest + image: fosrl/gerbil:{{.GerbilVersion}} container_name: gerbil restart: unless-stopped depends_on: @@ -32,12 +33,20 @@ services: - 51820:51820/udp - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode +{{end}} traefik: image: traefik:v3.1 container_name: traefik restart: unless-stopped +{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service +{{end}} +{{if not .InstallGerbil}} + ports: + - 443:443 + - 80:80 +{{end}} depends_on: pangolin: condition: service_healthy diff --git a/install/fs/traefik/traefik_config.yml b/install/fs/traefik/traefik_config.yml index c83cc8c4..de104a2f 100644 --- a/install/fs/traefik/traefik_config.yml +++ b/install/fs/traefik/traefik_config.yml @@ -13,7 +13,7 @@ experimental: plugins: badger: moduleName: "github.com/fosrl/badger" - version: "v1.0.0-beta.1" + version: "v1.0.0-beta.2" log: level: "INFO" diff --git a/install/go.mod b/install/go.mod index 3de61fa9..85cf49e4 100644 --- a/install/go.mod +++ b/install/go.mod @@ -1,3 +1,8 @@ module installer -go 1.23.0 \ No newline at end of file +go 1.23.0 + +require ( + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect +) diff --git a/install/go.sum b/install/go.sum index e69de29b..f05f63b4 100644 --- a/install/go.sum +++ b/install/go.sum @@ -0,0 +1,4 @@ +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= diff --git a/install/main.go b/install/main.go index 480ff934..ae598033 100644 --- a/install/main.go +++ b/install/main.go @@ -10,27 +10,38 @@ import ( "path/filepath" "runtime" "strings" + "syscall" "text/template" "unicode" + + "golang.org/x/term" ) +func loadVersions(config *Config) { + config.PangolinVersion = "1.0.0-beta.5" + config.GerbilVersion = "1.0.0-beta.1" +} + //go:embed fs/* var configFiles embed.FS type Config struct { - BaseDomain string `yaml:"baseDomain"` - DashboardDomain string `yaml:"dashboardUrl"` - LetsEncryptEmail string `yaml:"letsEncryptEmail"` - AdminUserEmail string `yaml:"adminUserEmail"` - AdminUserPassword string `yaml:"adminUserPassword"` - DisableSignupWithoutInvite bool `yaml:"disableSignupWithoutInvite"` - DisableUserCreateOrg bool `yaml:"disableUserCreateOrg"` - EnableEmail bool `yaml:"enableEmail"` - EmailSMTPHost string `yaml:"emailSMTPHost"` - EmailSMTPPort int `yaml:"emailSMTPPort"` - EmailSMTPUser string `yaml:"emailSMTPUser"` - EmailSMTPPass string `yaml:"emailSMTPPass"` - EmailNoReply string `yaml:"emailNoReply"` + PangolinVersion string + GerbilVersion string + BaseDomain string + DashboardDomain string + LetsEncryptEmail string + AdminUserEmail string + AdminUserPassword string + DisableSignupWithoutInvite bool + DisableUserCreateOrg bool + EnableEmail bool + EmailSMTPHost string + EmailSMTPPort int + EmailSMTPUser string + EmailSMTPPass string + EmailNoReply string + InstallGerbil bool } func main() { @@ -45,13 +56,16 @@ func main() { // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { config := collectUserInput(reader) + + loadVersions(&config) + if err := createConfigFiles(config); err != nil { fmt.Printf("Error creating config files: %v\n", err) os.Exit(1) } if !isDockerInstalled() && runtime.GOOS == "linux" { - if shouldInstallDocker() { + if readBool(reader, "Docker is not installed. Would you like to install it?", true) { installDocker() } } @@ -82,6 +96,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string return input } +func readPassword(prompt string) string { + fmt.Print(prompt + ": ") + + // Read password without echo + password, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() // Add a newline since ReadPassword doesn't add one + + if err != nil { + return "" + } + + input := strings.TrimSpace(string(password)) + if input == "" { + return readPassword(prompt) + } + return input +} + func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool { defaultStr := "no" if defaultValue { @@ -109,21 +141,29 @@ func collectUserInput(reader *bufio.Reader) Config { config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain) config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") + config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunned connections", true) // Admin user configuration fmt.Println("\n=== Admin User Configuration ===") config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain) for { - config.AdminUserPassword = readString(reader, "Enter admin user password", "") - if valid, message := validatePassword(config.AdminUserPassword); valid { - break + pass1 := readPassword("Create admin user password") + pass2 := readPassword("Confirm admin user password") + + if pass1 != pass2 { + fmt.Println("Passwords do not match") } else { - fmt.Println("Invalid password:", message) - fmt.Println("Password requirements:") - fmt.Println("- At least one uppercase English letter") - fmt.Println("- At least one lowercase English letter") - fmt.Println("- At least one digit") - fmt.Println("- At least one special character") + config.AdminUserPassword = pass1 + if valid, message := validatePassword(config.AdminUserPassword); valid { + break + } else { + fmt.Println("Invalid password:", message) + fmt.Println("Password requirements:") + fmt.Println("- At least one uppercase English letter") + fmt.Println("- At least one lowercase English letter") + fmt.Println("- At least one digit") + fmt.Println("- At least one special character") + } } } @@ -302,13 +342,6 @@ func createConfigFiles(config Config) error { return nil } -func shouldInstallDocker() bool { - reader := bufio.NewReader(os.Stdin) - fmt.Print("Would you like to install Docker? (yes/no): ") - response, _ := reader.ReadString('\n') - return strings.ToLower(strings.TrimSpace(response)) == "yes" -} - func installDocker() error { // Detect Linux distribution cmd := exec.Command("cat", "/etc/os-release") diff --git a/package.json b/package.json index 14e87d68..5b1b25b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", @@ -26,6 +26,7 @@ "@oslojs/encoding": "1.1.0", "@radix-ui/react-avatar": "1.1.2", "@radix-ui/react-checkbox": "1.1.3", + "@radix-ui/react-collapsible": "1.1.2", "@radix-ui/react-dialog": "1.1.4", "@radix-ui/react-dropdown-menu": "2.1.4", "@radix-ui/react-icons": "1.3.2", diff --git a/server/auth/canUserAccessResource.ts b/server/auth/canUserAccessResource.ts new file mode 100644 index 00000000..bdafaa0d --- /dev/null +++ b/server/auth/canUserAccessResource.ts @@ -0,0 +1,45 @@ +import db from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { roleResources, userResources } from "@server/db/schema"; + +export async function canUserAccessResource({ + userId, + resourceId, + roleId +}: { + userId: string; + resourceId: number; + roleId: number; +}): Promise { + const roleResourceAccess = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ) + .limit(1); + + if (roleResourceAccess.length > 0) { + return true; + } + + const userResourceAccess = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.userId, userId), + eq(userResources.resourceId, resourceId) + ) + ) + .limit(1); + + if (userResourceAccess.length > 0) { + return true; + } + + return false; +} diff --git a/server/auth/verifyResourceAccessToken.ts b/server/auth/verifyResourceAccessToken.ts new file mode 100644 index 00000000..ce74952b --- /dev/null +++ b/server/auth/verifyResourceAccessToken.ts @@ -0,0 +1,67 @@ +import db from "@server/db"; +import { + Resource, + ResourceAccessToken, + resourceAccessToken, +} from "@server/db/schema"; +import { and, eq } from "drizzle-orm"; +import { isWithinExpirationDate } from "oslo"; +import { verifyPassword } from "./password"; + +export async function verifyResourceAccessToken({ + resource, + accessTokenId, + accessToken +}: { + resource: Resource; + accessTokenId: string; + accessToken: string; +}): Promise<{ + valid: boolean; + error?: string; + tokenItem?: ResourceAccessToken; +}> { + const [result] = await db + .select() + .from(resourceAccessToken) + .where( + and( + eq(resourceAccessToken.resourceId, resource.resourceId), + eq(resourceAccessToken.accessTokenId, accessTokenId) + ) + ) + .limit(1); + + const tokenItem = result; + + if (!tokenItem) { + return { + valid: false, + error: "Access token does not exist for resource" + }; + } + + const validCode = await verifyPassword(accessToken, tokenItem.tokenHash); + + if (!validCode) { + return { + valid: false, + error: "Invalid access token" + }; + } + + if ( + tokenItem.expiresAt && + !isWithinExpirationDate(new Date(tokenItem.expiresAt)) + ) { + return { + valid: false, + error: "Access token has expired" + }; + } + + return { + valid: true, + tokenItem + }; +} diff --git a/server/db/index.ts b/server/db/index.ts index 12b6ed10..5dc1360a 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -4,10 +4,13 @@ import * as schema from "@server/db/schema"; import path from "path"; import fs from "fs/promises"; import { APP_PATH } from "@server/lib/consts"; +import { existsSync, mkdirSync } from "fs"; export const location = path.join(APP_PATH, "db", "db.sqlite"); export const exists = await checkFileExists(location); +bootstrapVolume(); + const sqlite = new Database(location); export const db = drizzle(sqlite, { schema }); @@ -21,3 +24,29 @@ async function checkFileExists(filePath: string): Promise { return false; } } + +function bootstrapVolume() { + const appPath = APP_PATH; + + const dbDir = path.join(appPath, "db"); + const logsDir = path.join(appPath, "logs"); + + // check if the db directory exists and create it if it doesn't + if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); + } + + // check if the logs directory exists and create it if it doesn't + if (!existsSync(logsDir)) { + mkdirSync(logsDir, { recursive: true }); + } + + // THIS IS FOR TRAEFIK; NOT REALLY NEEDED, BUT JUST IN CASE + + const traefikDir = path.join(appPath, "traefik"); + + // check if the traefik directory exists and create it if it doesn't + if (!existsSync(traefikDir)) { + mkdirSync(traefikDir, { recursive: true }); + } +} diff --git a/server/lib/config.ts b/server/lib/config.ts index 203a6441..d480892b 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -32,7 +32,8 @@ const environmentSchema = z.object({ internal_hostname: z.string().transform((url) => url.toLowerCase()), secure_cookies: z.boolean(), session_cookie_name: z.string(), - resource_session_cookie_name: z.string() + resource_session_cookie_name: z.string(), + resource_access_token_param: z.string() }), traefik: z.object({ http_entrypoint: z.string(), @@ -186,6 +187,7 @@ export class Config { ?.disable_user_create_org ? "true" : "false"; + process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.data.server.resource_access_token_param; this.rawConfig = parsedConfig.data; } diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 2854c46c..3b7e3121 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -4,7 +4,7 @@ import { resourceAccessToken, resources, userOrgs } from "@server/db/schema"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; -import { canUserAccessResource } from "@server/lib/canUserAccessResource"; +import { canUserAccessResource } from "@server/auth/canUserAccessResource"; export async function verifyAccessTokenAccess( req: Request, diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 003e5e8d..865e7e5e 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -4,7 +4,7 @@ import { resources, targets, userOrgs } from "@server/db/schema"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; -import { canUserAccessResource } from "../lib/canUserAccessResource"; +import { canUserAccessResource } from "../auth/canUserAccessResource"; export async function verifyTargetAccess( req: Request, diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 459219c5..0593e3b4 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -7,6 +7,7 @@ import { response } from "@server/lib/response"; import { validateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; import { + ResourceAccessToken, resourceAccessToken, resourcePassword, resourcePincode, @@ -17,9 +18,15 @@ import { } from "@server/db/schema"; import { and, eq } from "drizzle-orm"; import config from "@server/lib/config"; -import { validateResourceSessionToken } from "@server/auth/sessions/resource"; +import { + createResourceSession, + serializeResourceSessionCookie, + validateResourceSessionToken +} from "@server/auth/sessions/resource"; import { Resource, roleResources, userResources } from "@server/db/schema"; import logger from "@server/logger"; +import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { generateSessionToken } from "@server/auth"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string()).optional(), @@ -28,6 +35,7 @@ const verifyResourceSessionSchema = z.object({ host: z.string(), path: z.string(), method: z.string(), + accessToken: z.string().optional(), tls: z.boolean() }); @@ -59,7 +67,8 @@ export async function verifyResourceSession( } try { - const { sessions, host, originalRequestURL } = parsedBody.data; + const { sessions, host, originalRequestURL, accessToken: token } = + parsedBody.data; const [result] = await db .select() @@ -103,11 +112,41 @@ export async function verifyResourceSession( const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; + // check for access token + let validAccessToken: ResourceAccessToken | undefined; + if (token) { + const [accessTokenId, accessToken] = token.split("."); + const { valid, error, tokenItem } = await verifyResourceAccessToken( + { + resource, + accessTokenId, + accessToken + } + ); + + if (error) { + logger.debug("Access token invalid: " + error); + } + + if (valid && tokenItem) { + validAccessToken = tokenItem; + + if (!sessions) { + return await createAccessTokenSession( + res, + resource, + tokenItem + ); + } + } + } + if (!sessions) { return notAllowed(res); } - const sessionToken = sessions[config.getRawConfig().server.session_cookie_name]; + const sessionToken = + sessions[config.getRawConfig().server.session_cookie_name]; // check for unified login if (sso && sessionToken) { @@ -172,6 +211,16 @@ export async function verifyResourceSession( } } + // At this point we have checked all sessions, but since the access token is valid, we should allow access + // and create a new session. + if (validAccessToken) { + return await createAccessTokenSession( + res, + resource, + validAccessToken + ); + } + logger.debug("No more auth to check, resource not allowed"); return notAllowed(res, redirectUrl); } catch (e) { @@ -209,11 +258,41 @@ function allowed(res: Response) { return response(res, data); } +async function createAccessTokenSession( + res: Response, + resource: Resource, + tokenItem: ResourceAccessToken +) { + const token = generateSessionToken(); + await createResourceSession({ + resourceId: resource.resourceId, + token, + accessTokenId: tokenItem.accessTokenId, + sessionLength: tokenItem.sessionLength, + expiresAt: tokenItem.expiresAt, + doNotExtend: tokenItem.expiresAt ? true : false + }); + const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; + const cookie = serializeResourceSessionCookie(cookieName, token); + res.appendHeader("Set-Cookie", cookie); + logger.debug("Access token is valid, creating new session") + return response(res, { + data: { valid: true }, + success: true, + error: false, + message: "Access allowed", + status: HttpCode.OK + }); +} + async function isUserAllowedToAccessResource( user: User, resource: Resource ): Promise { - if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) { + if ( + config.getRawConfig().flags?.require_email_verification && + !user.emailVerified + ) { return false; } diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index fdc4b254..a4340f77 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -14,9 +14,7 @@ import { } from "@server/auth/sessions/resource"; import config from "@server/lib/config"; import logger from "@server/logger"; -import { verify } from "@node-rs/argon2"; -import { isWithinExpirationDate } from "oslo"; -import { verifyPassword } from "@server/auth/password"; +import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; const authWithAccessTokenBodySchema = z .object({ @@ -69,58 +67,38 @@ export async function authWithAccessToken( const { accessToken, accessTokenId } = parsedBody.data; try { - const [result] = await db + const [resource] = await db .select() - .from(resourceAccessToken) - .where( - and( - eq(resourceAccessToken.resourceId, resourceId), - eq(resourceAccessToken.accessTokenId, accessTokenId) - ) - ) - .leftJoin( - resources, - eq(resources.resourceId, resourceAccessToken.resourceId) - ) + .from(resources) + .where(eq(resources.resourceId, resourceId)) .limit(1); - const resource = result?.resources; - const tokenItem = result?.resourceAccessToken; - - if (!tokenItem) { - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - createHttpError( - HttpCode.BAD_REQUEST, - "Access token does not exist for resource" - ) - ) - ); - } - if (!resource) { return next( - createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") + createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } - const validCode = await verifyPassword(accessToken, tokenItem.tokenHash); + const { valid, error, tokenItem } = await verifyResourceAccessToken({ + resource, + accessTokenId, + accessToken + }); - if (!validCode) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token") - ); - } - - if ( - tokenItem.expiresAt && - !isWithinExpirationDate(new Date(tokenItem.expiresAt)) - ) { + if (!valid) { return next( createHttpError( HttpCode.UNAUTHORIZED, - "Access token has expired" + error || "Invalid access token" + ) + ); + } + + if (!tokenItem || !resource) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Access token does not exist for resource" ) ); } diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 1151feac..9a5e6f46 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -24,7 +24,7 @@ const createSiteParamsSchema = z const createSiteSchema = z .object({ name: z.string().min(1).max(255), - exitNodeId: z.number().int().positive(), + exitNodeId: z.number().int().positive().optional(), // subdomain: z // .string() // .min(1) @@ -32,7 +32,7 @@ const createSiteSchema = z // .transform((val) => val.toLowerCase()) // .optional(), pubKey: z.string().optional(), - subnet: z.string(), + subnet: z.string().optional(), newtId: z.string().optional(), secret: z.string().optional(), type: z.string() @@ -82,28 +82,46 @@ export async function createSite( const niceId = await getUniqueSiteName(orgId); - let payload: any = { - orgId, - exitNodeId, - name, - niceId, - subnet, - type - }; - - if (pubKey && type == "wireguard") { - // we dont add the pubKey for newts because the newt will generate it - payload = { - ...payload, - pubKey - }; - } - await db.transaction(async (trx) => { - const [newSite] = await trx - .insert(sites) - .values(payload) - .returning(); + let newSite: Site; + + if (exitNodeId) { + // we are creating a site with an exit node (tunneled) + if (!subnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Subnet is required for tunneled sites" + ) + ); + } + + [newSite] = await trx + .insert(sites) + .values({ + orgId, + exitNodeId, + name, + niceId, + subnet, + type, + ...(pubKey && type == "wireguard" && { pubKey }) + }) + .returning(); + } else { + // we are creating a site with no tunneling + + [newSite] = await trx + .insert(sites) + .values({ + orgId, + name, + niceId, + type, + subnet: "0.0.0.0/0" + }) + .returning(); + } const adminRole = await trx .select() @@ -149,6 +167,16 @@ export async function createSite( ) ); } + + if (!exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Exit node ID is required for wireguard sites" + ) + ); + } + await addPeer(exitNodeId, { publicKey: pubKey, allowedIps: [] diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index e7ae3aca..1376ab0a 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -123,88 +123,100 @@ export async function createTarget( ); } - // make sure the target is within the site subnet - if ( - site.type == "wireguard" && - !isIpInCidr(targetData.ip, site.subnet!) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Target IP is not within the site subnet` - ) - ); - } - - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId) - }); - - // TODO: is this all inefficient? - // Fetch targets for all resources of this site - let targetIps: string[] = []; - let targetInternalPorts: number[] = []; - await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId) - }); - targetsRes.forEach((target) => { - targetIps.push(`${target.ip}/32`); - if (target.internalPort) { - targetInternalPorts.push(target.internalPort); - } - }); - }) - ); - - let internalPort!: number; - // pick a port - for (let i = 40000; i < 65535; i++) { - if (!targetInternalPorts.includes(i)) { - internalPort = i; - break; + let newTarget: Target[] = []; + if (site.type == "local") { + newTarget = await db + .insert(targets) + .values({ + resourceId, + protocol: "tcp", // hard code for now + ...targetData + }) + .returning(); + } else { + // make sure the target is within the site subnet + if ( + site.type == "wireguard" && + !isIpInCidr(targetData.ip, site.subnet!) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Target IP is not within the site subnet` + ) + ); } - } - if (!internalPort) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `No available internal port` - ) + // Fetch resources for this site + const resourcesRes = await db.query.resources.findMany({ + where: eq(resources.siteId, site.siteId) + }); + + // TODO: is this all inefficient? + // Fetch targets for all resources of this site + let targetIps: string[] = []; + let targetInternalPorts: number[] = []; + await Promise.all( + resourcesRes.map(async (resource) => { + const targetsRes = await db.query.targets.findMany({ + where: eq(targets.resourceId, resource.resourceId) + }); + targetsRes.forEach((target) => { + targetIps.push(`${target.ip}/32`); + if (target.internalPort) { + targetInternalPorts.push(target.internalPort); + } + }); + }) ); - } - const newTarget = await db - .insert(targets) - .values({ - resourceId, - protocol: "tcp", // hard code for now - internalPort, - ...targetData - }) - .returning(); + let internalPort!: number; + // pick a port + for (let i = 40000; i < 65535; i++) { + if (!targetInternalPorts.includes(i)) { + internalPort = i; + break; + } + } - // add the new target to the targetIps array - targetIps.push(`${targetData.ip}/32`); + if (!internalPort) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `No available internal port` + ) + ); + } - if (site.pubKey) { - if (site.type == "wireguard") { - await addPeer(site.exitNodeId!, { - publicKey: site.pubKey, - allowedIps: targetIps.flat() - }); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); + newTarget = await db + .insert(targets) + .values({ + resourceId, + protocol: "tcp", // hard code for now + internalPort, + ...targetData + }) + .returning(); - addTargets(newt.newtId, newTarget); + // add the new target to the targetIps array + targetIps.push(`${targetData.ip}/32`); + + if (site.pubKey) { + if (site.type == "wireguard") { + await addPeer(site.exitNodeId!, { + publicKey: site.pubKey, + allowedIps: targetIps.flat() + }); + } else if (site.type == "newt") { + // get the newt on the site by querying the newt table for siteId + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + addTargets(newt.newtId, newTarget); + } } } diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 708fa413..fb775ac4 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -56,6 +56,7 @@ export async function traefikConfigProvider( config.getRawConfig().server.resource_session_cookie_name, userSessionCookieName: config.getRawConfig().server.session_cookie_name, + accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param, }, }, }, @@ -150,6 +151,16 @@ export async function traefikConfigProvider( ], }, }; + } else if (site.type === "local") { + http.services![serviceName] = { + loadBalancer: { + servers: [ + { + url: `${target.method}://${target.ip}:${target.port}`, + }, + ], + }, + }; } } diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index c0fe6216..4e0d77ca 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -4,11 +4,13 @@ import path from "path"; import semver from "semver"; import { versionMigrations } from "@server/db/schema"; import { desc } from "drizzle-orm"; -import { __DIRNAME } from "@server/lib/consts"; +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import { loadAppVersion } from "@server/lib/loadAppVersion"; import m1 from "./scripts/1.0.0-beta1"; import m2 from "./scripts/1.0.0-beta2"; import m3 from "./scripts/1.0.0-beta3"; +import m4 from "./scripts/1.0.0-beta5"; +import { existsSync, mkdirSync } from "fs"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -17,7 +19,8 @@ import m3 from "./scripts/1.0.0-beta3"; const migrations = [ { version: "1.0.0-beta.1", run: m1 }, { version: "1.0.0-beta.2", run: m2 }, - { version: "1.0.0-beta.3", run: m3 } + { version: "1.0.0-beta.3", run: m3 }, + { version: "1.0.0-beta.5", run: m4 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.0.0-beta5.ts b/server/setup/scripts/1.0.0-beta5.ts new file mode 100644 index 00000000..1fe6db49 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta5.ts @@ -0,0 +1,42 @@ +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import fs from "fs"; +import yaml from "js-yaml"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.5..."); + + // Determine which config file exists + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + // Read and parse the YAML file + let rawConfig: any; + const fileContents = fs.readFileSync(filePath, "utf8"); + rawConfig = yaml.load(fileContents); + + // Validate the structure + if (!rawConfig.server) { + throw new Error(`Invalid config file: server is missing.`); + } + + // Update the config + rawConfig.server.resource_access_token_param = "p_token"; + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + + console.log("Done."); +} diff --git a/server/setup/setupServerAdmin.ts b/server/setup/setupServerAdmin.ts index 54d38338..70faff86 100644 --- a/server/setup/setupServerAdmin.ts +++ b/server/setup/setupServerAdmin.ts @@ -69,6 +69,8 @@ export async function setupServerAdmin() { const userId = generateId(15); + await trx.update(users).set({ serverAdmin: false }); + await db.insert(users).values({ userId: userId, email: email, diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index 64c4d319..1bbf6e78 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -57,14 +57,22 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { register } from "module"; import { Label } from "@app/components/ui/label"; import { Checkbox } from "@app/components/ui/checkbox"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; -import { constructShareLink } from "@app/lib/shareLinks"; +import { + constructDirectShareLink, + constructShareLink +} from "@app/lib/shareLinks"; import { ShareLinkRow } from "./ShareLinksTable"; import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@app/components/ui/collapsible"; type FormProps = { open: boolean; @@ -75,6 +83,7 @@ type FormProps = { const formSchema = z.object({ resourceId: z.number({ message: "Please select a resource" }), resourceName: z.string(), + resourceUrl: z.string(), timeUnit: z.string(), timeValue: z.coerce.number().int().positive().min(1), title: z.string().optional() @@ -88,14 +97,18 @@ export default function CreateShareLinkForm({ const { toast } = useToast(); const { org } = useOrgContext(); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const api = createApiClient({ env }); const [link, setLink] = useState(null); + const [directLink, setDirectLink] = useState(null); const [loading, setLoading] = useState(false); const [neverExpire, setNeverExpire] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [resources, setResources] = useState< - { resourceId: number; name: string }[] + { resourceId: number; name: string; resourceUrl: string }[] >([]); const timeUnits = [ @@ -139,7 +152,13 @@ export default function CreateShareLinkForm({ }); if (res?.status === 200) { - setResources(res.data.data.resources); + setResources( + res.data.data.resources.map((r) => ({ + resourceId: r.resourceId, + name: r.name, + resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/` + })) + ); } } @@ -202,6 +221,13 @@ export default function CreateShareLinkForm({ token.accessToken ); setLink(link); + const directLink = constructDirectShareLink( + env.server.resourceAccessTokenParam, + values.resourceUrl, + token.accessTokenId, + token.accessToken + ); + setDirectLink(directLink); onCreated?.({ accessTokenId: token.accessTokenId, resourceId: token.resourceId, @@ -306,6 +332,10 @@ export default function CreateShareLinkForm({ "resourceName", r.name ); + form.setValue( + "resourceUrl", + r.resourceUrl + ); }} > -
- -
+ +
+ +
+
+ + + +
+ + {directLink && ( +
+
+ +
+

+ This link does not + require visiting in a + browser to complete the + redirect. It contains + the access token + directly in the URL, + which can be useful for + sharing with clients + that do not support + redirects. +

+
+ )} +
+
)} diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx index 505a85c5..451bec9f 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx @@ -24,7 +24,7 @@ import { useRouter } from "next/navigation"; // import CreateResourceForm from "./CreateResourceForm"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { useToast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -109,15 +109,14 @@ export default function ShareLinksTable({ - - diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx index 2515284d..3064cfa5 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx @@ -49,7 +49,7 @@ const createSiteFormSchema = z.object({ .max(30, { message: "Name must not be longer than 30 characters." }), - method: z.enum(["wireguard", "newt"]) + method: z.enum(["wireguard", "newt", "local"]) }); type CreateSiteFormValues = z.infer; @@ -79,17 +79,16 @@ export default function CreateSiteForm({ const [isLoading, setIsLoading] = useState(false); const [isChecked, setIsChecked] = useState(false); - const router = useRouter(); - const [keypair, setKeypair] = useState<{ publicKey: string; privateKey: string; } | null>(null); + const [siteDefaults, setSiteDefaults] = useState(null); const handleCheckboxChange = (checked: boolean) => { - setChecked?.(checked); + // setChecked?.(checked); setIsChecked(checked); }; @@ -98,6 +97,17 @@ export default function CreateSiteForm({ defaultValues }); + const nameField = form.watch("name"); + const methodField = form.watch("method"); + + useEffect(() => { + const nameIsValid = nameField?.length >= 2 && nameField?.length <= 30; + const isFormValid = methodField === "local" || isChecked; + + // Only set checked to true if name is valid AND (method is local OR checkbox is checked) + setChecked?.(nameIsValid && isFormValid); + }, [nameField, methodField, isChecked, setChecked]); + useEffect(() => { if (!open) return; @@ -114,11 +124,8 @@ export default function CreateSiteForm({ api.get(`/org/${orgId}/pick-site-defaults`) .catch((e) => { - toast({ - variant: "destructive", - title: "Error picking site defaults", - description: formatAxiosError(e) - }); + // update the default value of the form to be local method + form.setValue("method", "local"); }) .then((res) => { if (res && res.status === 200) { @@ -130,24 +137,54 @@ export default function CreateSiteForm({ async function onSubmit(data: CreateSiteFormValues) { setLoading?.(true); setIsLoading(true); - if (!siteDefaults || !keypair) { - return; - } let payload: CreateSiteBody = { name: data.name, - subnet: siteDefaults.subnet, - exitNodeId: siteDefaults.exitNodeId, - pubKey: keypair.publicKey, type: data.method }; - if (data.method === "newt") { - payload.secret = siteDefaults.newtSecret; - payload.newtId = siteDefaults.newtId; + + if (data.method == "wireguard") { + if (!keypair || !siteDefaults) { + toast({ + variant: "destructive", + title: "Error creating site", + description: "Key pair or site defaults not found" + }); + setLoading?.(false); + setIsLoading(false); + return; + } + + payload = { + ...payload, + subnet: siteDefaults.subnet, + exitNodeId: siteDefaults.exitNodeId, + pubKey: keypair.publicKey + }; } + if (data.method === "newt") { + if (!siteDefaults) { + toast({ + variant: "destructive", + title: "Error creating site", + description: "Site defaults not found" + }); + setLoading?.(false); + setIsLoading(false); + return; + } + + payload = { + ...payload, + secret: siteDefaults.newtSecret, + newtId: siteDefaults.newtId + }; + } + const res = await api - .put< - AxiosResponse - >(`/org/${orgId}/site/`, payload) + .put>( + `/org/${orgId}/site/`, + payload + ) .catch((e) => { toast({ variant: "destructive", @@ -157,18 +194,20 @@ export default function CreateSiteForm({ }); if (res && res.status === 201) { - const niceId = res.data.data.niceId; - // navigate to the site page - // router.push(`/${orgId}/settings/sites/${niceId}`); - const data = res.data.data; onCreate?.({ name: data.name, id: data.siteId, nice: data.niceId.toString(), - mbIn: "0 MB", - mbOut: "0 MB", + mbIn: + data.type == "wireguard" || data.type == "newt" + ? "0 MB" + : "--", + mbOut: + data.type == "wireguard" || data.type == "newt" + ? "0 MB" + : "--", orgId: orgId as string, type: data.type as any, online: false @@ -245,12 +284,21 @@ PersistentKeepalive = 5` - - WireGuard + + Local - + Newt + + WireGuard + @@ -264,50 +312,76 @@ PersistentKeepalive = 5`
{form.watch("method") === "wireguard" && !isLoading ? ( - + <> + + + You will only be able to see the + configuration once. + + ) : form.watch("method") === "wireguard" && isLoading ? (

Loading WireGuard configuration...

- ) : ( - - )} + ) : form.watch("method") === "newt" ? ( + <> + + + You will only be able to see the + configuration once. + + + ) : null}
- - You will only be able to see the configuration once. - - {form.watch("method") === "newt" && ( - <> -
- - - {" "} - Learn how to install Newt on your system - - - - + + + {" "} + Learn how to install Newt on your system + + + )} -
- - -
+ + {" "} + Local sites do not tunnel, learn more + + + + )} + + {(form.watch("method") === "newt" || + form.watch("method") === "wireguard") && ( +
+ + +
+ )} diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index f4361177..e76203a3 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -23,7 +23,7 @@ import { useState } from "react"; import CreateSiteForm from "./CreateSiteForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useToast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import CreateSiteFormModal from "./CreateSiteModal"; @@ -146,21 +146,27 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { }, cell: ({ row }) => { const originalRow = row.original; - - if (originalRow.online) { - return ( - -
- Online -
- ); + if ( + originalRow.type == "newt" || + originalRow.type == "wireguard" + ) { + if (originalRow.online) { + return ( + +
+ Online +
+ ); + } else { + return ( + +
+ Offline +
+ ); + } } else { - return ( - -
- Offline -
- ); + return --; } } }, @@ -245,6 +251,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { ); } + + if (originalRow.type === "local") { + return ( +
+ Local +
+ ); + } } }, { diff --git a/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx b/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx index d93b815b..2a05606c 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx @@ -16,37 +16,50 @@ type SiteInfoCardProps = {}; export default function SiteInfoCard({}: SiteInfoCardProps) { const { site, updateSite } = useSiteContext(); + const getConnectionTypeString = (type: string) => { + if (type === "newt") { + return "Newt"; + } else if (type === "wireguard") { + return "WireGuard"; + } else if (type === "local") { + return "Local"; + } else { + return "Unknown"; + } + }; + return ( Site Information - - Status - - {site.online ? ( -
-
- Online -
- ) : ( -
-
- Offline -
- )} -
-
- + {(site.type == "newt" || site.type == "wireguard") && ( + <> + + Status + + {site.online ? ( +
+
+ Online +
+ ) : ( +
+
+ Offline +
+ )} +
+
+ + + + )} Connection Type - {site.type === "newt" - ? "Newt" - : site.type === "wireguard" - ? "WireGuard" - : "Unknown"} + {getConnectionTypeString(site.type)}
diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index c5d83ce2..f3fa4957 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -23,7 +23,10 @@ export default async function SitesPage(props: SitesPageProps) { sites = res.data.data.sites; } catch (e) {} - function formatSize(mb: number): string { + function formatSize(mb: number, type: string): string { + if (type === "local") { + return "--"; // because we are not able to track the data use in a local site right now + } if (mb >= 1024 * 1024) { return `${(mb / (1024 * 1024)).toFixed(2)} TB`; } else if (mb >= 1024) { @@ -38,8 +41,8 @@ export default async function SitesPage(props: SitesPageProps) { name: site.name, id: site.siteId, nice: site.niceId.toString(), - mbIn: formatSize(site.megabytesIn || 0), - mbOut: formatSize(site.megabytesOut || 0), + mbIn: formatSize(site.megabytesIn || 0, site.type), + mbOut: formatSize(site.megabytesOut || 0, site.type), orgId: params.orgId, type: site.type as any, online: site.online diff --git a/src/app/auth/resource/[resourceId]/AccessToken.tsx b/src/app/auth/resource/[resourceId]/AccessToken.tsx index 5098cca8..408a187b 100644 --- a/src/app/auth/resource/[resourceId]/AccessToken.tsx +++ b/src/app/auth/resource/[resourceId]/AccessToken.tsx @@ -30,6 +30,7 @@ export default function AccessToken({ redirectUrl }: AccessTokenProps) { const [loading, setLoading] = useState(true); + const [isValid, setIsValid] = useState(false); const api = createApiClient(useEnvContext()); @@ -49,6 +50,7 @@ export default function AccessToken({ }); if (res.data.data.session) { + setIsValid(true); window.location.href = redirectUrl; } } catch (e) { @@ -61,24 +63,47 @@ export default function AccessToken({ check(); }, [accessTokenId, accessToken]); + function renderTitle() { + if (isValid) { + return "Access Granted"; + } else { + return "Access URL Invalid"; + } + } + + function renderContent() { + if (isValid) { + return ( +
+ You have been granted access to this resource. Redirecting + you... +
+ ); + } else { + return ( +
+ This shared access URL is invalid. Please contact the + resource owner for a new URL. +
+ +
+
+ ); + } + } + return loading ? (
) : ( - Access URL Invalid + {renderTitle()} - - This shared access URL is invalid. Please contact the resource - owner for a new URL. -
- -
-
+ {renderContent()}
); } diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 2cf37848..49041a0d 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -45,11 +45,10 @@ export default async function ResourceAuthPage(props: { const user = await getUser({ skipCheckVerifyEmail: true }); if (!authInfo) { - { - /* @ts-ignore */ - } // TODO: fix this + // TODO: fix this return (
+ {/* @ts-ignore */}
); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 404f353e..3aa288a9 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -19,6 +19,7 @@ const buttonVariants = cva( secondary: "bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", + text: "", link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..9fa48946 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index 827744af..d335d703 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -6,7 +6,8 @@ export function pullEnv(): Env { nextPort: process.env.NEXT_PORT as string, externalPort: process.env.SERVER_EXTERNAL_PORT as string, sessionCookieName: process.env.SESSION_COOKIE_NAME as string, - resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string + resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string, + resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string }, app: { environment: process.env.ENVIRONMENT as string, diff --git a/src/lib/shareLinks.ts b/src/lib/shareLinks.ts index 0579698c..94c292ad 100644 --- a/src/lib/shareLinks.ts +++ b/src/lib/shareLinks.ts @@ -1,3 +1,5 @@ +import { pullEnv } from "./pullEnv"; + export function constructShareLink( resourceId: number, id: string, @@ -5,3 +7,12 @@ export function constructShareLink( ) { return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`; } + +export function constructDirectShareLink( + param: string, + resourceUrl: string, + id: string, + token: string +) { + return `${resourceUrl}?${param}=${id}.${token}`; +} diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 41a22363..559bb531 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -8,6 +8,7 @@ export type Env = { nextPort: string; sessionCookieName: string; resourceSessionCookieName: string; + resourceAccessTokenParam: string; }, email: { emailEnabled: boolean;