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,
|
Resource,
|
||||||
ResourceAccessToken,
|
ResourceAccessToken,
|
||||||
resourceAccessToken,
|
resourceAccessToken,
|
||||||
|
resources
|
||||||
} from "@server/db/schemas";
|
} from "@server/db/schemas";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
import { isWithinExpirationDate } from "oslo";
|
||||||
import { verifyPassword } from "./password";
|
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({
|
export async function verifyResourceAccessToken({
|
||||||
resource,
|
resource,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// 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 __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|
|
@ -20,6 +20,8 @@ import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { createDate, TimeSpan } from "oslo";
|
import { createDate, TimeSpan } from "oslo";
|
||||||
import { hashPassword } from "@server/auth/password";
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||||
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
|
|
||||||
export const generateAccessTokenBodySchema = z
|
export const generateAccessTokenBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -90,11 +92,13 @@ export async function generateAccessToken(
|
||||||
? createDate(new TimeSpan(validForSeconds, "s")).getTime()
|
? createDate(new TimeSpan(validForSeconds, "s")).getTime()
|
||||||
: undefined;
|
: 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
|
const [result] = await db
|
||||||
.insert(resourceAccessToken)
|
.insert(resourceAccessToken)
|
||||||
.values({
|
.values({
|
||||||
|
|
|
@ -566,3 +566,8 @@ authRouter.post(
|
||||||
"/resource/:resourceId/access-token",
|
"/resource/:resourceId/access-token",
|
||||||
resource.authWithAccessToken
|
resource.authWithAccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authRouter.post(
|
||||||
|
"/access-token",
|
||||||
|
resource.authWithAccessToken
|
||||||
|
);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import db from "@server/db";
|
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 HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
@ -10,13 +10,17 @@ import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { createResourceSession } from "@server/auth/sessions/resource";
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
import logger from "@server/logger";
|
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 config from "@server/lib/config";
|
||||||
|
import stoi from "@server/lib/stoi";
|
||||||
|
|
||||||
const authWithAccessTokenBodySchema = z
|
const authWithAccessTokenBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
accessToken: z.string(),
|
accessToken: z.string(),
|
||||||
accessTokenId: z.string()
|
accessTokenId: z.string().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -24,13 +28,15 @@ const authWithAccessTokenParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
resourceId: z
|
resourceId: z
|
||||||
.string()
|
.string()
|
||||||
.transform(Number)
|
.optional()
|
||||||
.pipe(z.number().int().positive())
|
.transform(stoi)
|
||||||
|
.pipe(z.number().int().positive().optional())
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export type AuthWithAccessTokenResponse = {
|
export type AuthWithAccessTokenResponse = {
|
||||||
session?: string;
|
session?: string;
|
||||||
|
redirectUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function authWithAccessToken(
|
export async function authWithAccessToken(
|
||||||
|
@ -64,23 +70,62 @@ export async function authWithAccessToken(
|
||||||
const { accessToken, accessTokenId } = parsedBody.data;
|
const { accessToken, accessTokenId } = parsedBody.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [resource] = await db
|
let valid;
|
||||||
.select()
|
let tokenItem;
|
||||||
.from(resources)
|
let error;
|
||||||
.where(eq(resources.resourceId, resourceId))
|
let resource: Resource | undefined;
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!resource) {
|
if (accessTokenId) {
|
||||||
return next(
|
if (!resourceId) {
|
||||||
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
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 (!foundResource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { valid, error, tokenItem } = await verifyResourceAccessToken({
|
if (!tokenItem || !resource) {
|
||||||
resource,
|
return next(
|
||||||
accessTokenId,
|
createHttpError(
|
||||||
accessToken
|
HttpCode.UNAUTHORIZED,
|
||||||
});
|
"Access token does not exist for resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
@ -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();
|
const token = generateSessionToken();
|
||||||
await createResourceSession({
|
await createResourceSession({
|
||||||
resourceId,
|
resourceId: resource.resourceId,
|
||||||
token,
|
token,
|
||||||
accessTokenId: tokenItem.accessTokenId,
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
isRequestToken: true,
|
isRequestToken: true,
|
||||||
|
@ -118,7 +154,8 @@ export async function authWithAccessToken(
|
||||||
|
|
||||||
return response<AuthWithAccessTokenResponse>(res, {
|
return response<AuthWithAccessTokenResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
session: token
|
session: token,
|
||||||
|
redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|
|
@ -18,6 +18,7 @@ import m13 from "./scripts/1.0.0-beta13";
|
||||||
import m15 from "./scripts/1.0.0-beta15";
|
import m15 from "./scripts/1.0.0-beta15";
|
||||||
import m16 from "./scripts/1.0.0";
|
import m16 from "./scripts/1.0.0";
|
||||||
import m17 from "./scripts/1.1.0";
|
import m17 from "./scripts/1.1.0";
|
||||||
|
import m18 from "./scripts/1.2.0";
|
||||||
|
|
||||||
// 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
|
||||||
|
@ -35,7 +36,8 @@ const migrations = [
|
||||||
{ version: "1.0.0-beta.13", run: m13 },
|
{ version: "1.0.0-beta.13", run: m13 },
|
||||||
{ version: "1.0.0-beta.15", run: m15 },
|
{ version: "1.0.0-beta.15", run: m15 },
|
||||||
{ version: "1.0.0", run: m16 },
|
{ 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
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] 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) {
|
if (res && res.data.data.accessTokenId) {
|
||||||
const token = res.data.data;
|
const token = res.data.data;
|
||||||
const link = constructShareLink(
|
const link = constructShareLink(token.accessToken);
|
||||||
values.resourceId,
|
|
||||||
token.accessTokenId,
|
|
||||||
token.accessToken
|
|
||||||
);
|
|
||||||
setLink(link);
|
setLink(link);
|
||||||
const directLink = constructDirectShareLink(
|
const directLink = constructDirectShareLink(
|
||||||
env.server.resourceAccessTokenParam,
|
env.server.resourceAccessTokenParam,
|
||||||
values.resourceUrl,
|
values.resourceUrl,
|
||||||
token.accessTokenId,
|
|
||||||
token.accessToken
|
token.accessToken
|
||||||
);
|
);
|
||||||
setDirectLink(directLink);
|
setDirectLink(directLink);
|
||||||
|
|
||||||
const resource = resources.find((r) => r.resourceId === values.resourceId);
|
const resource = resources.find(
|
||||||
|
(r) => r.resourceId === values.resourceId
|
||||||
|
);
|
||||||
|
|
||||||
onCreated?.({
|
onCreated?.({
|
||||||
accessTokenId: token.accessTokenId,
|
accessTokenId: token.accessTokenId,
|
||||||
|
@ -247,7 +244,7 @@ export default function CreateShareLinkForm({
|
||||||
title: token.title,
|
title: token.title,
|
||||||
createdAt: token.createdAt,
|
createdAt: token.createdAt,
|
||||||
expiresAt: token.expiresAt,
|
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";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type AccessTokenProps = {
|
type AccessTokenProps = {
|
||||||
accessTokenId: string | undefined;
|
token: string;
|
||||||
accessToken: string | undefined;
|
resourceId?: number;
|
||||||
resourceId: number;
|
redirectUrl?: string;
|
||||||
redirectUrl: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AccessToken({
|
export default function AccessToken({
|
||||||
accessTokenId,
|
token,
|
||||||
accessToken,
|
|
||||||
resourceId,
|
resourceId,
|
||||||
redirectUrl
|
redirectUrl
|
||||||
}: AccessTokenProps) {
|
}: AccessTokenProps) {
|
||||||
|
@ -43,11 +41,49 @@ export default function AccessToken({
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accessTokenId || !accessToken) {
|
if (!token) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
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() {
|
async function check() {
|
||||||
try {
|
try {
|
||||||
const res = await api.post<
|
const res = await api.post<
|
||||||
|
@ -60,7 +96,7 @@ export default function AccessToken({
|
||||||
if (res.data.data.session) {
|
if (res.data.data.session) {
|
||||||
setIsValid(true);
|
setIsValid(true);
|
||||||
window.location.href = appendRequestToken(
|
window.location.href = appendRequestToken(
|
||||||
redirectUrl,
|
redirectUrl!,
|
||||||
res.data.data.session
|
res.data.data.session
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -71,8 +107,13 @@ export default function AccessToken({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
check();
|
if (!accessTokenId) {
|
||||||
}, [accessTokenId, accessToken]);
|
// no access token id so check the sha256
|
||||||
|
checkSHA256();
|
||||||
|
} else {
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
function renderTitle() {
|
function renderTitle() {
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
|
|
|
@ -118,12 +118,10 @@ export default async function ResourceAuthPage(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchParams.token) {
|
if (searchParams.token) {
|
||||||
const [accessTokenId, accessToken] = searchParams.token.split(".");
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<AccessToken
|
<AccessToken
|
||||||
accessToken={accessToken}
|
token={searchParams.token}
|
||||||
accessTokenId={accessTokenId}
|
|
||||||
resourceId={params.resourceId}
|
resourceId={params.resourceId}
|
||||||
redirectUrl={redirectUrl}
|
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(
|
export function constructShareLink(
|
||||||
resourceId: number,
|
|
||||||
id: string,
|
|
||||||
token: string
|
token: string
|
||||||
) {
|
) {
|
||||||
return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`;
|
return `${window.location.origin}/s/${token!}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function constructDirectShareLink(
|
export function constructDirectShareLink(
|
||||||
param: string,
|
param: string,
|
||||||
resourceUrl: string,
|
resourceUrl: string,
|
||||||
id: string,
|
|
||||||
token: string
|
token: string
|
||||||
) {
|
) {
|
||||||
return `${resourceUrl}?${param}=${id}.${token}`;
|
return `${resourceUrl}?${param}=${token}`;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue