Compare commits

..

4 Commits

33 changed files with 381 additions and 1103 deletions

View File

@@ -47,17 +47,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳 ## Self Hosting 🐳
### One-click Deployments The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
</a>
### Docker Compose
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
1. Create a new file called `docker-compose.yml` and paste the following configuration: 1. Create a new file called `docker-compose.yml` and paste the following configuration:

View File

@@ -20,7 +20,6 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0", "@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/lib-storage": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0", "@aws-sdk/s3-request-presigner": "^3.812.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
@@ -39,7 +38,6 @@
"@tiptap/extension-link": "^2.22.2", "@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9", "@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0", "@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-typography": "^3.18.0",
"@tiptap/pm": "^2.14.0", "@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0", "@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0", "@tiptap/starter-kit": "^2.14.0",

View File

@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
})} })}
</div> </div>
)} )}
{!(isCredentialsEnabled || isMagicLinkAvailable) && <form onSubmit={handleSubmit(onSubmit)}>
socialProviders?.length === 0 && ( {!isCredentialsEnabled && socialProviders?.length === 0 && (
<div className="flex w-full items-center gap-4"> <div className="flex w-full items-center gap-4">
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" /> <div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
<span className="text-center text-sm text-light-900 dark:text-dark-900"> <span className="text-center text-sm text-light-900 dark:text-dark-900">
@@ -371,62 +371,65 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" /> <div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
</div> </div>
)} )}
{(isCredentialsEnabled || isMagicLinkAvailable) && ( {!isCredentialsEnabled && socialProviders?.length !== 0 && (
<form onSubmit={handleSubmit(onSubmit)}> <div className="mb-[1.5rem] flex w-full items-center gap-4">
{socialProviders?.length !== 0 && ( <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<div className="mb-[1.5rem] flex w-full items-center gap-4"> <span className="text-sm text-light-900 dark:text-dark-900">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> {t`or`}
<span className="text-sm text-light-900 dark:text-dark-900"> </span>
{t`or`} <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</span> </div>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> )}
</div> <div className="space-y-2">
)} {isSignUp && isCredentialsEnabled && (
<div className="space-y-2">
{isSignUp && isCredentialsEnabled && (
<div>
<Input
{...register("name", { required: true })}
placeholder={t`Enter your name`}
/>
{errors.name && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid name`}
</p>
)}
</div>
)}
<div> <div>
<Input <Input
{...register("email", { required: true })} {...register("name", { required: true })}
placeholder={t`Enter your email address`} placeholder={t`Enter your name`}
/> />
{errors.email && ( {errors.name && (
<p className="mt-2 text-xs text-red-400"> <p className="mt-2 text-xs text-red-400">
{t`Please enter a valid email address`} {t`Please enter a valid name`}
</p> </p>
)} )}
</div> </div>
)}
{isCredentialsEnabled && ( {(isCredentialsEnabled || isMagicLinkAvailable) && (
<>
<div> <div>
<Input <Input
type="password" {...register("email", { required: true })}
{...register("password", { required: true })} placeholder={t`Enter your email address`}
placeholder={t`Enter your password`}
/> />
{errors.password && ( {errors.email && (
<p className="mt-2 text-xs text-red-400"> <p className="mt-2 text-xs text-red-400">
{errors.password.message ?? {t`Please enter a valid email address`}
t`Please enter a valid password`}
</p> </p>
)} )}
</div> </div>
)}
{loginError && ( {isCredentialsEnabled && (
<p className="mt-2 text-xs text-red-400">{loginError}</p> <div>
)} <Input
</div> type="password"
{...register("password", { required: true })}
placeholder={t`Enter your password`}
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
{errors.password.message ??
t`Please enter a valid password`}
</p>
)}
</div>
)}
</>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
{(isCredentialsEnabled || isMagicLinkAvailable) && (
<div className="mt-[1.5rem] flex items-center gap-4"> <div className="mt-[1.5rem] flex items-center gap-4">
<Button <Button
isLoading={isLoginWithEmailPending} isLoading={isLoginWithEmailPending}
@@ -438,11 +441,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{isMagicLinkMode ? t`magic link` : t`email`} {isMagicLinkMode ? t`magic link` : t`email`}
</Button> </Button>
</div> </div>
</form> )}
)} </form>
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div> </div>
); );
} }

View File

@@ -25,7 +25,7 @@ const Avatar = ({
icon?: React.ReactNode; icon?: React.ReactNode;
isLoading?: boolean; isLoading?: boolean;
}) => { }) => {
const initials = name?.trim() const initials = name
? getInitialsFromName(name) ? getInitialsFromName(name)
: inferInitialsFromEmail(email); : inferInitialsFromEmail(email);

View File

@@ -12,7 +12,6 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside"; import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace"; import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import SideNavigation from "./SideNavigation"; import SideNavigation from "./SideNavigation";
interface DashboardProps { interface DashboardProps {
@@ -45,12 +44,6 @@ export default function Dashboard({
const { availableWorkspaces, hasLoaded } = useWorkspace(); const { availableWorkspaces, hasLoaded } = useWorkspace();
const { data: session, isPending: sessionLoading } = authClient.useSession(); const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
undefined,
{
enabled: !!session?.user,
},
);
const [isSideNavOpen, setIsSideNavOpen] = useState(false); const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -162,12 +155,8 @@ export default function Dashboard({
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `} className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
> >
<SideNavigation <SideNavigation
user={{ user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
displayName: user?.name ?? session?.user.name, isLoading={sessionLoading}
email: user?.email ?? session?.user.email ?? "",
image: user?.image ?? undefined,
}}
isLoading={sessionLoading || userLoading}
onCloseSideNav={closeSideNav} onCloseSideNav={closeSideNav}
/> />
</div> </div>

View File

@@ -17,7 +17,6 @@ import {
ReactRenderer, ReactRenderer,
useEditor, useEditor,
} from "@tiptap/react"; } from "@tiptap/react";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion"; import Suggestion from "@tiptap/suggestion";
import { import {
@@ -515,17 +514,6 @@ export default function Editor({
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
}, },
}), }),
Typography.configure({
openDoubleQuote: false,
closeDoubleQuote: false,
openSingleQuote: false,
closeSingleQuote: false,
oneHalf: false,
oneQuarter: false,
threeQuarters: false,
superscriptTwo: false,
superscriptThree: false,
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []), ...(enableYouTubeEmbed ? [YouTubeNode] : []),
], ],
content, content,

View File

@@ -7,7 +7,7 @@ import tippy from "tippy.js";
interface TooltipProps { interface TooltipProps {
children: ReactNode; children: ReactNode;
content?: ReactNode; content: ReactNode;
placement?: Placement; placement?: Placement;
delay?: number | [number, number]; delay?: number | [number, number];
} }
@@ -24,8 +24,6 @@ export function Tooltip({
useEffect(() => { useEffect(() => {
if (!triggerRef.current) return; if (!triggerRef.current) return;
if (!content) return;
const container = document.createElement("div"); const container = document.createElement("div");
const root = createRoot(container); const root = createRoot(container);
rootRef.current = root; rootRef.current = root;

View File

@@ -1,131 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { Upload } from "@aws-sdk/lib-storage";
import { createNextApiContext } from "@kan/api/trpc";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
import { generateUID } from "@kan/shared/utils";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
import { assertPermission } from "@kan/api/utils/permissions";
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
export const config = {
api: {
bodyParser: false,
},
};
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Attachments bucket not configured" });
}
const cardPublicId = req.query.cardPublicId;
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
return res.status(400).json({ error: "Invalid cardPublicId" });
}
const contentType = req.headers["content-type"];
const contentLengthHeader = req.headers["content-length"];
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: NaN;
if (typeof contentType !== "string") {
return res.status(400).json({ error: "Missing content type" });
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")
.substring(0, 200);
// Get card and check permissions
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
db,
cardPublicId,
);
if (!card) {
return res.status(404).json({ error: "Card not found" });
}
// Check if user has permission to edit the card
try {
await assertPermission(db, user.id, card.workspaceId, "card:edit");
} catch {
return res.status(403).json({ error: "Permission denied" });
}
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
const client = createS3Client();
const upload = new Upload({
client,
params: {
Bucket: bucket,
Key: s3Key,
Body: req,
ContentType: contentType,
ContentLength: contentLength,
},
leavePartsOnError: false,
});
await upload.done();
// Create attachment record and log activity
const attachment = await cardAttachmentRepo.create(db, {
cardId: card.id,
filename: sanitizedFilename,
originalFilename: originalFilenameHeader,
contentType,
size: contentLength,
s3Key,
createdBy: user.id,
});
await cardActivityRepo.create(db, {
type: "card.updated.attachment.added",
cardId: card.id,
createdBy: user.id,
});
return res.status(200).json({ attachment });
} catch (error) {
console.error("Attachment upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
);

View File

@@ -1,101 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc";
import * as userRepo from "@kan/db/repository/user.repo";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {
api: {
bodyParser: false,
},
};
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Avatar bucket not configured" });
}
const contentType = req.headers["content-type"];
const contentLengthHeader = req.headers["content-length"];
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: NaN;
if (typeof contentType !== "string") {
return res.status(400).json({ error: "Missing content type" });
}
if (!allowedContentTypes.includes(contentType)) {
return res.status(400).json({ error: "Invalid content type" });
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")
.substring(0, 200);
const s3Key = `${user.id}/${sanitizedFilename}`;
const client = createS3Client();
// Upload the file to S3
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: s3Key,
Body: req,
ContentType: contentType,
ContentLength: contentLength,
}),
);
// Update user image in database
const updatedUser = await userRepo.update(db, user.id, {
image: s3Key,
});
return res.status(200).json({
key: s3Key,
filename: sanitizedFilename,
contentType,
size: contentLength,
user: updatedUser,
});
} catch (error) {
console.error("Avatar upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
);

View File

@@ -0,0 +1,75 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env as nextRuntimeEnv } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { filename, contentType } = req.body as {
filename: string;
contentType: string;
};
// Specific to avatar uploads for now
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
if (!filenameRegex.test(filename)) {
return res.status(400).json({ error: "Invalid filename" });
}
if (
typeof contentType !== "string" ||
!allowedContentTypes.includes(contentType)
) {
return res.status(400).json({ error: "Invalid content type" });
}
const credentials =
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
? {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
}
: undefined;
const client = new S3Client({
region: env.S3_REGION ?? "",
endpoint: env.S3_ENDPOINT ?? "",
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
credentials,
});
const signedUrl = await getSignedUrl(
client,
new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename,
ACL: "public-read",
}),
);
return res.status(200).json({ url: signedUrl, key: filename });
} catch (error) {
return res.status(500).json({ error: (error as Error).message });
}
},
);

View File

@@ -1,3 +1,5 @@
import { env } from "next-runtime-env";
export const formatToArray = ( export const formatToArray = (
value: string | string[] | undefined, value: string | string[] | undefined,
): string[] => { ): string[] => {
@@ -50,5 +52,14 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
return imageOrKey; return imageOrKey;
} }
return ""; const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
if (useVirtualHostedUrls === "true" && storageDomain) {
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
}
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
return `${storageUrl}/${bucket}/${imageOrKey}`;
}; };

View File

@@ -1,40 +1,24 @@
import { Fragment } from "react"; import Link from "next/link";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { HiLink } from "react-icons/hi"; import { HiLink } from "react-icons/hi";
import { Tooltip } from "~/components/Tooltip"; import { Tooltip } from "~/components/Tooltip";
import { usePopup } from "~/providers/popup";
const displayBaseUrl =
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn"
: env("NEXT_PUBLIC_BASE_URL");
const linkBaseUrl = env("NEXT_PUBLIC_BASE_URL");
const pathSeparator = (
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600" />
);
const UpdateBoardSlugButton = ({ const UpdateBoardSlugButton = ({
handleOnClick, handleOnClick,
workspaceSlug, workspaceSlug,
boardSlug, boardSlug,
boardPublicId,
visibility,
isLoading, isLoading,
canEdit, canEdit,
}: { }: {
handleOnClick: () => void; handleOnClick: () => void;
workspaceSlug: string; workspaceSlug: string;
boardSlug: string; boardSlug: string;
boardPublicId: string;
visibility: "public" | "private";
isLoading: boolean; isLoading: boolean;
canEdit: boolean; canEdit: boolean;
}) => { }) => {
const { showPopup } = usePopup(); if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
if (isLoading) { if (isLoading) {
return ( return (
@@ -42,55 +26,44 @@ const UpdateBoardSlugButton = ({
); );
} }
if (!workspaceSlug || !boardSlug || !boardPublicId) return <></>;
const isPublic = visibility === "public";
const boardUrl = isPublic
? `${linkBaseUrl}/${workspaceSlug}/${boardSlug}`
: `${linkBaseUrl}/boards/${boardPublicId}`;
const pathSegments = isPublic
? [displayBaseUrl, workspaceSlug, boardSlug]
: [displayBaseUrl, "boards", boardPublicId];
return ( return (
<Tooltip <Tooltip
content={!canEdit ? t`You don't have permission` : undefined} content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
> >
<button <button
onClick={canEdit ? handleOnClick : undefined} onClick={canEdit ? handleOnClick : undefined}
disabled={!canEdit || isLoading} disabled={!canEdit || isLoading}
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex" className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
> >
<div className="flex items-center"> <div className="flex items-center">
{pathSegments.map((segment, i) => ( <span>
<Fragment key={i}> {env("NEXT_PUBLIC_KAN_ENV") === "cloud"
{i > 0 && pathSeparator} ? "kan.bn"
<span>{segment}</span> : env("NEXT_PUBLIC_BASE_URL")}
</Fragment> </span>
))} <div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
</div> <span>{workspaceSlug}</span>
<button <div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
type="button" <span>{boardSlug}</span>
onClick={(e) => { </div>
e.stopPropagation(); <Link
navigator.clipboard.writeText(boardUrl).then( href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
() => target="_blank"
showPopup({ rel="noopener noreferrer"
header: t`Link copied`, onClick={(e) => {
icon: "success", e.stopPropagation();
message: t`Board URL copied to clipboard`, if (!canEdit) {
}), e.preventDefault();
).catch(() => undefined); }
}} }}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200" className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
aria-label={t`Copy board link`} >
> <HiLink className="h-[13px] w-[13px]" />
<HiLink className="h-[13px] w-[13px]" /> </Link>
</button> </button>
</button>
</Tooltip> </Tooltip>
); );
}; };
export default UpdateBoardSlugButton; export default UpdateBoardSlugButton;

View File

@@ -156,7 +156,7 @@ export function UpdateBoardSlugForm({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="secondary" variant="secondary"
href="/settings/workspace" href="/settings?tab=workspace"
onClick={closeModal} onClick={closeModal}
> >
{t`Edit workspace URL`} {t`Edit workspace URL`}

View File

@@ -443,8 +443,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isLoading={isLoading} isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""} workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""} boardSlug={boardData?.slug ?? ""}
boardPublicId={boardId ?? ""}
visibility={boardData?.visibility ?? "private"}
canEdit={canEditBoard} canEdit={canEditBoard}
/> />
<VisibilityButton <VisibilityButton

View File

@@ -7,7 +7,6 @@ import { twMerge } from "tailwind-merge";
import Button from "~/components/Button"; import Button from "~/components/Button";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { env } from "next-runtime-env";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation"; import { invalidateCard } from "~/utils/cardInvalidation";
@@ -19,33 +18,62 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const uploadFile = async (file: File) => { const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
setUploading(true); const confirmAttachment = api.attachment.confirm.useMutation({
onSuccess: async () => {
try {
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
const response = await fetch(
`${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`,
{
method: "POST",
headers: {
"Content-Type": file.type,
"x-original-filename": file.name,
},
body: file,
},
);
if (!response.ok) {
throw new Error("Upload failed");
}
await invalidateCard(utils, cardPublicId); await invalidateCard(utils, cardPublicId);
showPopup({ showPopup({
header: t`Attachment uploaded`, header: t`Attachment uploaded`,
message: t`Your file has been uploaded successfully.`, message: t`Your file has been uploaded successfully.`,
icon: "success", icon: "success",
}); });
},
onError: () => {
showPopup({
header: t`Upload failed`,
message: t`Failed to upload attachment. Please try again.`,
icon: "error",
});
},
onSettled: () => {
setUploading(false);
},
});
const uploadFile = async (file: File) => {
setUploading(true);
try {
// Generate presigned URL
const { url, key } = await generateUploadUrl.mutateAsync({
cardPublicId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// Upload file to S3
const uploadResponse = await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Confirm attachment in database
await confirmAttachment.mutateAsync({
cardPublicId,
s3Key: key,
filename: file.name,
originalFilename: file.name,
contentType: file.type,
size: file.size,
});
} catch { } catch {
showPopup({ showPopup({
header: t`Upload failed`, header: t`Upload failed`,

View File

@@ -1,7 +1,6 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { import {
HiEllipsisHorizontal, HiEllipsisHorizontal,
HiLink,
HiOutlineCheckCircle, HiOutlineCheckCircle,
HiOutlineTrash, HiOutlineTrash,
} from "react-icons/hi2"; } from "react-icons/hi2";
@@ -11,54 +10,18 @@ import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
export default function CardDropdown({ export default function CardDropdown({
cardPublicId,
isTemplate,
boardPublicId,
cardCreatedBy, cardCreatedBy,
}: { }: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null; cardCreatedBy?: string | null;
}) { }) {
const { openModal } = useModal(); const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditCard, canDeleteCard } = usePermissions(); const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy; const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
const handleCopyCardLink = async () => {
const path =
isTemplate && boardPublicId
? `/templates/${boardPublicId}/cards/${cardPublicId}`
: `/cards/${cardPublicId}`;
const url = `${window.location.origin}${path}`;
try {
await navigator.clipboard.writeText(url);
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Card URL copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const items = [ const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(canEditCard ...(canEditCard
? [ ? [
{ {

View File

@@ -5,8 +5,6 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5"; import { IoChevronForwardSharp } from "react-icons/io5";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -16,6 +14,8 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal"; import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { authClient } from "@kan/auth/client";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -317,12 +317,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</Link> </Link>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Dropdown <Dropdown cardCreatedBy={card?.createdBy} />
cardPublicId={cardId}
isTemplate={isTemplate}
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
/>
</div> </div>
</> </>
)} )}
@@ -379,14 +374,8 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div className="mt-2"> <div className="mt-2">
<Editor <Editor
content={card.description} content={card.description}
onChange={ onChange={canEdit ? (e) => setValue("description", e) : undefined}
canEdit onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
? (e) => setValue("description", e)
: undefined
}
onBlur={
canEdit ? () => handleSubmit(onSubmit)() : undefined
}
workspaceMembers={board?.workspace.members ?? []} workspaceMembers={board?.workspace.members ?? []}
readOnly={!canEdit} readOnly={!canEdit}
/> />

View File

@@ -126,6 +126,7 @@ export default function MembersPage() {
name={memberName ?? ""} name={memberName ?? ""}
email={memberEmail ?? ""} email={memberEmail ?? ""}
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined} imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
icon={showPendingIcon ? "?" : undefined}
/> />
)} )}
</div> </div>

View File

@@ -1,13 +1,12 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { HiLink, HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import Badge from "~/components/Badge"; import Badge from "~/components/Badge";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList"; import ActivityList from "~/views/card/components/ActivityList";
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails"; import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
@@ -24,29 +23,10 @@ export function CardModal({
}) { }) {
const router = useRouter(); const router = useRouter();
const { closeModal, isOpen } = useModal(); const { closeModal, isOpen } = useModal();
const { showPopup } = usePopup();
const [showFade, setShowFade] = useState(false); const [showFade, setShowFade] = useState(false);
const [showTopFade, setShowTopFade] = useState(false); const [showTopFade, setShowTopFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const handleCopyCardLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Card URL copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const { data, isLoading } = api.card.byId.useQuery( const { data, isLoading } = api.card.byId.useQuery(
{ {
cardPublicId: cardPublicId ?? "", cardPublicId: cardPublicId ?? "",
@@ -85,44 +65,33 @@ export function CardModal({
<div className="h-full p-8"> <div className="h-full p-8">
<div className="mb-6"> <div className="mb-6">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1"> <button
<button className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
type="button" onClick={(e) => {
onClick={handleCopyCardLink} e.preventDefault();
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100" closeModal();
aria-label="Copy card link"
>
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" />
</button>
<button
type="button"
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
onClick={(e) => {
e.preventDefault();
closeModal();
setTimeout(() => { setTimeout(() => {
void router.replace( void router.replace(
{ {
pathname: router.pathname, pathname: router.pathname,
query: { query: {
...router.query, ...router.query,
workspaceSlug: workspaceSlug ?? "", workspaceSlug,
boardSlug: [boardSlug ?? ""], boardSlug: [boardSlug],
},
}, },
undefined, },
{ shallow: true }, undefined,
); { shallow: true },
}, 400); );
}} }, 400);
> }}
<HiXMark >
size={18} <HiXMark
className="text-light-900 dark:text-dark-900" size={18}
/> className="dark:text-dark-9000 text-light-900"
</button> />
</div> </button>
{isLoading ? ( {isLoading ? (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />

View File

@@ -30,7 +30,7 @@ export default function PublicBoardView() {
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const [isRouteLoaded, setIsRouteLoaded] = useState(false); const [isRouteLoaded, setIsRouteLoaded] = useState(false);
const { openModal } = useModal(); const { openModal } = useModal();
const { ref: scrollRef, onMouseDown } = useDragToScroll({ const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true, enabled: true,
direction: "horizontal", direction: "horizontal",
@@ -70,34 +70,29 @@ export default function PublicBoardView() {
}, },
); );
const handleCopyBoardLink = async () => { const CopyBoardLink = () => {
try { return (
await navigator.clipboard.writeText(window.location.href); <button
showPopup({ onClick={async () => {
header: t`Link copied`, try {
icon: "success", await navigator.clipboard.writeText(window.location.href);
message: t`Board URL copied to clipboard`, } catch (error) {
}); console.error(error);
} catch (error) { }
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const CopyBoardLink = () => ( showPopup({
<button header: t`Link copied`,
type="button" icon: "success",
onClick={handleCopyBoardLink} message: t`Board URL copied to clipboard`,
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100" });
aria-label="Copy board URL" }}
> className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" /> aria-label={`Copy board URL`}
</button> >
); <HiLink className={`h-4 w-4 text-light-900 dark:text-dark-900`} />
</button>
);
};
const pathWithoutQuery = router.asPath.split("?")[0]; const pathWithoutQuery = router.asPath.split("?")[0];
const splitPath = pathWithoutQuery?.split("/") ?? []; const splitPath = pathWithoutQuery?.split("/") ?? [];

View File

@@ -58,6 +58,28 @@ export default function Avatar({
const [crop, setCrop] = useState<PercentCrop>(); const [crop, setCrop] = useState<PercentCrop>();
const imgRef = useRef<HTMLImageElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(null);
const updateUser = api.user.update.useMutation({
onSuccess: async () => {
showPopup({
header: t`Profile image updated`,
message: t`Your profile image has been updated.`,
icon: "success",
});
try {
await utils.user.getUser.refetch();
} catch (e) {
console.error(e);
throw e;
}
},
onError: () => {
showPopup({
header: t`Error updating profile image`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined; const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
@@ -165,32 +187,29 @@ export default function Avatar({
const originalExt = selectedFile.name.split(".").pop() ?? "jpg"; const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`; const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
const response = await fetch( const response = await fetch(
`${baseUrl}/api/upload/avatar`, env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": blob.type, "Content-Type": "application/json",
"x-original-filename": fileName,
}, },
body: blob, body: JSON.stringify({ filename: fileName, contentType: blob.type }),
}, },
); );
if (!response.ok) { if (!response.ok) throw new Error("Failed to get pre-signed URL");
throw new Error("Failed to upload profile image");
}
// User image is updated in the backend, refresh user data const { url } = (await response.json()) as { url: string };
await utils.user.getUser.refetch();
const uploadResponse = await fetch(url, {
showPopup({ method: "PUT",
header: t`Profile image updated`, body: blob,
message: t`Your profile image has been updated.`,
icon: "success",
}); });
if (!uploadResponse.ok) throw new Error("Failed to upload profile image");
updateUser.mutate({ image: fileName });
setCropDialogOpen(false); setCropDialogOpen(false);
resetCropState(); resetCropState();
} catch (error) { } catch (error) {
@@ -208,7 +227,7 @@ export default function Avatar({
resetCropState, resetCropState,
selectedFile, selectedFile,
showPopup, showPopup,
utils.user.getUser, updateUser,
userId, userId,
]); ]);

View File

@@ -27,10 +27,6 @@
"./utils/rateLimit": { "./utils/rateLimit": {
"types": "./dist/utils/rateLimit.d.ts", "types": "./dist/utils/rateLimit.d.ts",
"default": "./src/utils/rateLimit.ts" "default": "./src/utils/rateLimit.ts"
},
"./utils/permissions": {
"types": "./dist/utils/permissions.d.ts",
"default": "./src/utils/permissions.ts"
} }
}, },
"license": "GPL-3.0", "license": "GPL-3.0",
@@ -43,6 +39,8 @@
"typecheck": "tsc --noEmit --emitDeclarationOnly false" "typecheck": "tsc --noEmit --emitDeclarationOnly false"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"@kan/auth": "workspace:*", "@kan/auth": "workspace:*",
"@kan/db": "workspace:*", "@kan/db": "workspace:*",
"@kan/email": "workspace:^", "@kan/email": "workspace:^",

View File

@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertPermission } from "../utils/permissions";
import { deleteObject, generateUploadUrl } from "@kan/shared/utils"; import { deleteObject, generateUploadUrl } from "../utils/s3";
export const attachmentRouter = createTRPCRouter({ export const attachmentRouter = createTRPCRouter({
generateUploadUrl: protectedProcedure generateUploadUrl: protectedProcedure

View File

@@ -10,7 +10,6 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { colours } from "@kan/shared/constants"; import { colours } from "@kan/shared/constants";
import { import {
convertDueDateFiltersToRanges, convertDueDateFiltersToRanges,
generateAvatarUrl,
generateSlug, generateSlug,
generateUID, generateUID,
} from "@kan/shared/utils"; } from "@kan/shared/utils";
@@ -143,40 +142,7 @@ export const boardRouter = createTRPCRouter({
}, },
); );
if (!result) { return result;
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
}
// Generate presigned URLs for workspace member avatars
const workspaceWithAvatarUrls = result.workspace
? {
...result.workspace,
members: await Promise.all(
result.workspace.members.map(async (member) => {
if (!member.user?.image) {
return member;
}
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
),
}
: result.workspace;
return {
...result,
workspace: workspaceWithAvatarUrls,
};
}), }),
bySlug: publicProcedure bySlug: publicProcedure
.meta({ .meta({

View File

@@ -11,7 +11,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities"; import { mergeActivities } from "../utils/activities";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils"; import { generateDownloadUrl } from "../utils/s3";
export const cardRouter = createTRPCRouter({ export const cardRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -631,54 +631,45 @@ export const cardRouter = createTRPCRouter({
}); });
// Generate URLs for all attachments // Generate URLs for all attachments
const attachmentsWithUrls = await Promise.all( const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
result.attachments.map(async (attachment) => { if (result.attachments && Array.isArray(result.attachments)) {
const url = await generateAttachmentUrl(attachment.s3Key); const attachments = result.attachments as {
return { publicId: string;
publicId: attachment.publicId, contentType: string;
contentType: attachment.contentType, s3Key: string;
s3Key: attachment.s3Key, originalFilename: string | null;
originalFilename: attachment.originalFilename, size?: number | null;
size: attachment.size, }[];
url,
};
}),
);
// Generate presigned URLs for workspace member avatars const attachmentsWithUrls = await Promise.all(
const workspaceWithAvatarUrls = result.list.board.workspace attachments.map(async (attachment) => {
? { const base = {
...result.list.board.workspace, publicId: attachment.publicId,
members: await Promise.all( contentType: attachment.contentType,
result.list.board.workspace.members.map(async (member) => { s3Key: attachment.s3Key,
if (!member.user?.image) { originalFilename: attachment.originalFilename,
return member; size: attachment.size,
} };
if (!bucket || !attachment.s3Key) {
return { ...base, url: null };
}
try {
const url = await generateDownloadUrl(
bucket,
attachment.s3Key,
86400, // 24 hours expiration
);
return { ...base, url };
} catch {
// If URL generation fails, return attachment with url: null
return { ...base, url: null };
}
}),
);
return { ...result, attachments: attachmentsWithUrls };
}
const avatarUrl = await generateAvatarUrl(member.user.image); return { ...result, attachments: [] };
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
),
}
: result.list.board.workspace;
return {
...result,
attachments: attachmentsWithUrls,
list: {
...result.list,
board: {
...result.list.board,
workspace: workspaceWithAvatarUrls,
},
},
};
}), }),
getActivities: publicProcedure getActivities: publicProcedure
.meta({ .meta({
@@ -747,39 +738,7 @@ export const cardRouter = createTRPCRouter({
}, },
); );
// Generate presigned URLs for user avatars in activities const mergedActivities = mergeActivities(result.activities);
const activitiesWithAvatarUrls = await Promise.all(
result.activities.map(async (activity) => {
const updatedActivity = { ...activity };
// Generate presigned URL for activity user avatar
if (activity.user?.image) {
const userAvatarUrl = await generateAvatarUrl(activity.user.image);
updatedActivity.user = {
...activity.user,
image: userAvatarUrl,
};
}
// Generate presigned URL for member user avatar (if exists)
if (activity.member?.user?.image) {
const memberAvatarUrl = await generateAvatarUrl(
activity.member.user.image,
);
updatedActivity.member = {
...activity.member,
user: {
...activity.member.user,
image: memberAvatarUrl,
},
};
}
return updatedActivity;
}),
);
const mergedActivities = mergeActivities(activitiesWithAvatarUrls);
return { return {
activities: mergedActivities, activities: mergedActivities,

View File

@@ -24,7 +24,7 @@ import {
createTRPCRouter, createTRPCRouter,
publicProcedure, publicProcedure,
} from "../trpc"; } from "../trpc";
import { createS3Client } from "@kan/shared/utils"; import { createS3Client } from "../utils/s3";
const checkDatabaseConnection = async (db: dbClient) => { const checkDatabaseConnection = async (db: dbClient) => {
try { try {

View File

@@ -4,7 +4,6 @@ import { z } from "zod";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { generateAvatarUrl } from "@kan/shared/utils";
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
getUser: protectedProcedure getUser: protectedProcedure
@@ -56,12 +55,8 @@ export const userRouter = createTRPCRouter({
const apiKey = result.apiKeys[0]; const apiKey = result.apiKeys[0];
// Generate presigned URL for avatar
const imageUrl = await generateAvatarUrl(result.image);
return { return {
...result, ...result,
image: imageUrl,
apiKey: apiKey ?? null, apiKey: apiKey ?? null,
}; };
}), }),
@@ -107,12 +102,6 @@ export const userRouter = createTRPCRouter({
}); });
} }
// Generate presigned URL for avatar return result;
const imageUrl = await generateAvatarUrl(result.image);
return {
...result,
image: imageUrl,
};
}), }),
}); });

View File

@@ -8,7 +8,6 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertPermission } from "../utils/permissions";
import { generateAvatarUrl } from "@kan/shared/utils";
export const workspaceRouter = createTRPCRouter({ export const workspaceRouter = createTRPCRouter({
all: protectedProcedure all: protectedProcedure
@@ -87,27 +86,9 @@ export const workspaceRouter = createTRPCRouter({
const shouldShowEmails = const shouldShowEmails =
isAdmin || result.showEmailsToMembers === true; isAdmin || result.showEmailsToMembers === true;
// Generate presigned URLs for member avatars
const membersWithAvatarUrls = await Promise.all(
result.members.map(async (member) => {
if (!member.user?.image) {
return member;
}
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
);
// If emails should be hidden, filter them out // If emails should be hidden, filter them out
if (!shouldShowEmails) { if (!shouldShowEmails) {
const sanitizedMembers = membersWithAvatarUrls.map((member) => { const sanitizedMembers = result.members.map((member) => {
// If user doesn't have a display name, use anonymous identifier // If user doesn't have a display name, use anonymous identifier
const displayName = const displayName =
member.user?.name?.trim() ?? `anonymous_${member.publicId}`; member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
@@ -139,10 +120,7 @@ export const workspaceRouter = createTRPCRouter({
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>; } as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
} }
return { return result;
...result,
members: membersWithAvatarUrls,
};
}), }),
bySlug: publicProcedure bySlug: publicProcedure
.meta({ .meta({

View File

@@ -5,7 +5,6 @@ import {
S3Client, S3Client,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "next-runtime-env";
export function createS3Client() { export function createS3Client() {
const credentials = const credentials =
@@ -68,60 +67,3 @@ export async function deleteObject(bucket: string, key: string) {
}), }),
); );
} }
/**
* Generate presigned URL for an avatar image
* Returns the URL as-is if it's already a full URL (external provider)
* Returns presigned URL if it's an S3 key
* Returns null if image key is missing, bucket is not configured, or URL generation fails
*/
export async function generateAvatarUrl(
imageKey: string | null | undefined,
expiresIn = 86400, // 24 hours
): Promise<string | null> {
if (!imageKey) {
return null;
}
if (imageKey.startsWith("http://") || imageKey.startsWith("https://")) {
return imageKey;
}
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
if (!bucket) {
return null;
}
try {
return await generateDownloadUrl(bucket, imageKey, expiresIn);
} catch {
// If URL generation fails, return null
return null;
}
}
/**
* Generate presigned URL for an attachment
* Returns null if attachment key is missing, bucket is not configured, or URL generation fails
*/
export async function generateAttachmentUrl(
attachmentKey: string | null | undefined,
expiresIn = 86400, // 24 hours
): Promise<string | null> {
if (!attachmentKey) {
return null;
}
const bucket = env("NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME");
if (!bucket) {
return null;
}
try {
return await generateDownloadUrl(bucket, attachmentKey, expiresIn);
} catch {
// If URL generation fails, return null
return null;
}
}

View File

@@ -1,4 +1,4 @@
import { PutObjectCommand } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { ChatOrPushProviderEnum } from "@novu/api/models/components"; import { ChatOrPushProviderEnum } from "@novu/api/models/components";
import { createAuthMiddleware } from "better-auth/api"; import { createAuthMiddleware } from "better-auth/api";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
@@ -7,7 +7,7 @@ import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import { notificationClient } from "@kan/email"; import { notificationClient } from "@kan/email";
import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared"; import { createEmailUnsubscribeLink } from "@kan/shared";
import { downloadImage } from "./utils"; import { downloadImage } from "./utils";
@@ -61,7 +61,20 @@ export function createDatabaseHooks(db: dbClient) {
!user.image.includes(storageDomain) !user.image.includes(storageDomain)
) { ) {
try { try {
const client = createS3Client(); const credentials =
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
? {
accessKeyId: env("S3_ACCESS_KEY_ID")!,
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
}
: undefined;
const client = new S3Client({
region: env("S3_REGION") ?? "",
endpoint: env("S3_ENDPOINT") ?? "",
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
credentials,
});
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"]; const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];

View File

@@ -36,8 +36,6 @@
}, },
"prettier": "@kan/prettier-config", "prettier": "@kan/prettier-config",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jose": "^6.1.2", "jose": "^6.1.2",
"nanoid": "^5.0.9", "nanoid": "^5.0.9",

View File

@@ -3,4 +3,3 @@ export * from "./generateSlug";
export * from "./subscriptions"; export * from "./subscriptions";
export * from "./email"; export * from "./email";
export * from "./dueDateFilters"; export * from "./dueDateFilters";
export * from "./s3";

326
pnpm-lock.yaml generated
View File

@@ -82,9 +82,6 @@ importers:
'@aws-sdk/client-s3': '@aws-sdk/client-s3':
specifier: ^3.802.0 specifier: ^3.802.0
version: 3.879.0 version: 3.879.0
'@aws-sdk/lib-storage':
specifier: ^3.802.0
version: 3.985.0(@aws-sdk/client-s3@3.879.0)
'@aws-sdk/s3-request-presigner': '@aws-sdk/s3-request-presigner':
specifier: ^3.812.0 specifier: ^3.812.0
version: 3.879.0 version: 3.879.0
@@ -139,9 +136,6 @@ importers:
'@tiptap/extension-placeholder': '@tiptap/extension-placeholder':
specifier: ^2.14.0 specifier: ^2.14.0
version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1) version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/extension-typography':
specifier: ^3.18.0
version: 3.18.0(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))
'@tiptap/pm': '@tiptap/pm':
specifier: ^2.14.0 specifier: ^2.14.0
version: 2.26.1 version: 2.26.1
@@ -299,9 +293,12 @@ importers:
packages/api: packages/api:
dependencies: dependencies:
'@aws-sdk/lib-storage': '@aws-sdk/client-s3':
specifier: ^3.802.0 specifier: ^3.802.0
version: 3.985.0(@aws-sdk/client-s3@3.879.0) version: 3.879.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.812.0
version: 3.879.0
'@kan/auth': '@kan/auth':
specifier: workspace:* specifier: workspace:*
version: link:../auth version: link:../auth
@@ -486,12 +483,6 @@ importers:
packages/shared: packages/shared:
dependencies: dependencies:
'@aws-sdk/client-s3':
specifier: ^3.802.0
version: 3.879.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.812.0
version: 3.879.0
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@@ -743,12 +734,6 @@ packages:
resolution: {integrity: sha512-Jy4uPFfGzHk1Mxy+/Wr43vuw9yXsE2yiF4e4598vc3aJfO0YtA2nSfbKD3PNKRORwXbeKqWPfph9SCKQpWoxEg==} resolution: {integrity: sha512-Jy4uPFfGzHk1Mxy+/Wr43vuw9yXsE2yiF4e4598vc3aJfO0YtA2nSfbKD3PNKRORwXbeKqWPfph9SCKQpWoxEg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@aws-sdk/lib-storage@3.985.0':
resolution: {integrity: sha512-EnqXf2E+5+7e5zuz8mPl+3Dk/DI0ObSL8s7Tsf6H8FL/p2BJw2LhgtxPnFZUmpanpoMO0hC9/qjXNd+aGPV0/w==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@aws-sdk/client-s3': ^3.985.0
'@aws-sdk/middleware-bucket-endpoint@3.873.0': '@aws-sdk/middleware-bucket-endpoint@3.873.0':
resolution: {integrity: sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==} resolution: {integrity: sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3315,10 +3300,6 @@ packages:
resolution: {integrity: sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==} resolution: {integrity: sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/abort-controller@4.2.8':
resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==}
engines: {node: '>=18.0.0'}
'@smithy/chunked-blob-reader-native@4.0.0': '@smithy/chunked-blob-reader-native@4.0.0':
resolution: {integrity: sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==} resolution: {integrity: sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3331,10 +3312,6 @@ packages:
resolution: {integrity: sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==} resolution: {integrity: sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/core@3.22.1':
resolution: {integrity: sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==}
engines: {node: '>=18.0.0'}
'@smithy/core@3.9.0': '@smithy/core@3.9.0':
resolution: {integrity: sha512-B/GknvCfS3llXd/b++hcrwIuqnEozQDnRL4sBmOac5/z/dr0/yG1PURNPOyU4Lsiy1IyTj8scPxVqRs5dYWf6A==} resolution: {integrity: sha512-B/GknvCfS3llXd/b++hcrwIuqnEozQDnRL4sBmOac5/z/dr0/yG1PURNPOyU4Lsiy1IyTj8scPxVqRs5dYWf6A==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3367,10 +3344,6 @@ packages:
resolution: {integrity: sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==} resolution: {integrity: sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.3.9':
resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==}
engines: {node: '>=18.0.0'}
'@smithy/hash-blob-browser@4.0.5': '@smithy/hash-blob-browser@4.0.5':
resolution: {integrity: sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==} resolution: {integrity: sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3395,10 +3368,6 @@ packages:
resolution: {integrity: sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==} resolution: {integrity: sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/is-array-buffer@4.2.0':
resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==}
engines: {node: '>=18.0.0'}
'@smithy/md5-js@4.0.5': '@smithy/md5-js@4.0.5':
resolution: {integrity: sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==} resolution: {integrity: sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3411,10 +3380,6 @@ packages:
resolution: {integrity: sha512-EAlEPncqo03siNZJ9Tm6adKCQ+sw5fNU8ncxWwaH0zTCwMPsgmERTi6CEKaermZdgJb+4Yvh0NFm36HeO4PGgQ==} resolution: {integrity: sha512-EAlEPncqo03siNZJ9Tm6adKCQ+sw5fNU8ncxWwaH0zTCwMPsgmERTi6CEKaermZdgJb+4Yvh0NFm36HeO4PGgQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/middleware-endpoint@4.4.13':
resolution: {integrity: sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-retry@4.1.20': '@smithy/middleware-retry@4.1.20':
resolution: {integrity: sha512-T3maNEm3Masae99eFdx1Q7PIqBBEVOvRd5hralqKZNeIivnoGNx5OFtI3DiZ5gCjUkl0mNondlzSXeVxkinh7Q==} resolution: {integrity: sha512-T3maNEm3Masae99eFdx1Q7PIqBBEVOvRd5hralqKZNeIivnoGNx5OFtI3DiZ5gCjUkl0mNondlzSXeVxkinh7Q==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3423,66 +3388,34 @@ packages:
resolution: {integrity: sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==} resolution: {integrity: sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/middleware-serde@4.2.9':
resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-stack@4.0.5': '@smithy/middleware-stack@4.0.5':
resolution: {integrity: sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==} resolution: {integrity: sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/middleware-stack@4.2.8':
resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==}
engines: {node: '>=18.0.0'}
'@smithy/node-config-provider@4.1.4': '@smithy/node-config-provider@4.1.4':
resolution: {integrity: sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==} resolution: {integrity: sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/node-config-provider@4.3.8':
resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.1.1': '@smithy/node-http-handler@4.1.1':
resolution: {integrity: sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==} resolution: {integrity: sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.4.9':
resolution: {integrity: sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==}
engines: {node: '>=18.0.0'}
'@smithy/property-provider@4.0.5': '@smithy/property-provider@4.0.5':
resolution: {integrity: sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==} resolution: {integrity: sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/property-provider@4.2.8':
resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==}
engines: {node: '>=18.0.0'}
'@smithy/protocol-http@5.1.3': '@smithy/protocol-http@5.1.3':
resolution: {integrity: sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==} resolution: {integrity: sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/protocol-http@5.3.8':
resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==}
engines: {node: '>=18.0.0'}
'@smithy/querystring-builder@4.0.5': '@smithy/querystring-builder@4.0.5':
resolution: {integrity: sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==} resolution: {integrity: sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/querystring-builder@4.2.8':
resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==}
engines: {node: '>=18.0.0'}
'@smithy/querystring-parser@4.0.5': '@smithy/querystring-parser@4.0.5':
resolution: {integrity: sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==} resolution: {integrity: sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/querystring-parser@4.2.8':
resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==}
engines: {node: '>=18.0.0'}
'@smithy/service-error-classification@4.0.7': '@smithy/service-error-classification@4.0.7':
resolution: {integrity: sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==} resolution: {integrity: sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3491,26 +3424,14 @@ packages:
resolution: {integrity: sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==} resolution: {integrity: sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/shared-ini-file-loader@4.4.3':
resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.1.3': '@smithy/signature-v4@5.1.3':
resolution: {integrity: sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==} resolution: {integrity: sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/smithy-client@4.11.2':
resolution: {integrity: sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A==}
engines: {node: '>=18.0.0'}
'@smithy/smithy-client@4.5.0': '@smithy/smithy-client@4.5.0':
resolution: {integrity: sha512-ZSdE3vl0MuVbEwJBxSftm0J5nL/gw76xp5WF13zW9cN18MFuFXD5/LV0QD8P+sCU5bSWGyy6CTgUupE1HhOo1A==} resolution: {integrity: sha512-ZSdE3vl0MuVbEwJBxSftm0J5nL/gw76xp5WF13zW9cN18MFuFXD5/LV0QD8P+sCU5bSWGyy6CTgUupE1HhOo1A==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/types@4.12.0':
resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.3.2': '@smithy/types@4.3.2':
resolution: {integrity: sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==} resolution: {integrity: sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3519,26 +3440,14 @@ packages:
resolution: {integrity: sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==} resolution: {integrity: sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/url-parser@4.2.8':
resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==}
engines: {node: '>=18.0.0'}
'@smithy/util-base64@4.0.0': '@smithy/util-base64@4.0.0':
resolution: {integrity: sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==} resolution: {integrity: sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/util-base64@4.3.0':
resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==}
engines: {node: '>=18.0.0'}
'@smithy/util-body-length-browser@4.0.0': '@smithy/util-body-length-browser@4.0.0':
resolution: {integrity: sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==} resolution: {integrity: sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/util-body-length-browser@4.2.0':
resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==}
engines: {node: '>=18.0.0'}
'@smithy/util-body-length-node@4.0.0': '@smithy/util-body-length-node@4.0.0':
resolution: {integrity: sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==} resolution: {integrity: sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3551,10 +3460,6 @@ packages:
resolution: {integrity: sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==} resolution: {integrity: sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/util-buffer-from@4.2.0':
resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==}
engines: {node: '>=18.0.0'}
'@smithy/util-config-provider@4.0.0': '@smithy/util-config-provider@4.0.0':
resolution: {integrity: sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==} resolution: {integrity: sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3575,18 +3480,10 @@ packages:
resolution: {integrity: sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==} resolution: {integrity: sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/util-hex-encoding@4.2.0':
resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==}
engines: {node: '>=18.0.0'}
'@smithy/util-middleware@4.0.5': '@smithy/util-middleware@4.0.5':
resolution: {integrity: sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==} resolution: {integrity: sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/util-middleware@4.2.8':
resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==}
engines: {node: '>=18.0.0'}
'@smithy/util-retry@4.0.7': '@smithy/util-retry@4.0.7':
resolution: {integrity: sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==} resolution: {integrity: sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3595,18 +3492,10 @@ packages:
resolution: {integrity: sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==} resolution: {integrity: sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/util-stream@4.5.11':
resolution: {integrity: sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA==}
engines: {node: '>=18.0.0'}
'@smithy/util-uri-escape@4.0.0': '@smithy/util-uri-escape@4.0.0':
resolution: {integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==} resolution: {integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/util-uri-escape@4.2.0':
resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==}
engines: {node: '>=18.0.0'}
'@smithy/util-utf8@2.3.0': '@smithy/util-utf8@2.3.0':
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -3615,18 +3504,10 @@ packages:
resolution: {integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==} resolution: {integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/util-utf8@4.2.0':
resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==}
engines: {node: '>=18.0.0'}
'@smithy/util-waiter@4.0.7': '@smithy/util-waiter@4.0.7':
resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==} resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/uuid@1.1.0':
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
engines: {node: '>=18.0.0'}
'@socket.io/component-emitter@3.1.2': '@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
@@ -3898,11 +3779,6 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@tiptap/core': ^2.7.0
'@tiptap/extension-typography@3.18.0':
resolution: {integrity: sha512-zTNGJjhJG3lObUhhTbDC1IOyi1DCiCd6i11xsnJDPy5BODYc7t7ZP6VMOWU0LIuMsK9kX02dXoNU7OarJgLpCg==}
peerDependencies:
'@tiptap/core': ^3.18.0
'@tiptap/pm@2.26.1': '@tiptap/pm@2.26.1':
resolution: {integrity: sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==} resolution: {integrity: sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==}
@@ -4602,9 +4478,6 @@ packages:
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@5.6.0:
resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==}
buffer@5.7.1: buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@@ -7879,9 +7752,6 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
stream-browserify@3.0.0:
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -9024,17 +8894,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@aws-sdk/lib-storage@3.985.0(@aws-sdk/client-s3@3.879.0)':
dependencies:
'@aws-sdk/client-s3': 3.879.0
'@smithy/abort-controller': 4.2.8
'@smithy/middleware-endpoint': 4.4.13
'@smithy/smithy-client': 4.11.2
buffer: 5.6.0
events: 3.3.0
stream-browserify: 3.0.0
tslib: 2.8.1
'@aws-sdk/middleware-bucket-endpoint@3.873.0': '@aws-sdk/middleware-bucket-endpoint@3.873.0':
dependencies: dependencies:
'@aws-sdk/types': 3.862.0 '@aws-sdk/types': 3.862.0
@@ -11469,11 +11328,6 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/abort-controller@4.2.8':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/chunked-blob-reader-native@4.0.0': '@smithy/chunked-blob-reader-native@4.0.0':
dependencies: dependencies:
'@smithy/util-base64': 4.0.0 '@smithy/util-base64': 4.0.0
@@ -11491,19 +11345,6 @@ snapshots:
'@smithy/util-middleware': 4.0.5 '@smithy/util-middleware': 4.0.5
tslib: 2.8.1 tslib: 2.8.1
'@smithy/core@3.22.1':
dependencies:
'@smithy/middleware-serde': 4.2.9
'@smithy/protocol-http': 5.3.8
'@smithy/types': 4.12.0
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-middleware': 4.2.8
'@smithy/util-stream': 4.5.11
'@smithy/util-utf8': 4.2.0
'@smithy/uuid': 1.1.0
tslib: 2.8.1
'@smithy/core@3.9.0': '@smithy/core@3.9.0':
dependencies: dependencies:
'@smithy/middleware-serde': 4.0.9 '@smithy/middleware-serde': 4.0.9
@@ -11564,14 +11405,6 @@ snapshots:
'@smithy/util-base64': 4.0.0 '@smithy/util-base64': 4.0.0
tslib: 2.8.1 tslib: 2.8.1
'@smithy/fetch-http-handler@5.3.9':
dependencies:
'@smithy/protocol-http': 5.3.8
'@smithy/querystring-builder': 4.2.8
'@smithy/types': 4.12.0
'@smithy/util-base64': 4.3.0
tslib: 2.8.1
'@smithy/hash-blob-browser@4.0.5': '@smithy/hash-blob-browser@4.0.5':
dependencies: dependencies:
'@smithy/chunked-blob-reader': 5.0.0 '@smithy/chunked-blob-reader': 5.0.0
@@ -11605,10 +11438,6 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@smithy/is-array-buffer@4.2.0':
dependencies:
tslib: 2.8.1
'@smithy/md5-js@4.0.5': '@smithy/md5-js@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
@@ -11632,17 +11461,6 @@ snapshots:
'@smithy/util-middleware': 4.0.5 '@smithy/util-middleware': 4.0.5
tslib: 2.8.1 tslib: 2.8.1
'@smithy/middleware-endpoint@4.4.13':
dependencies:
'@smithy/core': 3.22.1
'@smithy/middleware-serde': 4.2.9
'@smithy/node-config-provider': 4.3.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
'@smithy/url-parser': 4.2.8
'@smithy/util-middleware': 4.2.8
tslib: 2.8.1
'@smithy/middleware-retry@4.1.20': '@smithy/middleware-retry@4.1.20':
dependencies: dependencies:
'@smithy/node-config-provider': 4.1.4 '@smithy/node-config-provider': 4.1.4
@@ -11662,22 +11480,11 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/middleware-serde@4.2.9':
dependencies:
'@smithy/protocol-http': 5.3.8
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/middleware-stack@4.0.5': '@smithy/middleware-stack@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/middleware-stack@4.2.8':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/node-config-provider@4.1.4': '@smithy/node-config-provider@4.1.4':
dependencies: dependencies:
'@smithy/property-provider': 4.0.5 '@smithy/property-provider': 4.0.5
@@ -11685,13 +11492,6 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/node-config-provider@4.3.8':
dependencies:
'@smithy/property-provider': 4.2.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/node-http-handler@4.1.1': '@smithy/node-http-handler@4.1.1':
dependencies: dependencies:
'@smithy/abort-controller': 4.0.5 '@smithy/abort-controller': 4.0.5
@@ -11700,56 +11500,27 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/node-http-handler@4.4.9':
dependencies:
'@smithy/abort-controller': 4.2.8
'@smithy/protocol-http': 5.3.8
'@smithy/querystring-builder': 4.2.8
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/property-provider@4.0.5': '@smithy/property-provider@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/property-provider@4.2.8':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/protocol-http@5.1.3': '@smithy/protocol-http@5.1.3':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/protocol-http@5.3.8':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/querystring-builder@4.0.5': '@smithy/querystring-builder@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
'@smithy/util-uri-escape': 4.0.0 '@smithy/util-uri-escape': 4.0.0
tslib: 2.8.1 tslib: 2.8.1
'@smithy/querystring-builder@4.2.8':
dependencies:
'@smithy/types': 4.12.0
'@smithy/util-uri-escape': 4.2.0
tslib: 2.8.1
'@smithy/querystring-parser@4.0.5': '@smithy/querystring-parser@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/querystring-parser@4.2.8':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/service-error-classification@4.0.7': '@smithy/service-error-classification@4.0.7':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
@@ -11759,11 +11530,6 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/shared-ini-file-loader@4.4.3':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/signature-v4@5.1.3': '@smithy/signature-v4@5.1.3':
dependencies: dependencies:
'@smithy/is-array-buffer': 4.0.0 '@smithy/is-array-buffer': 4.0.0
@@ -11775,16 +11541,6 @@ snapshots:
'@smithy/util-utf8': 4.0.0 '@smithy/util-utf8': 4.0.0
tslib: 2.8.1 tslib: 2.8.1
'@smithy/smithy-client@4.11.2':
dependencies:
'@smithy/core': 3.22.1
'@smithy/middleware-endpoint': 4.4.13
'@smithy/middleware-stack': 4.2.8
'@smithy/protocol-http': 5.3.8
'@smithy/types': 4.12.0
'@smithy/util-stream': 4.5.11
tslib: 2.8.1
'@smithy/smithy-client@4.5.0': '@smithy/smithy-client@4.5.0':
dependencies: dependencies:
'@smithy/core': 3.9.0 '@smithy/core': 3.9.0
@@ -11795,10 +11551,6 @@ snapshots:
'@smithy/util-stream': 4.2.4 '@smithy/util-stream': 4.2.4
tslib: 2.8.1 tslib: 2.8.1
'@smithy/types@4.12.0':
dependencies:
tslib: 2.8.1
'@smithy/types@4.3.2': '@smithy/types@4.3.2':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -11809,32 +11561,16 @@ snapshots:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/url-parser@4.2.8':
dependencies:
'@smithy/querystring-parser': 4.2.8
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/util-base64@4.0.0': '@smithy/util-base64@4.0.0':
dependencies: dependencies:
'@smithy/util-buffer-from': 4.0.0 '@smithy/util-buffer-from': 4.0.0
'@smithy/util-utf8': 4.0.0 '@smithy/util-utf8': 4.0.0
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-base64@4.3.0':
dependencies:
'@smithy/util-buffer-from': 4.2.0
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
'@smithy/util-body-length-browser@4.0.0': '@smithy/util-body-length-browser@4.0.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-body-length-browser@4.2.0':
dependencies:
tslib: 2.8.1
'@smithy/util-body-length-node@4.0.0': '@smithy/util-body-length-node@4.0.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -11849,11 +11585,6 @@ snapshots:
'@smithy/is-array-buffer': 4.0.0 '@smithy/is-array-buffer': 4.0.0
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-buffer-from@4.2.0':
dependencies:
'@smithy/is-array-buffer': 4.2.0
tslib: 2.8.1
'@smithy/util-config-provider@4.0.0': '@smithy/util-config-provider@4.0.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -11886,20 +11617,11 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-hex-encoding@4.2.0':
dependencies:
tslib: 2.8.1
'@smithy/util-middleware@4.0.5': '@smithy/util-middleware@4.0.5':
dependencies: dependencies:
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-middleware@4.2.8':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/util-retry@4.0.7': '@smithy/util-retry@4.0.7':
dependencies: dependencies:
'@smithy/service-error-classification': 4.0.7 '@smithy/service-error-classification': 4.0.7
@@ -11917,25 +11639,10 @@ snapshots:
'@smithy/util-utf8': 4.0.0 '@smithy/util-utf8': 4.0.0
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-stream@4.5.11':
dependencies:
'@smithy/fetch-http-handler': 5.3.9
'@smithy/node-http-handler': 4.4.9
'@smithy/types': 4.12.0
'@smithy/util-base64': 4.3.0
'@smithy/util-buffer-from': 4.2.0
'@smithy/util-hex-encoding': 4.2.0
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
'@smithy/util-uri-escape@4.0.0': '@smithy/util-uri-escape@4.0.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-uri-escape@4.2.0':
dependencies:
tslib: 2.8.1
'@smithy/util-utf8@2.3.0': '@smithy/util-utf8@2.3.0':
dependencies: dependencies:
'@smithy/util-buffer-from': 2.2.0 '@smithy/util-buffer-from': 2.2.0
@@ -11946,21 +11653,12 @@ snapshots:
'@smithy/util-buffer-from': 4.0.0 '@smithy/util-buffer-from': 4.0.0
tslib: 2.8.1 tslib: 2.8.1
'@smithy/util-utf8@4.2.0':
dependencies:
'@smithy/util-buffer-from': 4.2.0
tslib: 2.8.1
'@smithy/util-waiter@4.0.7': '@smithy/util-waiter@4.0.7':
dependencies: dependencies:
'@smithy/abort-controller': 4.0.5 '@smithy/abort-controller': 4.0.5
'@smithy/types': 4.3.2 '@smithy/types': 4.3.2
tslib: 2.8.1 tslib: 2.8.1
'@smithy/uuid@1.1.0':
dependencies:
tslib: 2.8.1
'@socket.io/component-emitter@3.1.2': {} '@socket.io/component-emitter@3.1.2': {}
'@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.0.0': {}
@@ -12221,10 +11919,6 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1)
'@tiptap/extension-typography@3.18.0(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))':
dependencies:
'@tiptap/core': 2.26.1(@tiptap/pm@2.26.1)
'@tiptap/pm@2.26.1': '@tiptap/pm@2.26.1':
dependencies: dependencies:
prosemirror-changeset: 2.3.1 prosemirror-changeset: 2.3.1
@@ -13084,11 +12778,6 @@ snapshots:
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer@5.6.0:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@5.7.1: buffer@5.7.1:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
@@ -17209,11 +16898,6 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
internal-slot: 1.1.0 internal-slot: 1.1.0
stream-browserify@3.0.0:
dependencies:
inherits: 2.0.4
readable-stream: 3.6.2
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0