mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-15 16:34:52 +02:00
shorten share links and add migration
This commit is contained in:
parent
302094771b
commit
74d6b3d902
12 changed files with 231 additions and 63 deletions
|
@ -3,10 +3,63 @@ import {
|
|||
Resource,
|
||||
ResourceAccessToken,
|
||||
resourceAccessToken,
|
||||
resources
|
||||
} from "@server/db/schemas";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { isWithinExpirationDate } from "oslo";
|
||||
import { verifyPassword } from "./password";
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
|
||||
export async function verifyResourceAccessTokenSHA256({
|
||||
accessToken
|
||||
}: {
|
||||
accessToken: string;
|
||||
}): Promise<{
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
tokenItem?: ResourceAccessToken;
|
||||
resource?: Resource;
|
||||
}> {
|
||||
const accessTokenHash = encodeHexLowerCase(
|
||||
sha256(new TextEncoder().encode(accessToken))
|
||||
);
|
||||
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(resourceAccessToken)
|
||||
.where(and(eq(resourceAccessToken.tokenHash, accessTokenHash)))
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resourceAccessToken.resourceId, resources.resourceId)
|
||||
);
|
||||
|
||||
const tokenItem = res?.resourceAccessToken;
|
||||
const resource = res?.resources;
|
||||
|
||||
if (!tokenItem || !resource) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Access token does not exist for resource"
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
tokenItem.expiresAt &&
|
||||
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Access token has expired"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
tokenItem,
|
||||
resource
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyResourceAccessToken({
|
||||
resource,
|
||||
|
|
|
@ -2,7 +2,7 @@ import path from "path";
|
|||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.1.0";
|
||||
export const APP_VERSION = "1.2.0";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
|
|
@ -20,6 +20,8 @@ import { fromError } from "zod-validation-error";
|
|||
import logger from "@server/logger";
|
||||
import { createDate, TimeSpan } from "oslo";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
|
||||
export const generateAccessTokenBodySchema = z
|
||||
.object({
|
||||
|
@ -90,11 +92,13 @@ export async function generateAccessToken(
|
|||
? createDate(new TimeSpan(validForSeconds, "s")).getTime()
|
||||
: undefined;
|
||||
|
||||
const token = generateIdFromEntropySize(25);
|
||||
const token = generateIdFromEntropySize(12);
|
||||
|
||||
const tokenHash = await hashPassword(token);
|
||||
const tokenHash = encodeHexLowerCase(
|
||||
sha256(new TextEncoder().encode(token))
|
||||
);
|
||||
|
||||
const id = generateId(15);
|
||||
const id = generateId(8);
|
||||
const [result] = await db
|
||||
.insert(resourceAccessToken)
|
||||
.values({
|
||||
|
|
|
@ -566,3 +566,8 @@ authRouter.post(
|
|||
"/resource/:resourceId/access-token",
|
||||
resource.authWithAccessToken
|
||||
);
|
||||
|
||||
authRouter.post(
|
||||
"/access-token",
|
||||
resource.authWithAccessToken
|
||||
);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import db from "@server/db";
|
||||
import { resources } from "@server/db/schemas";
|
||||
import { Resource, resources } from "@server/db/schemas";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
@ -10,13 +10,17 @@ import { z } from "zod";
|
|||
import { fromError } from "zod-validation-error";
|
||||
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||
import logger from "@server/logger";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
import {
|
||||
verifyResourceAccessToken,
|
||||
verifyResourceAccessTokenSHA256
|
||||
} from "@server/auth/verifyResourceAccessToken";
|
||||
import config from "@server/lib/config";
|
||||
import stoi from "@server/lib/stoi";
|
||||
|
||||
const authWithAccessTokenBodySchema = z
|
||||
.object({
|
||||
accessToken: z.string(),
|
||||
accessTokenId: z.string()
|
||||
accessTokenId: z.string().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
@ -24,13 +28,15 @@ const authWithAccessTokenParamsSchema = z
|
|||
.object({
|
||||
resourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.optional()
|
||||
.transform(stoi)
|
||||
.pipe(z.number().int().positive().optional())
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type AuthWithAccessTokenResponse = {
|
||||
session?: string;
|
||||
redirectUrl?: string | null;
|
||||
};
|
||||
|
||||
export async function authWithAccessToken(
|
||||
|
@ -64,24 +70,63 @@ export async function authWithAccessToken(
|
|||
const { accessToken, accessTokenId } = parsedBody.data;
|
||||
|
||||
try {
|
||||
const [resource] = await db
|
||||
let valid;
|
||||
let tokenItem;
|
||||
let error;
|
||||
let resource: Resource | undefined;
|
||||
|
||||
if (accessTokenId) {
|
||||
if (!resourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Resource ID is required"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [foundResource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
if (!foundResource) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||
);
|
||||
}
|
||||
|
||||
const { valid, error, tokenItem } = await verifyResourceAccessToken({
|
||||
resource,
|
||||
const res = await verifyResourceAccessToken({
|
||||
resource: foundResource,
|
||||
accessTokenId,
|
||||
accessToken
|
||||
});
|
||||
|
||||
valid = res.valid;
|
||||
tokenItem = res.tokenItem;
|
||||
error = res.error;
|
||||
resource = foundResource;
|
||||
} else {
|
||||
const res = await verifyResourceAccessTokenSHA256({
|
||||
accessToken
|
||||
});
|
||||
|
||||
valid = res.valid;
|
||||
tokenItem = res.tokenItem;
|
||||
error = res.error;
|
||||
resource = res.resource;
|
||||
}
|
||||
|
||||
if (!tokenItem || !resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Access token does not exist for resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
|
@ -96,18 +141,9 @@ export async function authWithAccessToken(
|
|||
);
|
||||
}
|
||||
|
||||
if (!tokenItem || !resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Access token does not exist for resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
await createResourceSession({
|
||||
resourceId,
|
||||
resourceId: resource.resourceId,
|
||||
token,
|
||||
accessTokenId: tokenItem.accessTokenId,
|
||||
isRequestToken: true,
|
||||
|
@ -118,7 +154,8 @@ export async function authWithAccessToken(
|
|||
|
||||
return response<AuthWithAccessTokenResponse>(res, {
|
||||
data: {
|
||||
session: token
|
||||
session: token,
|
||||
redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
|
|
@ -18,6 +18,7 @@ import m13 from "./scripts/1.0.0-beta13";
|
|||
import m15 from "./scripts/1.0.0-beta15";
|
||||
import m16 from "./scripts/1.0.0";
|
||||
import m17 from "./scripts/1.1.0";
|
||||
import m18 from "./scripts/1.2.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -35,7 +36,8 @@ const migrations = [
|
|||
{ version: "1.0.0-beta.13", run: m13 },
|
||||
{ version: "1.0.0-beta.15", run: m15 },
|
||||
{ version: "1.0.0", run: m16 },
|
||||
{ version: "1.1.0", run: m17 }
|
||||
{ version: "1.1.0", run: m17 },
|
||||
{ version: "1.2.0", run: m18 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
|
23
server/setup/scripts/1.2.0.ts
Normal file
23
server/setup/scripts/1.2.0.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import db from "@server/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const version = "1.2.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
db.transaction((trx) => {
|
||||
trx.run(
|
||||
sql`ALTER TABLE 'resources' ADD 'enabled' integer DEFAULT true NOT NULL;`
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`Migrated database schema`);
|
||||
} catch (e) {
|
||||
console.log("Unable to migrate database schema");
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
|
@ -224,21 +224,18 @@ export default function CreateShareLinkForm({
|
|||
|
||||
if (res && res.data.data.accessTokenId) {
|
||||
const token = res.data.data;
|
||||
const link = constructShareLink(
|
||||
values.resourceId,
|
||||
token.accessTokenId,
|
||||
token.accessToken
|
||||
);
|
||||
const link = constructShareLink(token.accessToken);
|
||||
setLink(link);
|
||||
const directLink = constructDirectShareLink(
|
||||
env.server.resourceAccessTokenParam,
|
||||
values.resourceUrl,
|
||||
token.accessTokenId,
|
||||
token.accessToken
|
||||
);
|
||||
setDirectLink(directLink);
|
||||
|
||||
const resource = resources.find((r) => r.resourceId === values.resourceId);
|
||||
const resource = resources.find(
|
||||
(r) => r.resourceId === values.resourceId
|
||||
);
|
||||
|
||||
onCreated?.({
|
||||
accessTokenId: token.accessTokenId,
|
||||
|
@ -247,7 +244,7 @@ export default function CreateShareLinkForm({
|
|||
title: token.title,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt,
|
||||
siteName: resource?.siteName || null,
|
||||
siteName: resource?.siteName || null
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -15,15 +15,13 @@ import Link from "next/link";
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
type AccessTokenProps = {
|
||||
accessTokenId: string | undefined;
|
||||
accessToken: string | undefined;
|
||||
resourceId: number;
|
||||
redirectUrl: string;
|
||||
token: string;
|
||||
resourceId?: number;
|
||||
redirectUrl?: string;
|
||||
};
|
||||
|
||||
export default function AccessToken({
|
||||
accessTokenId,
|
||||
accessToken,
|
||||
token,
|
||||
resourceId,
|
||||
redirectUrl
|
||||
}: AccessTokenProps) {
|
||||
|
@ -43,11 +41,49 @@ export default function AccessToken({
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessTokenId || !accessToken) {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let accessTokenId = "";
|
||||
let accessToken = "";
|
||||
|
||||
const parts = token.split(".");
|
||||
|
||||
if (parts.length === 2) {
|
||||
accessTokenId = parts[0];
|
||||
accessToken = parts[1];
|
||||
} else if (parts.length === 1) {
|
||||
accessToken = parts[0];
|
||||
} else {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function checkSHA256() {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<AuthWithAccessTokenResponse>
|
||||
>(`/auth/access-token`, {
|
||||
accessToken,
|
||||
accessTokenId
|
||||
});
|
||||
|
||||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = appendRequestToken(
|
||||
res.data.data.redirectUrl!,
|
||||
res.data.data.session
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error checking access token", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await api.post<
|
||||
|
@ -60,7 +96,7 @@ export default function AccessToken({
|
|||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = appendRequestToken(
|
||||
redirectUrl,
|
||||
redirectUrl!,
|
||||
res.data.data.session
|
||||
);
|
||||
}
|
||||
|
@ -71,8 +107,13 @@ export default function AccessToken({
|
|||
}
|
||||
}
|
||||
|
||||
if (!accessTokenId) {
|
||||
// no access token id so check the sha256
|
||||
checkSHA256();
|
||||
} else {
|
||||
check();
|
||||
}, [accessTokenId, accessToken]);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
function renderTitle() {
|
||||
if (isValid) {
|
||||
|
|
|
@ -118,12 +118,10 @@ export default async function ResourceAuthPage(props: {
|
|||
}
|
||||
|
||||
if (searchParams.token) {
|
||||
const [accessTokenId, accessToken] = searchParams.token.split(".");
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<AccessToken
|
||||
accessToken={accessToken}
|
||||
accessTokenId={accessTokenId}
|
||||
token={searchParams.token}
|
||||
resourceId={params.resourceId}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
|
|
13
src/app/s/[accessToken]/page.tsx
Normal file
13
src/app/s/[accessToken]/page.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import AccessToken from "@app/app/auth/resource/[resourceId]/AccessToken";
|
||||
|
||||
export default async function ResourceAuthPage(props: {
|
||||
params: Promise<{ accessToken: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto p-3 md:mt-32">
|
||||
<AccessToken token={params.accessToken} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,18 +1,13 @@
|
|||
import { pullEnv } from "./pullEnv";
|
||||
|
||||
export function constructShareLink(
|
||||
resourceId: number,
|
||||
id: string,
|
||||
token: string
|
||||
) {
|
||||
return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`;
|
||||
return `${window.location.origin}/s/${token!}`;
|
||||
}
|
||||
|
||||
export function constructDirectShareLink(
|
||||
param: string,
|
||||
resourceUrl: string,
|
||||
id: string,
|
||||
token: string
|
||||
) {
|
||||
return `${resourceUrl}?${param}=${id}.${token}`;
|
||||
return `${resourceUrl}?${param}=${token}`;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue