瀏覽代碼

complete integration of direct share link as discussed in #35

Milo Schwartz 6 月之前
父節點
當前提交
a2ed7c7117

+ 1 - 0
config/config.example.yml

@@ -12,6 +12,7 @@ server:
     secure_cookies: false
     secure_cookies: false
     session_cookie_name: p_session
     session_cookie_name: p_session
     resource_session_cookie_name: p_resource_session
     resource_session_cookie_name: p_resource_session
+    resource_access_token_param: p_token
 
 
 traefik:
 traefik:
     cert_resolver: letsencrypt
     cert_resolver: letsencrypt

+ 2 - 1
install/fs/config.yml

@@ -9,9 +9,10 @@ server:
     internal_port: 3001
     internal_port: 3001
     next_port: 3002
     next_port: 3002
     internal_hostname: pangolin
     internal_hostname: pangolin
-    secure_cookies: false
+    secure_cookies: true
     session_cookie_name: p_session
     session_cookie_name: p_session
     resource_session_cookie_name: p_resource_session
     resource_session_cookie_name: p_resource_session
+    resource_access_token_param: p_token
 
 
 traefik:
 traefik:
     cert_resolver: letsencrypt
     cert_resolver: letsencrypt

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@fosrl/pangolin",
     "name": "@fosrl/pangolin",
-    "version": "1.0.0-beta.4",
+    "version": "1.0.0-beta.5",
     "private": true,
     "private": true,
     "type": "module",
     "type": "module",
     "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
     "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
@@ -26,6 +26,7 @@
         "@oslojs/encoding": "1.1.0",
         "@oslojs/encoding": "1.1.0",
         "@radix-ui/react-avatar": "1.1.2",
         "@radix-ui/react-avatar": "1.1.2",
         "@radix-ui/react-checkbox": "1.1.3",
         "@radix-ui/react-checkbox": "1.1.3",
+        "@radix-ui/react-collapsible": "1.1.2",
         "@radix-ui/react-dialog": "1.1.4",
         "@radix-ui/react-dialog": "1.1.4",
         "@radix-ui/react-dropdown-menu": "2.1.4",
         "@radix-ui/react-dropdown-menu": "2.1.4",
         "@radix-ui/react-icons": "1.3.2",
         "@radix-ui/react-icons": "1.3.2",

+ 3 - 1
server/lib/config.ts

@@ -32,7 +32,8 @@ const environmentSchema = z.object({
         internal_hostname: z.string().transform((url) => url.toLowerCase()),
         internal_hostname: z.string().transform((url) => url.toLowerCase()),
         secure_cookies: z.boolean(),
         secure_cookies: z.boolean(),
         session_cookie_name: z.string(),
         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({
     traefik: z.object({
         http_entrypoint: z.string(),
         http_entrypoint: z.string(),
@@ -186,6 +187,7 @@ export class Config {
             ?.disable_user_create_org
             ?.disable_user_create_org
             ? "true"
             ? "true"
             : "false";
             : "false";
+        process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.data.server.resource_access_token_param;
 
 
         this.rawConfig = parsedConfig.data;
         this.rawConfig = parsedConfig.data;
     }
     }

+ 1 - 1
server/routers/traefik/getTraefikConfig.ts

@@ -56,7 +56,7 @@ export async function traefikConfigProvider(
                                 config.getRawConfig().server.resource_session_cookie_name,
                                 config.getRawConfig().server.resource_session_cookie_name,
                             userSessionCookieName:
                             userSessionCookieName:
                                 config.getRawConfig().server.session_cookie_name,
                                 config.getRawConfig().server.session_cookie_name,
-                            accessTokenQueryParam: "p_token"
+                            accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
                         },
                         },
                     },
                     },
                 },
                 },

+ 3 - 1
server/setup/migrations.ts

@@ -9,6 +9,7 @@ import { loadAppVersion } from "@server/lib/loadAppVersion";
 import m1 from "./scripts/1.0.0-beta1";
 import m1 from "./scripts/1.0.0-beta1";
 import m2 from "./scripts/1.0.0-beta2";
 import m2 from "./scripts/1.0.0-beta2";
 import m3 from "./scripts/1.0.0-beta3";
 import m3 from "./scripts/1.0.0-beta3";
