From b9a9e0169efafa27e776c18328d8512306c38b8f Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 12 Jan 2025 13:43:16 -0500 Subject: [PATCH] complete integration of direct share link as discussed in #35 --- config/config.example.yml | 1 + install/fs/config.yml | 3 +- package.json | 3 +- server/lib/config.ts | 4 +- server/routers/traefik/getTraefikConfig.ts | 2 +- server/setup/migrations.ts | 4 +- server/setup/scripts/1.0.0-beta5.ts | 42 ++++++++ .../share-links/CreateShareLinkForm.tsx | 102 ++++++++++++++++-- .../settings/share-links/ShareLinksTable.tsx | 19 ++-- .../resource/[resourceId]/AccessToken.tsx | 45 ++++++-- src/components/ui/button.tsx | 1 + src/components/ui/collapsible.tsx | 11 ++ src/lib/pullEnv.ts | 3 +- src/lib/shareLinks.ts | 11 ++ src/lib/types/env.ts | 1 + 15 files changed, 215 insertions(+), 37 deletions(-) create mode 100644 server/setup/scripts/1.0.0-beta5.ts create mode 100644 src/components/ui/collapsible.tsx 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/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/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/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 829a3a93..e6aa7c56 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -56,7 +56,7 @@ export async function traefikConfigProvider( config.getRawConfig().server.resource_session_cookie_name, userSessionCookieName: config.getRawConfig().server.session_cookie_name, - accessTokenQueryParam: "p_token" + accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param, }, }, }, diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index c0fe6216..24409ef5 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -9,6 +9,7 @@ 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"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -17,7 +18,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/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index 64c4d319..ff51449f 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/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/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;