Compare commits
2 Commits
docs/railw
...
fix/activi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53d6ebb200 | ||
|
|
957d3d3c09 |
12
README.md
12
README.md
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const config = {
|
|||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'cdn.discordapp.com',
|
hostname: 'cdn.discordapp.com',
|
||||||
|
pathname: '/avatars/**',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -94,6 +95,12 @@ const config = {
|
|||||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
api: {
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -55,10 +55,9 @@ export default function SideNavigation({
|
|||||||
const [isInitialised, setIsInitialised] = useState(false);
|
const [isInitialised, setIsInitialised] = useState(false);
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||||
{ workspacePublicId: workspace.publicId },
|
workspacePublicId: workspace.publicId,
|
||||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const subscriptions = workspaceData?.subscriptions as
|
const subscriptions = workspaceData?.subscriptions as
|
||||||
| Subscription[]
|
| Subscription[]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Upload the file to S3
|
|
||||||
await client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: bucket,
|
|
||||||
Key: s3Key,
|
|
||||||
Body: req,
|
|
||||||
ContentType: contentType,
|
|
||||||
ContentLength: contentLength,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -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" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
75
apps/web/src/pages/api/upload/image.ts
Normal file
75
apps/web/src/pages/api/upload/image.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogBackdrop,
|
DialogBackdrop,
|
||||||
@@ -465,37 +464,19 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
|||||||
? stroke.modifiers.map(stringifyModifier)
|
? stroke.modifiers.map(stringifyModifier)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
modifierStrings.forEach((mod, index) => {
|
modifierStrings.forEach((mod) => {
|
||||||
parts.push(
|
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
|
||||||
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
|
|
||||||
{mod}
|
|
||||||
</kbd>,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
parts.push(
|
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
|
||||||
<kbd key={`key-${stroke.key}`} className={kbdClassName}>
|
|
||||||
{stroke.key.toUpperCase()}
|
|
||||||
</kbd>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shortcut.type === "SEQUENCE") {
|
if (shortcut.type === "SEQUENCE") {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
shortcut.strokes.forEach((stroke, strokeIndex) => {
|
shortcut.strokes.forEach((stroke) => {
|
||||||
const strokeParts = formatStroke(stroke);
|
parts.push(...formatStroke(stroke));
|
||||||
// Add stroke index to keys to ensure uniqueness across multiple strokes
|
|
||||||
const keyedParts = strokeParts.map((part, partIndex) => {
|
|
||||||
if (React.isValidElement(part)) {
|
|
||||||
return React.cloneElement(part, {
|
|
||||||
key: `stroke-${strokeIndex}-${part.key || partIndex}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return part;
|
|
||||||
});
|
|
||||||
parts.push(...keyedParts);
|
|
||||||
});
|
});
|
||||||
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ export async function invalidateCard(
|
|||||||
utils: ReturnType<typeof api.useUtils>,
|
utils: ReturnType<typeof api.useUtils>,
|
||||||
cardPublicId: string,
|
cardPublicId: string,
|
||||||
) {
|
) {
|
||||||
if (!cardPublicId || cardPublicId.length < 12) return;
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
utils.card.byId.invalidate({ cardPublicId }),
|
utils.card.byId.invalidate({ cardPublicId }),
|
||||||
utils.card.getActivities.invalidate({ cardPublicId }),
|
utils.card.getActivities.invalidate({ cardPublicId }),
|
||||||
|
|||||||
@@ -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}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ const ActivityList = ({
|
|||||||
limit: ACTIVITIES_PAGE_SIZE,
|
limit: ACTIVITIES_PAGE_SIZE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!cardPublicId && cardPublicId.length >= 12,
|
enabled: !!cardPublicId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -53,10 +53,9 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? router.query.cardId[0]
|
? router.query.cardId[0]
|
||||||
: router.query.cardId;
|
: router.query.cardId;
|
||||||
|
|
||||||
const { data: card } = api.card.byId.useQuery(
|
const { data: card } = api.card.byId.useQuery({
|
||||||
{ cardPublicId: cardId ?? "" },
|
cardPublicId: cardId ?? "",
|
||||||
{ enabled: !!cardId && cardId.length >= 12 },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||||
const canEdit = canEditCard || isCreator;
|
const canEdit = canEditCard || isCreator;
|
||||||
@@ -185,10 +184,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? router.query.cardId[0]
|
? router.query.cardId[0]
|
||||||
: router.query.cardId;
|
: router.query.cardId;
|
||||||
|
|
||||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
const { data: card, isLoading } = api.card.byId.useQuery({
|
||||||
{ cardPublicId: cardId ?? "" },
|
cardPublicId: cardId ?? "",
|
||||||
{ enabled: !!cardId && cardId.length >= 12 },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||||
const canEdit = canEditCard || isCreator;
|
const canEdit = canEditCard || isCreator;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
const { data, isLoading } = api.workspace.byId.useQuery(
|
const { data, isLoading } = api.workspace.byId.useQuery(
|
||||||
{ workspacePublicId: workspace.publicId },
|
{ workspacePublicId: workspace.publicId },
|
||||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
// { enabled: workspace?.publicId ? true : false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
@@ -48,11 +48,9 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
const updateRoleMutation = api.member.updateRole.useMutation({
|
const updateRoleMutation = api.member.updateRole.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (workspace.publicId && workspace.publicId.length >= 12) {
|
await utils.workspace.byId.invalidate({
|
||||||
await utils.workspace.byId.invalidate({
|
workspacePublicId: workspace.publicId,
|
||||||
workspacePublicId: workspace.publicId,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Role updated`,
|
header: t`Role updated`,
|
||||||
@@ -126,6 +124,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>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function CardModal({
|
|||||||
cardPublicId: cardPublicId ?? "",
|
cardPublicId: cardPublicId ?? "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isOpen && !!cardPublicId && cardPublicId.length >= 12,
|
enabled: isOpen && !!cardPublicId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,9 @@ export default function PermissionsSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Refresh any relevant workspace data
|
// Refresh any relevant workspace data
|
||||||
if (workspace.publicId && workspace.publicId.length >= 12) {
|
await utils.workspace.byId.invalidate({
|
||||||
await utils.workspace.byId.invalidate({
|
workspacePublicId: workspace.publicId,
|
||||||
workspacePublicId: workspace.publicId,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showPopup({
|
showPopup({
|
||||||
|
|||||||
@@ -31,10 +31,9 @@ export default function WorkspaceSettings() {
|
|||||||
const { data } = api.user.getUser.useQuery();
|
const { data } = api.user.getUser.useQuery();
|
||||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||||
{ workspacePublicId: workspace.publicId },
|
workspacePublicId: workspace.publicId,
|
||||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const subscriptions = workspaceData?.subscriptions as
|
const subscriptions = workspaceData?.subscriptions as
|
||||||
| Subscription[]
|
| Subscription[]
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,9 @@ export default function UpdateWorkspaceEmailVisibilityForm({
|
|||||||
|
|
||||||
const updateWorkspace = api.workspace.update.useMutation({
|
const updateWorkspace = api.workspace.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (workspacePublicId && workspacePublicId.length >= 12) {
|
void utils.workspace.byId.invalidate({
|
||||||
void utils.workspace.byId.invalidate({
|
workspacePublicId,
|
||||||
workspacePublicId,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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:^",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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"];
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -293,6 +293,12 @@ importers:
|
|||||||
|
|
||||||
packages/api:
|
packages/api:
|
||||||
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
|
||||||
'@kan/auth':
|
'@kan/auth':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../auth
|
version: link:../auth
|
||||||
@@ -477,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
|
||||||
|
|||||||
Reference in New Issue
Block a user