+import m4 from "./scripts/1.0.0-beta5";
 
 
 // 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
@@ -17,7 +18,8 @@ import m3 from "./scripts/1.0.0-beta3";
 const migrations = [
 const migrations = [
     { version: "1.0.0-beta.1", run: m1 },
     { version: "1.0.0-beta.1", run: m1 },
     { version: "1.0.0-beta.2", run: m2 },
     { 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
     // Add new migrations here as they are created
 ] as const;
 ] as const;
 
 

+ 42 - 0
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.");
+}

+ 91 - 11
src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx

@@ -57,14 +57,22 @@ import {
     CommandItem,
     CommandItem,
     CommandList
     CommandList
 } from "@app/components/ui/command";
 } from "@app/components/ui/command";
-import { CheckIcon } from "lucide-react";
+import { CheckIcon, ChevronsUpDown } from "lucide-react";
 import { register } from "module";
 import { register } from "module";
 import { Label } from "@app/components/ui/label";
 import { Label } from "@app/components/ui/label";
 import { Checkbox } from "@app/components/ui/checkbox";
 import { Checkbox } from "@app/components/ui/checkbox";
 import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
 import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
-import { constructShareLink } from "@app/lib/shareLinks";
+import {
+    constructDirectShareLink,
+    constructShareLink
+} from "@app/lib/shareLinks";
 import { ShareLinkRow } from "./ShareLinksTable";
 import { ShareLinkRow } from "./ShareLinksTable";
 import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
 import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
+import {
+    Collapsible,
+    CollapsibleContent,
+    CollapsibleTrigger
+} from "@app/components/ui/collapsible";
 
 
 type FormProps = {
 type FormProps = {
     open: boolean;
     open: boolean;
@@ -75,6 +83,7 @@ type FormProps = {
 const formSchema = z.object({
 const formSchema = z.object({
     resourceId: z.number({ message: "Please select a resource" }),
     resourceId: z.number({ message: "Please select a resource" }),
     resourceName: z.string(),
     resourceName: z.string(),
+    resourceUrl: z.string(),
     timeUnit: z.string(),
     timeUnit: z.string(),
     timeValue: z.coerce.number().int().positive().min(1),
     timeValue: z.coerce.number().int().positive().min(1),
     title: z.string().optional()
     title: z.string().optional()
@@ -88,14 +97,18 @@ export default function CreateShareLinkForm({
     const { toast } = useToast();
     const { toast } = useToast();
     const { org } = useOrgContext();
     const { org } = useOrgContext();
 
 
-    const api = createApiClient(useEnvContext());
+    const { env } = useEnvContext();
+    const api = createApiClient({ env });
 
 
     const [link, setLink] = useState<string | null>(null);
     const [link, setLink] = useState<string | null>(null);
+    const [directLink, setDirectLink] = useState<string | null>(null);
     const [loading, setLoading] = useState(false);
     const [loading, setLoading] = useState(false);
     const [neverExpire, setNeverExpire] = useState(false);
     const [neverExpire, setNeverExpire] = useState(false);
 
 
+    const [isOpen, setIsOpen] = useState(false);
+
     const [resources, setResources] = useState<
     const [resources, setResources] = useState<
-        { resourceId: number; name: string }[]
+        { resourceId: number; name: string; resourceUrl: string }[]
     >([]);
     >([]);
 
 
     const timeUnits = [
     const timeUnits = [
@@ -139,7 +152,13 @@ export default function CreateShareLinkForm({
                 });
                 });
 
 
             if (res?.status === 200) {
             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
                 token.accessToken
             );
             );
             setLink(link);
             setLink(link);
+            const directLink = constructDirectShareLink(
+                env.server.resourceAccessTokenParam,
+                values.resourceUrl,
+                token.accessTokenId,
+                token.accessToken
+            );
+            setDirectLink(directLink);
             onCreated?.({
             onCreated?.({
                 accessTokenId: token.accessTokenId,
                 accessTokenId: token.accessTokenId,
                 resourceId: token.resourceId,
                 resourceId: token.resourceId,
@@ -306,6 +332,10 @@ export default function CreateShareLinkForm({
                                                                                             "resourceName",
                                                                                             "resourceName",
                                                                                             r.name
                                                                                             r.name
                                                                                         );
                                                                                         );
+                                                                                        form.setValue(
+                                                                                            "resourceUrl",
+                                                                                            r.resourceUrl
+                                                                                        );
                                                                                     }}
                                                                                     }}
                                                                                 >
                                                                                 >
                                                                                     <CheckIcon
                                                                                     <CheckIcon
@@ -462,12 +492,62 @@ export default function CreateShareLinkForm({
                                         <QRCodeCanvas value={link} size={200} />
                                         <QRCodeCanvas value={link} size={200} />
                                     </div>
                                     </div>
 
 
-                                    <div className="mx-auto">
-                                        <CopyTextBox
-                                            text={link}
-                                            wrapText={false}
-                                        />
-                                    </div>
+                                    <Collapsible
+                                        open={isOpen}
+                                        onOpenChange={setIsOpen}
+                                        className="space-y-2"
+                                    >
+                                        <div className="mx-auto">
+                                            <CopyTextBox
+                                                text={link}
+                                                wrapText={false}
+                                            />
+                                        </div>
+                                        <div className="flex items-center justify-between space-x-4">
+                                            <CollapsibleTrigger asChild>
+                                                <Button
+                                                    variant="text"
+                                                    size="sm"
+                                                    className="p-0 flex items-center justify-between w-full"
+                                                >
+                                                    <h4 className="text-sm font-semibold">
+                                                        See alternative share
+                                                        links
+                                                    </h4>
+                                                    <div>
+                                                        <ChevronsUpDown className="h-4 w-4" />
+                                                        <span className="sr-only">
+                                                            Toggle
+                                                        </span>
+                                                    </div>
+                                                </Button>
+                                            </CollapsibleTrigger>
+                                        </div>
+                                        <CollapsibleContent className="space-y-2">
+                                            {directLink && (
+                                                <div className="space-y-1">
+                                                    <div className="mx-auto">
+                                                        <CopyTextBox
+                                                            text={directLink}
+                                                            wrapText={false}
+                                                        />
+                                                    </div>
+                                                    <p className="text-sm text-muted-foreground">
+                                                        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.
+                                                    </p>
+                                                </div>
+                                            )}
+                                        </CollapsibleContent>
+                                    </Collapsible>
                                 </div>
                                 </div>
                             )}
                             )}
                         </div>
                         </div>

+ 9 - 10
src/app/[orgId]/settings/share-links/ShareLinksTable.tsx

@@ -24,7 +24,7 @@ import { useRouter } from "next/navigation";
 // import CreateResourceForm from "./CreateResourceForm";
 // import CreateResourceForm from "./CreateResourceForm";
 import { useState } from "react";
 import { useState } from "react";
 import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
 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 { useToast } from "@app/hooks/useToast";
 import { createApiClient } from "@app/lib/api";
 import { createApiClient } from "@app/lib/api";
 import { useEnvContext } from "@app/hooks/useEnvContext";
 import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -109,15 +109,14 @@ export default function ShareLinksTable({
                                     </Button>
                                     </Button>
                                 </DropdownMenuTrigger>
                                 </DropdownMenuTrigger>
                                 <DropdownMenuContent align="end">
                                 <DropdownMenuContent align="end">
-                                    <DropdownMenuItem>
-                                        <button
-                                            onClick={() =>
-                                                deleteSharelink(
-                                                    resourceRow.accessTokenId
-                                                )
-                                            }
-                                            className="text-red-500"
-                                        >
+                                    <DropdownMenuItem
+                                        onClick={() => {
+                                            deleteSharelink(
+                                                resourceRow.accessTokenId
+                                            );
+                                        }}
+                                    >
+                                        <button className="text-red-500">
                                             Delete
                                             Delete
                                         </button>
                                         </button>
                                     </DropdownMenuItem>
                                     </DropdownMenuItem>

+ 35 - 10
src/app/auth/resource/[resourceId]/AccessToken.tsx

@@ -30,6 +30,7 @@ export default function AccessToken({
     redirectUrl
     redirectUrl
 }: AccessTokenProps) {
 }: AccessTokenProps) {
     const [loading, setLoading] = useState(true);
     const [loading, setLoading] = useState(true);
+    const [isValid, setIsValid] = useState(false);
 
 
     const api = createApiClient(useEnvContext());
     const api = createApiClient(useEnvContext());
 
 
@@ -49,6 +50,7 @@ export default function AccessToken({
                 });
                 });
 
 
                 if (res.data.data.session) {
                 if (res.data.data.session) {
+                    setIsValid(true);
                     window.location.href = redirectUrl;
                     window.location.href = redirectUrl;
                 }
                 }
             } catch (e) {
             } catch (e) {
@@ -61,24 +63,47 @@ export default function AccessToken({
         check();
         check();
     }, [accessTokenId, accessToken]);
     }, [accessTokenId, accessToken]);
 
 
+    function renderTitle() {
+        if (isValid) {
+            return "Access Granted";
+        } else {
+            return "Access URL Invalid";
+        }
+    }
+
+    function renderContent() {
+        if (isValid) {
+            return (
+                <div>
+                    You have been granted access to this resource. Redirecting
+                    you...
+                </div>
+            );
+        } else {
+            return (
+                <div>
+                    This shared access URL is invalid. Please contact the
+                    resource owner for a new URL.
+                    <div className="text-center mt-4">
+                        <Button>
+                            <Link href="/">Go Home</Link>
+                        </Button>
+                    </div>
+                </div>
+            );
+        }
+    }
+
     return loading ? (
     return loading ? (
         <div></div>
         <div></div>
     ) : (
     ) : (
         <Card className="w-full max-w-md">
         <Card className="w-full max-w-md">
             <CardHeader>
             <CardHeader>
                 <CardTitle className="text-center text-2xl font-bold">
                 <CardTitle className="text-center text-2xl font-bold">
-                    Access URL Invalid
+                    {renderTitle()}
                 </CardTitle>
                 </CardTitle>
             </CardHeader>
             </CardHeader>
-            <CardContent>
-                This shared access URL is invalid. Please contact the resource
-                owner for a new URL.
-                <div className="text-center mt-4">
-                    <Button>
-                        <Link href="/">Go Home</Link>
-                    </Button>
-                </div>
-            </CardContent>
+            <CardContent>{renderContent()}</CardContent>
         </Card>
         </Card>
     );
     );
 }
 }

+ 1 - 0
src/components/ui/button.tsx

@@ -19,6 +19,7 @@ const buttonVariants = cva(
                 secondary:
                 secondary:
                     "bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
                     "bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
                 ghost: "hover:bg-accent hover:text-accent-foreground",
                 ghost: "hover:bg-accent hover:text-accent-foreground",
+                text: "",
                 link: "text-primary underline-offset-4 hover:underline",
                 link: "text-primary underline-offset-4 hover:underline",
             },
             },
             size: {
             size: {

+ 11 - 0
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 }

+ 2 - 1
src/lib/pullEnv.ts

@@ -6,7 +6,8 @@ export function pullEnv(): Env {
             nextPort: process.env.NEXT_PORT as string,
             nextPort: process.env.NEXT_PORT as string,
             externalPort: process.env.SERVER_EXTERNAL_PORT as string,
             externalPort: process.env.SERVER_EXTERNAL_PORT as string,
             sessionCookieName: process.env.SESSION_COOKIE_NAME 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: {
         app: {
             environment: process.env.ENVIRONMENT as string,
             environment: process.env.ENVIRONMENT as string,

+ 11 - 0
src/lib/shareLinks.ts

@@ -1,3 +1,5 @@
+import { pullEnv } from "./pullEnv";
+
 export function constructShareLink(
 export function constructShareLink(
     resourceId: number,
     resourceId: number,
     id: string,
     id: string,
@@ -5,3 +7,12 @@ export function constructShareLink(
 ) {
 ) {
     return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`;
     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}`;
+}

+ 1 - 0
src/lib/types/env.ts

@@ -8,6 +8,7 @@ export type Env = {
         nextPort: string;
         nextPort: string;
         sessionCookieName: string;
         sessionCookieName: string;
         resourceSessionCookieName: string;
         resourceSessionCookieName: string;
+        resourceAccessTokenParam: string;
     },
     },
     email: {
     email: {
         emailEnabled: boolean;
         emailEnabled: boolean;