kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: enhance file upload functionality for attachments

Andrej c76af9dc 3ef5f680

+929 -181
+26
apps/api/drizzle/0014_private_assets.sql
··· 1 + CREATE TABLE "asset" ( 2 + "id" text PRIMARY KEY NOT NULL, 3 + "workspace_id" text NOT NULL, 4 + "project_id" text NOT NULL, 5 + "task_id" text, 6 + "activity_id" text, 7 + "object_key" text NOT NULL, 8 + "filename" text NOT NULL, 9 + "mime_type" text NOT NULL, 10 + "size" integer NOT NULL, 11 + "kind" text DEFAULT 'image' NOT NULL, 12 + "surface" text DEFAULT 'description' NOT NULL, 13 + "created_by" text, 14 + "created_at" timestamp DEFAULT now() NOT NULL, 15 + CONSTRAINT "asset_object_key_unique" UNIQUE("object_key") 16 + ); 17 + --> statement-breakpoint 18 + ALTER TABLE "asset" ADD CONSTRAINT "asset_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 19 + ALTER TABLE "asset" ADD CONSTRAINT "asset_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 20 + ALTER TABLE "asset" ADD CONSTRAINT "asset_task_id_task_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."task"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 21 + ALTER TABLE "asset" ADD CONSTRAINT "asset_activity_id_activity_id_fk" FOREIGN KEY ("activity_id") REFERENCES "public"."activity"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 22 + ALTER TABLE "asset" ADD CONSTRAINT "asset_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint 23 + CREATE INDEX "asset_workspaceId_idx" ON "asset" USING btree ("workspace_id");--> statement-breakpoint 24 + CREATE INDEX "asset_projectId_idx" ON "asset" USING btree ("project_id");--> statement-breakpoint 25 + CREATE INDEX "asset_taskId_idx" ON "asset" USING btree ("task_id");--> statement-breakpoint 26 + CREATE INDEX "asset_activityId_idx" ON "asset" USING btree ("activity_id");
+7
apps/api/drizzle/meta/_journal.json
··· 99 99 "when": 1772660160328, 100 100 "tag": "0013_quiet_gladiator", 101 101 "breakpoints": true 102 + }, 103 + { 104 + "idx": 14, 105 + "version": "7", 106 + "when": 1773259300000, 107 + "tag": "0014_private_assets", 108 + "breakpoints": true 102 109 } 103 110 ] 104 111 }
+4
apps/api/src/database/index.ts
··· 5 5 accountTableRelations, 6 6 activityTableRelations, 7 7 apikeyTableRelations, 8 + assetTableRelations, 8 9 columnTableRelations, 9 10 externalLinkTableRelations, 10 11 githubIntegrationTableRelations, ··· 28 29 accountTable, 29 30 activityTable, 30 31 apikeyTable, 32 + assetTable, 31 33 columnTable, 32 34 externalLinkTable, 33 35 githubIntegrationTable, ··· 58 60 59 61 export const schema = { 60 62 accountTable, 63 + assetTable, 61 64 activityTable, 62 65 apikeyTable, 63 66 columnTable, ··· 79 82 workspaceTable, 80 83 workspaceUserTable, 81 84 accountTableRelations, 85 + assetTableRelations, 82 86 activityTableRelations, 83 87 apikeyTableRelations, 84 88 columnTableRelations,
+28
apps/api/src/database/relations.ts
··· 3 3 accountTable, 4 4 activityTable, 5 5 apikeyTable, 6 + assetTable, 6 7 columnTable, 7 8 externalLinkTable, 8 9 githubIntegrationTable, ··· 32 33 assignedTasks: many(taskTable), 33 34 timeEntries: many(timeEntryTable), 34 35 activities: many(activityTable), 36 + assets: many(assetTable), 35 37 notifications: many(notificationTable), 36 38 sentInvitations: many(invitationTable), 37 39 apikeys: many(apikeyTable), ··· 62 64 teams: many(teamTable), 63 65 members: many(workspaceUserTable), 64 66 projects: many(projectTable), 67 + assets: many(assetTable), 65 68 invitations: many(invitationTable), 66 69 }), 67 70 ); ··· 88 91 references: [workspaceTable.id], 89 92 }), 90 93 tasks: many(taskTable), 94 + assets: many(assetTable), 91 95 columns: many(columnTable), 92 96 workflowRules: many(workflowRuleTable), 93 97 githubIntegration: many(githubIntegrationTable), ··· 133 137 }), 134 138 timeEntries: many(timeEntryTable), 135 139 activities: many(activityTable), 140 + assets: many(assetTable), 136 141 labels: many(labelTable), 137 142 externalLinks: many(externalLinkTable), 138 143 })); ··· 155 160 }), 156 161 user: one(userTable, { 157 162 fields: [activityTable.userId], 163 + references: [userTable.id], 164 + }), 165 + })); 166 + 167 + export const assetTableRelations = relations(assetTable, ({ one }) => ({ 168 + workspace: one(workspaceTable, { 169 + fields: [assetTable.workspaceId], 170 + references: [workspaceTable.id], 171 + }), 172 + project: one(projectTable, { 173 + fields: [assetTable.projectId], 174 + references: [projectTable.id], 175 + }), 176 + task: one(taskTable, { 177 + fields: [assetTable.taskId], 178 + references: [taskTable.id], 179 + }), 180 + activity: one(activityTable, { 181 + fields: [assetTable.activityId], 182 + references: [activityTable.id], 183 + }), 184 + creator: one(userTable, { 185 + fields: [assetTable.createdBy], 158 186 references: [userTable.id], 159 187 }), 160 188 }));
+46
apps/api/src/database/schema.ts
··· 338 338 externalUrl: text("external_url"), 339 339 }); 340 340 341 + export const assetTable = pgTable( 342 + "asset", 343 + { 344 + id: text("id") 345 + .$defaultFn(() => createId()) 346 + .primaryKey(), 347 + workspaceId: text("workspace_id") 348 + .notNull() 349 + .references(() => workspaceTable.id, { 350 + onDelete: "cascade", 351 + onUpdate: "cascade", 352 + }), 353 + projectId: text("project_id") 354 + .notNull() 355 + .references(() => projectTable.id, { 356 + onDelete: "cascade", 357 + onUpdate: "cascade", 358 + }), 359 + taskId: text("task_id").references(() => taskTable.id, { 360 + onDelete: "cascade", 361 + onUpdate: "cascade", 362 + }), 363 + activityId: text("activity_id").references(() => activityTable.id, { 364 + onDelete: "cascade", 365 + onUpdate: "cascade", 366 + }), 367 + objectKey: text("object_key").notNull().unique(), 368 + filename: text("filename").notNull(), 369 + mimeType: text("mime_type").notNull(), 370 + size: integer("size").notNull(), 371 + kind: text("kind").notNull().default("image"), 372 + surface: text("surface").notNull().default("description"), 373 + createdBy: text("created_by").references(() => userTable.id, { 374 + onDelete: "set null", 375 + onUpdate: "cascade", 376 + }), 377 + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 378 + }, 379 + (table) => [ 380 + index("asset_workspaceId_idx").on(table.workspaceId), 381 + index("asset_projectId_idx").on(table.projectId), 382 + index("asset_taskId_idx").on(table.taskId), 383 + index("asset_activityId_idx").on(table.activityId), 384 + ], 385 + ); 386 + 341 387 export const labelTable = pgTable("label", { 342 388 id: text("id") 343 389 .$defaultFn(() => createId())
+57 -1
apps/api/src/index.ts
··· 1 1 import { serve } from "@hono/node-server"; 2 2 import type { Session, User } from "better-auth/types"; 3 + import { eq } from "drizzle-orm"; 3 4 import { migrate } from "drizzle-orm/node-postgres/migrator"; 4 5 import { Hono } from "hono"; 5 6 import { cors } from "hono/cors"; ··· 9 10 import { auth } from "./auth"; 10 11 import column from "./column"; 11 12 import config from "./config"; 12 - import db from "./database"; 13 + import db, { schema } from "./database"; 13 14 import externalLink from "./external-link"; 14 15 import githubIntegration, { 15 16 handleGithubWebhookRoute, ··· 23 24 import project from "./project"; 24 25 import { getPublicProject } from "./project/controllers/get-public-project"; 25 26 import search from "./search"; 27 + import { getPrivateObject } from "./storage/s3"; 26 28 import task from "./task"; 27 29 import timeEntry from "./time-entry"; 28 30 import { getInvitationDetails } from "./utils/check-registration-allowed"; ··· 38 40 normalizeNullableSchemasForOpenApi30, 39 41 normalizeOrganizationAuthOperations, 40 42 } from "./utils/openapi-spec"; 43 + import { validateWorkspaceAccess } from "./utils/validate-workspace-access"; 41 44 import { verifyApiKey } from "./utils/verify-api-key"; 42 45 import workflowRule from "./workflow-rule"; 43 46 ··· 105 108 const { id } = c.req.param(); 106 109 const result = await getInvitationDetails(id); 107 110 return c.json(result); 111 + }); 112 + 113 + api.get("/asset/:id", async (c) => { 114 + const { id } = c.req.param(); 115 + const [asset] = await db 116 + .select({ 117 + id: schema.assetTable.id, 118 + objectKey: schema.assetTable.objectKey, 119 + mimeType: schema.assetTable.mimeType, 120 + filename: schema.assetTable.filename, 121 + workspaceId: schema.assetTable.workspaceId, 122 + isPublic: schema.projectTable.isPublic, 123 + }) 124 + .from(schema.assetTable) 125 + .innerJoin( 126 + schema.projectTable, 127 + eq(schema.assetTable.projectId, schema.projectTable.id), 128 + ) 129 + .where(eq(schema.assetTable.id, id)) 130 + .limit(1); 131 + 132 + if (!asset) { 133 + throw new HTTPException(404, { message: "Asset not found" }); 134 + } 135 + 136 + const session = await auth.api.getSession({ headers: c.req.raw.headers }); 137 + const userId = session?.user?.id || ""; 138 + 139 + if (userId) { 140 + await validateWorkspaceAccess(userId, asset.workspaceId); 141 + } else if (!asset.isPublic) { 142 + throw new HTTPException(401, { message: "Unauthorized" }); 143 + } 144 + 145 + try { 146 + const object = await getPrivateObject(asset.objectKey); 147 + 148 + return new Response(object.body as BodyInit, { 149 + headers: { 150 + "Cache-Control": asset.isPublic 151 + ? "public, max-age=300" 152 + : "private, max-age=120", 153 + "Content-Disposition": `inline; filename="${asset.filename.replace(/"/g, "")}"`, 154 + "Content-Length": object.contentLength?.toString() || "", 155 + "Content-Type": object.contentType || asset.mimeType, 156 + ETag: object.etag || "", 157 + "Last-Modified": object.lastModified?.toUTCString() || "", 158 + }, 159 + }); 160 + } catch (error) { 161 + console.error("Failed to stream asset:", error); 162 + throw new HTTPException(404, { message: "Asset object not found" }); 163 + } 108 164 }); 109 165 110 166 const configApi = api.route("/config", config);
+87 -45
apps/api/src/storage/s3.ts
··· 1 + import { Readable } from "node:stream"; 1 2 import { 3 + GetObjectCommand, 4 + HeadObjectCommand, 2 5 PutObjectCommand, 3 6 S3Client, 4 7 type S3ClientConfig, ··· 25 28 "image/webp", 26 29 ]); 27 30 31 + export function isImageContentType(contentType: string) { 32 + return allowedImageMimeTypes.has(contentType.toLowerCase()); 33 + } 34 + 28 35 type UploadSurface = "description" | "comment"; 29 36 30 37 type StorageConfig = { ··· 52 59 key: string; 53 60 uploadUrl: string; 54 61 headers: Record<string, string>; 55 - assetUrl: string; 62 + }; 63 + 64 + type AssetObject = { 65 + body: unknown; 66 + contentType: string | undefined; 67 + contentLength: number | undefined; 68 + etag: string | undefined; 69 + lastModified: Date | undefined; 56 70 }; 57 71 58 72 let clientCache: ··· 162 176 return sanitizePathSegment(extension).slice(0, 12); 163 177 } 164 178 165 - function encodeKeyPath(key: string) { 166 - return key 167 - .split("/") 168 - .map((segment) => encodeURIComponent(segment)) 169 - .join("/"); 170 - } 171 - 172 - function buildAssetUrl(config: StorageConfig, key: string) { 173 - const encodedKey = encodeKeyPath(key); 174 - 175 - if (config.publicBaseUrl) { 176 - return new URL( 177 - encodedKey, 178 - `${config.publicBaseUrl.replace(/\/+$/, "")}/`, 179 - ).toString(); 180 - } 181 - 182 - const endpoint = new URL(config.endpoint); 183 - 184 - if (config.forcePathStyle) { 185 - endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, "")}/${config.bucket}/${encodedKey}`; 186 - return endpoint.toString(); 187 - } 179 + function buildObjectKeyPrefix( 180 + context: Omit<TaskImageUploadContext, "filename" | "contentType">, 181 + ) { 182 + const surfaceFolder = 183 + context.surface === "comment" ? "comments" : "descriptions"; 188 184 189 - endpoint.hostname = `${config.bucket}.${endpoint.hostname}`; 190 - endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, "")}/${encodedKey}`; 191 - return endpoint.toString(); 185 + return [ 186 + "workspace", 187 + sanitizePathSegment(context.workspaceId), 188 + "project", 189 + sanitizePathSegment(context.projectId), 190 + "task", 191 + sanitizePathSegment(context.taskId), 192 + surfaceFolder, 193 + ].join("/"); 192 194 } 193 195 194 196 function buildObjectKey(context: TaskImageUploadContext) { 195 197 const extension = getFileExtension(context.filename); 196 - const surfaceFolder = 197 - context.surface === "comment" ? "comments" : "descriptions"; 198 + const objectKeyPrefix = buildObjectKeyPrefix(context); 198 199 const timestamp = Date.now(); 199 200 const randomId = createId(); 200 201 ··· 206 207 ? `${baseName}-${timestamp}-${randomId}.${extension}` 207 208 : `${baseName}-${timestamp}-${randomId}`; 208 209 209 - return [ 210 - "workspace", 211 - sanitizePathSegment(context.workspaceId), 212 - "project", 213 - sanitizePathSegment(context.projectId), 214 - "task", 215 - sanitizePathSegment(context.taskId), 216 - surfaceFolder, 217 - fileName, 218 - ].join("/"); 210 + return `${objectKeyPrefix}/${fileName}`; 219 211 } 220 212 221 - export function validateImageUploadInput(contentType: string, size: number) { 213 + export function validateTaskAssetUploadInput( 214 + contentType: string, 215 + size: number, 216 + ) { 222 217 const maxImageUploadBytes = getMaxImageUploadBytes(); 223 218 224 - if (!allowedImageMimeTypes.has(contentType.toLowerCase())) { 225 - throw new Error("Only image uploads are supported."); 219 + if (!contentType.trim()) { 220 + throw new Error("A valid content type is required."); 226 221 } 227 222 228 223 if (size <= 0) { 229 - throw new Error("Image upload size must be greater than zero."); 224 + throw new Error("Upload size must be greater than zero."); 230 225 } 231 226 232 227 if (size > maxImageUploadBytes) { 233 228 throw new Error( 234 - `Image exceeds the maximum upload size of ${Math.floor(maxImageUploadBytes / (1024 * 1024))}MB.`, 229 + `Upload exceeds the maximum upload size of ${Math.floor(maxImageUploadBytes / (1024 * 1024))}MB.`, 235 230 ); 236 231 } 237 232 } ··· 259 254 headers: { 260 255 "Content-Type": context.contentType, 261 256 }, 262 - assetUrl: buildAssetUrl(config, key), 263 257 }; 264 258 } 265 259 266 260 export function assertStorageConfigured() { 267 261 return getStorageConfig(); 268 262 } 263 + 264 + export function assertTaskImageKeyMatchesContext( 265 + key: string, 266 + context: Omit<TaskImageUploadContext, "filename" | "contentType">, 267 + ) { 268 + const prefix = `${buildObjectKeyPrefix(context)}/`; 269 + return key.startsWith(prefix); 270 + } 271 + 272 + export async function assertObjectExists(key: string) { 273 + const config = getStorageConfig(); 274 + const client = getClient(config); 275 + 276 + await client.send( 277 + new HeadObjectCommand({ 278 + Bucket: config.bucket, 279 + Key: key, 280 + }), 281 + ); 282 + } 283 + 284 + export async function getPrivateObject(key: string): Promise<AssetObject> { 285 + const config = getStorageConfig(); 286 + const client = getClient(config); 287 + const response = await client.send( 288 + new GetObjectCommand({ 289 + Bucket: config.bucket, 290 + Key: key, 291 + }), 292 + ); 293 + 294 + if (!response.Body) { 295 + throw new Error("Storage object body is missing."); 296 + } 297 + 298 + const body = 299 + "transformToWebStream" in response.Body 300 + ? response.Body.transformToWebStream() 301 + : Readable.toWeb(response.Body as Readable); 302 + 303 + return { 304 + body, 305 + contentType: response.ContentType, 306 + contentLength: response.ContentLength, 307 + etag: response.ETag, 308 + lastModified: response.LastModified, 309 + }; 310 + }
+138 -2
apps/api/src/task/index.ts
··· 5 5 import * as v from "valibot"; 6 6 import db from "../database"; 7 7 import { 8 + assetTable, 8 9 projectTable, 9 10 taskTable, 10 11 userTable, ··· 13 14 import { publishEvent } from "../events"; 14 15 import { taskSchema } from "../schemas"; 15 16 import { 17 + assertObjectExists, 18 + assertTaskImageKeyMatchesContext, 16 19 createTaskImageUploadUrl, 17 - validateImageUploadInput, 20 + isImageContentType, 21 + validateTaskAssetUploadInput, 18 22 } from "../storage/s3"; 19 23 import { workspaceAccess } from "../utils/workspace-access-middleware"; 20 24 import createTask from "./controllers/create-task"; ··· 548 552 const { filename, contentType, size, surface } = c.req.valid("json"); 549 553 550 554 try { 551 - validateImageUploadInput(contentType, size); 555 + validateTaskAssetUploadInput(contentType, size); 552 556 } catch (error) { 553 557 throw new HTTPException(400, { 554 558 message: ··· 596 600 : "Image uploads are not configured", 597 601 }); 598 602 } 603 + }, 604 + ) 605 + .post( 606 + "/image-upload/:id/finalize", 607 + describeRoute({ 608 + operationId: "finalizeTaskImageUpload", 609 + tags: ["Tasks"], 610 + description: 611 + "Finalize an uploaded task image and create a private asset record", 612 + responses: { 613 + 200: { 614 + description: "Image upload finalized successfully", 615 + content: { 616 + "application/json": { schema: resolver(v.any()) }, 617 + }, 618 + }, 619 + }, 620 + }), 621 + validator("param", v.object({ id: v.string() })), 622 + validator( 623 + "json", 624 + v.object({ 625 + key: v.string(), 626 + filename: v.string(), 627 + contentType: v.string(), 628 + size: v.number(), 629 + surface: v.picklist(["description", "comment"] as const), 630 + }), 631 + ), 632 + workspaceAccess.fromTask(), 633 + async (c) => { 634 + const { id } = c.req.valid("param"); 635 + const { key, filename, contentType, size, surface } = c.req.valid("json"); 636 + const userId = c.get("userId"); 637 + 638 + try { 639 + validateTaskAssetUploadInput(contentType, size); 640 + } catch (error) { 641 + throw new HTTPException(400, { 642 + message: 643 + error instanceof Error 644 + ? error.message 645 + : "Invalid image upload request", 646 + }); 647 + } 648 + 649 + const [taskContext] = await db 650 + .select({ 651 + taskId: taskTable.id, 652 + projectId: taskTable.projectId, 653 + workspaceId: workspaceTable.id, 654 + }) 655 + .from(taskTable) 656 + .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 657 + .innerJoin( 658 + workspaceTable, 659 + eq(projectTable.workspaceId, workspaceTable.id), 660 + ) 661 + .where(eq(taskTable.id, id)) 662 + .limit(1); 663 + 664 + if (!taskContext) { 665 + throw new HTTPException(404, { message: "Task not found" }); 666 + } 667 + 668 + if ( 669 + !assertTaskImageKeyMatchesContext(key, { 670 + workspaceId: taskContext.workspaceId, 671 + projectId: taskContext.projectId, 672 + taskId: taskContext.taskId, 673 + surface, 674 + }) 675 + ) { 676 + throw new HTTPException(400, { 677 + message: "Image upload key does not match the task context.", 678 + }); 679 + } 680 + 681 + try { 682 + await assertObjectExists(key); 683 + } catch { 684 + throw new HTTPException(404, { 685 + message: "Uploaded object could not be found in storage.", 686 + }); 687 + } 688 + 689 + const [existingAsset] = await db 690 + .select({ id: assetTable.id }) 691 + .from(assetTable) 692 + .where(eq(assetTable.objectKey, key)) 693 + .limit(1); 694 + 695 + const [asset] = existingAsset 696 + ? await db 697 + .update(assetTable) 698 + .set({ 699 + workspaceId: taskContext.workspaceId, 700 + projectId: taskContext.projectId, 701 + taskId: taskContext.taskId, 702 + filename, 703 + mimeType: contentType, 704 + size, 705 + kind: isImageContentType(contentType) ? "image" : "attachment", 706 + surface, 707 + createdBy: userId || null, 708 + }) 709 + .where(eq(assetTable.id, existingAsset.id)) 710 + .returning({ 711 + id: assetTable.id, 712 + }) 713 + : await db 714 + .insert(assetTable) 715 + .values({ 716 + workspaceId: taskContext.workspaceId, 717 + projectId: taskContext.projectId, 718 + taskId: taskContext.taskId, 719 + objectKey: key, 720 + filename, 721 + mimeType: contentType, 722 + size, 723 + kind: isImageContentType(contentType) ? "image" : "attachment", 724 + surface, 725 + createdBy: userId || null, 726 + }) 727 + .returning({ 728 + id: assetTable.id, 729 + }); 730 + 731 + return c.json({ 732 + id: asset.id, 733 + url: new URL(`/api/asset/${asset.id}`, c.req.url).toString(), 734 + }); 599 735 }, 600 736 ) 601 737 .put(
+2 -1
apps/docs/core/installation/docker-compose.mdx
··· 63 63 64 64 ## Notes on object storage 65 65 66 - Kaneo uses S3-compatible object storage for image uploads in task descriptions and comments. 66 + Kaneo uses S3-compatible object storage for private uploads in task descriptions and comments. 67 67 68 68 - Object storage is optional. Kaneo runs without it, but image uploads in task descriptions and comments will be unavailable. 69 69 - For local/self-hosted setups, MinIO is the recommended option when you want uploads. 70 70 - The Kaneo API creates presigned upload URLs and the browser uploads images directly to the configured storage backend. 71 + - Kaneo serves uploaded assets back through its own API, so the bucket does not need to be public. 71 72 - If you do not configure object storage, leave the `S3_*` variables unset. 72 73 - For a complete MinIO example and backend-specific setup notes, see the [storage backends guide](/core/installation/storage-backends). 73 74
+4 -4
apps/docs/core/installation/environment-variables.mdx
··· 56 56 57 57 ### Object Storage 58 58 59 - Kaneo uses S3-compatible object storage for image uploads in task descriptions and task comments. 59 + Kaneo uses S3-compatible object storage for private uploads in task descriptions and task comments. 60 60 61 61 MinIO is the easiest local option and the primary tested target. You can also use other S3-compatible storage backends such as AWS S3, Cloudflare R2, or [`fs`](https://github.com/ferdzo/fs). 62 62 63 63 Name | Description | Default | 64 64 --- | --- | --- | 65 65 | `S3_ENDPOINT` | The S3-compatible API endpoint used by the Kaneo API for presigned uploads. Example: `http://minio:9000`. | | 66 - | `S3_BUCKET` | The bucket Kaneo will use for uploaded images. | | 66 + | `S3_BUCKET` | The bucket Kaneo will use for uploaded files. | | 67 67 | `S3_ACCESS_KEY_ID` | Access key used by the Kaneo API to create presigned upload URLs. | | 68 68 | `S3_SECRET_ACCESS_KEY` | Secret key used by the Kaneo API to create presigned upload URLs. | | 69 69 | `S3_REGION` | The storage region used for request signing. | `us-east-1` | 70 - | `S3_PUBLIC_BASE_URL` | Optional public base URL for uploaded assets. Use this when objects should be served from a public hostname or reverse proxy instead of the raw API endpoint. | | 70 + | `S3_PUBLIC_BASE_URL` | Optional public base URL for uploaded assets. Kaneo does not require this for the current private asset flow. | | 71 71 | `S3_FORCE_PATH_STYLE` | Use path-style S3 URLs. This should usually be `true` for MinIO and `fs`. | `true` | 72 - | `S3_MAX_IMAGE_UPLOAD_BYTES` | Maximum allowed image upload size in bytes. | `10485760` | 72 + | `S3_MAX_IMAGE_UPLOAD_BYTES` | Maximum allowed upload size in bytes. | `10485760` | 73 73 | `S3_PRESIGN_TTL_SECONDS` | How long presigned upload URLs remain valid. | `300` | 74 74 75 75 ### Access Control
+20 -11
apps/docs/core/installation/object-storage-and-image-uploads.mdx
··· 1 1 --- 2 - title: Object storage and image uploads 3 - description: Configure S3-compatible storage for task description and comment image uploads. 2 + title: Object storage and uploads 3 + description: Configure S3-compatible storage for private images and attachments in task descriptions and comments. 4 4 --- 5 5 6 6 ## Overview 7 7 8 - Kaneo supports image uploads in task descriptions and task comments. 8 + Kaneo supports private uploads in task descriptions and task comments. 9 9 10 10 Uploads use an S3-compatible object storage backend: 11 11 12 12 - the Kaneo API creates presigned upload URLs 13 13 - the browser uploads images directly to the storage backend 14 - - Kaneo stores the final image URLs inside the task or comment content 14 + - Kaneo finalizes uploads into private asset records 15 + - Kaneo serves uploaded files back through its own API 15 16 16 17 This means Kaneo can work with multiple backends as long as they expose a compatible S3-style API. 18 + 19 + Current behavior: 20 + 21 + - images render inline 22 + - other files such as CSV, PDF, or ZIP are inserted as attachment cards/links 23 + - uploaded files are private by default and are not meant to be served from public bucket URLs 17 24 18 25 If you want backend-specific setup examples, see the [storage backends guide](/core/installation/storage-backends). 19 26 ··· 44 51 45 52 Create the bucket before using uploads. 46 53 54 + You do not need to make the bucket public. 55 + 47 56 ## Important: internal Docker URLs are not enough in production 48 57 49 58 When Kaneo generates a presigned upload URL, the browser uploads directly to your storage backend. ··· 66 75 S3_SECRET_ACCESS_KEY=minioadmin 67 76 S3_REGION=us-east-1 68 77 S3_FORCE_PATH_STYLE=true 69 - S3_PUBLIC_BASE_URL=https://files.cloud.kaneo.app/kaneo-uploads 70 78 ``` 71 79 72 80 ## Recommended public setup ··· 85 93 - Docker-internal names like `minio` are not resolvable from a user's browser 86 94 87 95 Using a dedicated subdomain is recommended over proxying MinIO under a path. 96 + 97 + Reads do not need a public bucket URL, because Kaneo serves uploaded assets back through `/api/asset/:id`. 88 98 89 99 ## Other S3-compatible backends 90 100 ··· 125 135 | Name | Description | 126 136 | --- | --- | 127 137 | `S3_ENDPOINT` | S3-compatible API endpoint used by Kaneo | 128 - | `S3_BUCKET` | Bucket used for uploaded images | 138 + | `S3_BUCKET` | Bucket used for uploaded files | 129 139 | `S3_ACCESS_KEY_ID` | Access key used to create presigned upload URLs | 130 140 | `S3_SECRET_ACCESS_KEY` | Secret key used to create presigned upload URLs | 131 141 | `S3_REGION` | Region used for signing | ··· 135 145 136 146 | Name | Description | 137 147 | --- | --- | 138 - | `S3_PUBLIC_BASE_URL` | Public URL used for serving uploaded assets | 139 - | `S3_MAX_IMAGE_UPLOAD_BYTES` | Maximum allowed image size | 148 + | `S3_PUBLIC_BASE_URL` | Optional public asset base URL. Kaneo does not require this for the current private asset flow. | 149 + | `S3_MAX_IMAGE_UPLOAD_BYTES` | Maximum allowed upload size in bytes | 140 150 | `S3_PRESIGN_TTL_SECONDS` | Presigned upload URL lifetime | 141 151 142 152 ## Testing uploads ··· 144 154 Once storage is configured: 145 155 146 156 1. Open a task. 147 - 2. Paste, drag, or select an image inside the description or a comment. 148 - 3. Confirm the image uploads and renders inline. 157 + 2. Paste, drag, or select a file inside the description or a comment. 158 + 3. Confirm images render inline and other files render as attachment cards/links. 149 159 150 160 If uploads fail: 151 161 ··· 153 163 - confirm the credentials can write to the bucket 154 164 - confirm the storage endpoint is reachable from the browser 155 165 - confirm `S3_ENDPOINT` is a public URL, not an internal Docker hostname 156 - - confirm `S3_PUBLIC_BASE_URL` matches the public asset host if you are using one 157 166 - if using direct browser uploads, confirm CORS is configured correctly for your storage endpoint
+14 -14
apps/docs/core/installation/storage-backends.mdx
··· 1 1 --- 2 2 title: Storage backends 3 - description: Configure image uploads with MinIO, AWS S3, or Cloudflare R2. 3 + description: Configure private uploads with MinIO, AWS S3, or Cloudflare R2. 4 4 --- 5 5 6 6 ## Overview 7 7 8 - Kaneo uses S3-compatible object storage for image uploads in: 8 + Kaneo uses S3-compatible object storage for uploads in: 9 9 10 10 - task descriptions 11 11 - task comments 12 12 13 13 The browser uploads directly to the configured storage backend using presigned URLs. 14 14 15 + Kaneo then serves uploaded assets back through its own API. 16 + 17 + Current behavior: 18 + 19 + - images render inline 20 + - non-image files such as CSV, PDF, and ZIP render as attachment cards/links 21 + - assets are private by default 22 + 15 23 That means one rule matters for every backend: 16 24 17 25 - `S3_ENDPOINT` must be reachable by the browser ··· 27 35 S3_SECRET_ACCESS_KEY= 28 36 S3_REGION=us-east-1 29 37 S3_FORCE_PATH_STYLE=false 30 - S3_PUBLIC_BASE_URL= 31 38 ``` 32 39 33 40 Notes: 34 41 35 - - `S3_PUBLIC_BASE_URL` is recommended when your asset URL should be different from the raw S3 endpoint. 36 42 - `S3_FORCE_PATH_STYLE=true` is usually needed for MinIO. 37 43 - `S3_FORCE_PATH_STYLE=false` is usually correct for AWS S3 and R2. 44 + - `S3_PUBLIC_BASE_URL` is optional and not required for the current private asset flow. 38 45 39 46 ## MinIO 40 47 ··· 54 61 S3_SECRET_ACCESS_KEY=minioadmin 55 62 S3_REGION=us-east-1 56 63 S3_FORCE_PATH_STYLE=true 57 - S3_PUBLIC_BASE_URL=http://localhost:9000/kaneo-uploads 58 64 ``` 59 65 60 66 This works when Kaneo and MinIO are on the same Docker network and your browser reaches MinIO through `localhost`. ··· 79 85 S3_SECRET_ACCESS_KEY=minioadmin 80 86 S3_REGION=us-east-1 81 87 S3_FORCE_PATH_STYLE=true 82 - S3_PUBLIC_BASE_URL=https://files.cloud.kaneo.app/kaneo-uploads 83 88 ``` 84 89 85 90 You also need: 86 91 87 92 - a created bucket 88 93 - MinIO CORS allowing your Kaneo origin 94 + - no anonymous bucket read policy is required 89 95 90 96 ## AWS S3 91 97 ··· 102 108 S3_SECRET_ACCESS_KEY=... 103 109 S3_REGION=us-east-1 104 110 S3_FORCE_PATH_STYLE=false 105 - S3_PUBLIC_BASE_URL=https://kaneo-uploads.s3.us-east-1.amazonaws.com 106 111 ``` 107 112 108 - For another AWS region, adjust both: 113 + For another AWS region, adjust: 109 114 110 115 - `S3_ENDPOINT` 111 - - `S3_PUBLIC_BASE_URL` 112 116 113 117 Recommended S3 CORS policy: 114 118 ··· 138 142 S3_SECRET_ACCESS_KEY=... 139 143 S3_REGION=auto 140 144 S3_FORCE_PATH_STYLE=false 141 - S3_PUBLIC_BASE_URL=https://pub-<hash>.r2.dev 142 145 ``` 143 146 144 147 Notes: 145 148 146 149 - `S3_REGION=auto` is typical for R2 147 - - `S3_PUBLIC_BASE_URL` should usually point to your public bucket domain or custom domain 148 - - if your bucket is not public, image rendering will not work from stored asset URLs 150 + - a public bucket is not required for Kaneo's current private asset flow 149 151 150 152 ## One copy-paste self-hosted example 151 153 ··· 212 214 S3_SECRET_ACCESS_KEY=change-me 213 215 S3_REGION=us-east-1 214 216 S3_FORCE_PATH_STYLE=true 215 - S3_PUBLIC_BASE_URL=https://files.cloud.kaneo.app/kaneo-uploads 216 217 ``` 217 218 218 219 Before testing uploads: ··· 227 228 228 229 - check that the bucket exists 229 230 - check that `S3_ENDPOINT` is public and browser-reachable 230 - - check that `S3_PUBLIC_BASE_URL` points to the correct public asset host 231 231 - check CORS on your storage backend 232 232 - check that the access key can `PutObject` and `GetObject`
+106 -49
apps/web/src/components/activity/comment-editor.tsx
··· 17 17 Check, 18 18 ChevronDown, 19 19 Copy, 20 - ImagePlus, 21 20 Italic, 22 21 Link2, 23 22 List, 24 23 ListOrdered, 25 24 ListTodo, 25 + Paperclip, 26 26 UnderlineIcon, 27 27 } from "lucide-react"; 28 28 import type { MouseEvent as ReactMouseEvent } from "react"; 29 29 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 30 30 import { bundledLanguages, type Highlighter } from "shiki"; 31 + import { AttachmentCard } from "@/components/task/extensions/attachment-card"; 31 32 import { EmbedBlock } from "@/components/task/extensions/embed-block"; 32 33 import { KaneoIssueLink } from "@/components/task/extensions/kaneo-issue-link"; 33 34 import { ··· 75 76 taskId?: string; 76 77 uploadSurface?: "description" | "comment"; 77 78 ensureTaskId?: () => Promise<string | null>; 79 + showQuickAttachButton?: boolean; 80 + onAttachActionChange?: (attach: (() => void) | null) => void; 78 81 }; 79 82 80 83 type SlashRange = { from: number; to: number }; ··· 163 166 taskId, 164 167 uploadSurface = "comment", 165 168 ensureTaskId, 169 + showQuickAttachButton = true, 170 + onAttachActionChange, 166 171 }: CommentEditorProps) { 167 172 const editorShellRef = useRef<HTMLDivElement | null>(null); 168 173 const imageInputRef = useRef<HTMLInputElement | null>(null); ··· 237 242 uploadSurfaceRef.current = uploadSurface; 238 243 }, [ensureTaskId, taskId, uploadSurface]); 239 244 240 - const handleImageFileUpload = useCallback( 245 + const insertUploadedAsset = useCallback( 246 + ( 247 + activeEditor: Editor, 248 + asset: Awaited<ReturnType<typeof uploadTaskImage>>, 249 + range?: SlashRange, 250 + ) => { 251 + const chain = activeEditor.chain().focus(); 252 + 253 + if (range) { 254 + chain.deleteRange(range); 255 + } else { 256 + const { selection } = activeEditor.state; 257 + if (!selection.empty) { 258 + chain.setTextSelection(selection.to); 259 + } 260 + } 261 + 262 + if (asset.kind === "image") { 263 + chain 264 + .setImage({ 265 + src: asset.url, 266 + alt: asset.alt, 267 + }) 268 + .run(); 269 + return; 270 + } 271 + 272 + chain 273 + .insertContent({ 274 + type: "attachmentCard", 275 + attrs: { 276 + url: asset.url, 277 + filename: asset.filename, 278 + mimeType: asset.mimeType, 279 + size: asset.size, 280 + }, 281 + }) 282 + .run(); 283 + }, 284 + [], 285 + ); 286 + 287 + const handleAssetFileUpload = useCallback( 241 288 async (file: File, targetEditor?: Editor | null, range?: SlashRange) => { 242 289 const activeEditor = targetEditor || lastEditorRef.current; 243 290 const resolvedTaskId = 244 291 taskIdRef.current ?? (await ensureTaskIdRef.current?.()); 245 292 246 293 if (!activeEditor || !resolvedTaskId) { 247 - toast.error("Image uploads are only available on saved tasks."); 294 + toast.error("File uploads are only available on saved tasks."); 248 295 return; 249 296 } 250 297 251 - const loadingToast = toast.loading("Uploading image..."); 298 + const loadingToast = toast.loading("Uploading file..."); 252 299 253 300 try { 254 - const uploadedImage = await uploadTaskImage({ 301 + const uploadedAsset = await uploadTaskImage({ 255 302 taskId: resolvedTaskId, 256 303 surface: uploadSurfaceRef.current, 257 304 file, 258 305 }); 259 - 260 - const chain = activeEditor.chain().focus(); 261 - if (range) { 262 - chain.deleteRange(range); 263 - } 264 - 265 - chain 266 - .setImage({ 267 - src: uploadedImage.url, 268 - alt: uploadedImage.alt, 269 - }) 270 - .run(); 306 + insertUploadedAsset(activeEditor, uploadedAsset, range); 271 307 272 308 toast.dismiss(loadingToast); 273 - toast.success("Image uploaded"); 309 + toast.success( 310 + uploadedAsset.kind === "image" ? "Image uploaded" : "File attached", 311 + ); 274 312 } catch (error) { 275 313 toast.dismiss(loadingToast); 276 314 toast.error( 277 - error instanceof Error ? error.message : "Failed to upload image", 315 + error instanceof Error ? error.message : "Failed to upload file", 278 316 ); 279 317 } 280 318 }, 281 - [], 319 + [insertUploadedAsset], 282 320 ); 283 321 284 - const canUploadImages = Boolean(taskId || ensureTaskId); 322 + const canUploadFiles = Boolean(taskId || ensureTaskId); 285 323 286 324 const openImagePicker = useCallback( 287 325 (activeEditor?: Editor | null, range?: SlashRange) => { ··· 293 331 [], 294 332 ); 295 333 296 - const hasImageDrag = useCallback((event: React.DragEvent<HTMLElement>) => { 297 - return Array.from(event.dataTransfer?.items || []).some((item) => 298 - item.type.toLowerCase().startsWith("image/"), 334 + const hasFileDrag = useCallback((event: React.DragEvent<HTMLElement>) => { 335 + return Array.from(event.dataTransfer?.items || []).some( 336 + (item) => item.kind === "file", 299 337 ); 300 338 }, []); 301 339 302 340 const handleShellDragEnter = useCallback( 303 341 (event: React.DragEvent<HTMLElement>) => { 304 - if (readOnly || disabled || !canUploadImages || !hasImageDrag(event)) { 342 + if (readOnly || disabled || !canUploadFiles || !hasFileDrag(event)) { 305 343 return; 306 344 } 307 345 event.preventDefault(); 308 346 dragDepthRef.current += 1; 309 347 setIsDragActive(true); 310 348 }, 311 - [canUploadImages, disabled, hasImageDrag, readOnly], 349 + [canUploadFiles, disabled, hasFileDrag, readOnly], 312 350 ); 313 351 314 352 const handleShellDragOver = useCallback( 315 353 (event: React.DragEvent<HTMLElement>) => { 316 - if (readOnly || disabled || !canUploadImages || !hasImageDrag(event)) { 354 + if (readOnly || disabled || !canUploadFiles || !hasFileDrag(event)) { 317 355 return; 318 356 } 319 357 event.preventDefault(); ··· 322 360 setIsDragActive(true); 323 361 } 324 362 }, 325 - [canUploadImages, disabled, hasImageDrag, isDragActive, readOnly], 363 + [canUploadFiles, disabled, hasFileDrag, isDragActive, readOnly], 326 364 ); 327 365 328 366 const handleShellDragLeave = useCallback( 329 367 (event: React.DragEvent<HTMLElement>) => { 330 - if (readOnly || disabled || !canUploadImages || !hasImageDrag(event)) { 368 + if (readOnly || disabled || !canUploadFiles || !hasFileDrag(event)) { 331 369 return; 332 370 } 333 371 event.preventDefault(); ··· 336 374 setIsDragActive(false); 337 375 } 338 376 }, 339 - [canUploadImages, disabled, hasImageDrag, readOnly], 377 + [canUploadFiles, disabled, hasFileDrag, readOnly], 340 378 ); 341 379 342 380 const handleShellDrop = useCallback( 343 381 (event: React.DragEvent<HTMLElement>) => { 344 - if (readOnly || disabled || !canUploadImages || !hasImageDrag(event)) { 382 + if (readOnly || disabled || !canUploadFiles || !hasFileDrag(event)) { 345 383 return; 346 384 } 347 385 dragDepthRef.current = 0; 348 386 setIsDragActive(false); 349 387 }, 350 - [canUploadImages, disabled, hasImageDrag, readOnly], 388 + [canUploadFiles, disabled, hasFileDrag, readOnly], 351 389 ); 352 390 353 391 const slashCommands = useMemo<SlashCommand[]>( ··· 464 502 }, 465 503 }, 466 504 { 467 - id: "image", 468 - label: "Image", 505 + id: "file", 506 + label: "File", 469 507 group: "insert", 470 - search: "image photo picture upload", 508 + search: "file attachment image photo picture upload", 471 509 run: (activeEditor, range) => { 472 510 activeEditor.chain().focus().deleteRange(range).run(); 473 511 openImagePicker(activeEditor); ··· 531 569 themeLight: "github-light", 532 570 }), 533 571 EmbedBlock, 572 + AttachmentCard, 534 573 KaneoIssueLink, 535 574 TaskList, 536 575 Image.configure({ ··· 563 602 if (readOnly || disabled) return false; 564 603 565 604 const pastedFiles = Array.from(event.clipboardData?.files || []); 566 - const pastedImage = pastedFiles.find((file) => 567 - file.type.toLowerCase().startsWith("image/"), 568 - ); 605 + const pastedFile = pastedFiles[0]; 569 606 570 - if (pastedImage) { 607 + if (pastedFile) { 571 608 event.preventDefault(); 572 - void handleImageFileUpload(pastedImage, editor); 609 + void handleAssetFileUpload(pastedFile, editor); 573 610 return true; 574 611 } 575 612 ··· 628 665 if (readOnly || disabled) return false; 629 666 630 667 const droppedFiles = Array.from(event.dataTransfer?.files || []); 631 - const droppedImage = droppedFiles.find((file) => 632 - file.type.toLowerCase().startsWith("image/"), 633 - ); 668 + const droppedFile = droppedFiles[0]; 634 669 635 - if (!droppedImage) return false; 670 + if (!droppedFile) return false; 636 671 637 672 event.preventDefault(); 638 673 const coordinates = view.posAtCoords({ ··· 644 679 ? { from: coordinates.pos, to: coordinates.pos } 645 680 : undefined; 646 681 647 - void handleImageFileUpload(droppedImage, editor, dropRange); 682 + void handleAssetFileUpload(droppedFile, editor, dropRange); 648 683 return true; 649 684 }, 650 685 handleKeyDown: (_view, event) => { ··· 753 788 onChange(markdown); 754 789 }, 755 790 }, 756 - [handleImageFileUpload, toShikiLanguage], 791 + [handleAssetFileUpload, toShikiLanguage], 757 792 ); 793 + 794 + useEffect(() => { 795 + if (!onAttachActionChange) return; 796 + 797 + onAttachActionChange(editor ? () => openImagePicker(editor) : null); 798 + 799 + return () => { 800 + onAttachActionChange(null); 801 + }; 802 + }, [editor, onAttachActionChange, openImagePicker]); 758 803 759 804 useEffect(() => { 760 805 if (!editor || !shikiHighlighter) return; ··· 1257 1302 <input 1258 1303 ref={imageInputRef} 1259 1304 type="file" 1260 - accept="image/*" 1261 1305 className="sr-only" 1262 1306 onChange={(event) => { 1263 1307 const file = event.target.files?.[0]; ··· 1265 1309 1266 1310 const pendingInsert = pendingImageInsertRef.current; 1267 1311 pendingImageInsertRef.current = null; 1268 - void handleImageFileUpload( 1312 + void handleAssetFileUpload( 1269 1313 file, 1270 1314 pendingInsert?.editor, 1271 1315 pendingInsert?.range, ··· 1446 1490 className="kaneo-comment-editor-bubble-btn" 1447 1491 onClick={() => openImagePicker(editor)} 1448 1492 > 1449 - <ImagePlus className="size-3.5" /> 1493 + <Paperclip className="size-3.5" /> 1450 1494 </Button> 1451 1495 </BubbleMenu> 1452 1496 )} ··· 1608 1652 onMouseMove={handleEditorMouseMove} 1609 1653 onMouseLeave={handleEditorMouseLeave} 1610 1654 /> 1655 + {!readOnly && !disabled && showQuickAttachButton && ( 1656 + <button 1657 + type="button" 1658 + className="kaneo-editor-quick-attach" 1659 + onMouseDown={(event) => { 1660 + event.preventDefault(); 1661 + }} 1662 + onClick={() => openImagePicker(editor)} 1663 + aria-label="Attach file" 1664 + > 1665 + <Paperclip className="size-3.5" /> 1666 + </button> 1667 + )} 1611 1668 {isDragActive && ( 1612 1669 <div className="kaneo-editor-drop-indicator"> 1613 1670 <span>Drop image to upload</span>
+22 -2
apps/web/src/components/activity/comment-input.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 - import { ArrowUp } from "lucide-react"; 2 + import { ArrowUp, Paperclip } from "lucide-react"; 3 3 import { useCallback, useState } from "react"; 4 4 import CommentEditor from "@/components/activity/comment-editor"; 5 5 import { Button } from "@/components/ui/button"; ··· 21 21 22 22 export default function CommentInput({ taskId }: CommentInputProps) { 23 23 const [content, setContent] = useState(""); 24 + const [attachAction, setAttachAction] = useState<(() => void) | null>(null); 24 25 const { mutateAsync: createComment, isPending } = useCreateComment(); 25 26 const queryClient = useQueryClient(); 26 27 ··· 46 47 } 47 48 }, [content, createComment, taskId, queryClient]); 48 49 50 + const handleAttachActionChange = useCallback( 51 + (nextAttachAction: (() => void) | null) => { 52 + setAttachAction(() => nextAttachAction); 53 + }, 54 + [], 55 + ); 56 + 49 57 return ( 50 58 <div className="w-full"> 51 59 <div className="rounded-xl border border-border/80 bg-card/70 transition-colors focus-within:border-ring/60 focus-within:shadow-[0_0_0_2px_color-mix(in_srgb,var(--ring)_20%,transparent)]"> ··· 55 63 placeholder="Leave a comment..." 56 64 taskId={taskId} 57 65 uploadSurface="comment" 66 + showQuickAttachButton={false} 67 + onAttachActionChange={handleAttachActionChange} 58 68 className="[&_.kaneo-comment-editor-content_.ProseMirror]:min-h-[3rem] [&_.kaneo-comment-editor-content_.ProseMirror]:max-h-none [&_.kaneo-comment-editor-content_.ProseMirror]:overflow-visible [&_.kaneo-comment-editor-content_.ProseMirror]:px-3 [&_.kaneo-comment-editor-content_.ProseMirror]:pt-3 [&_.kaneo-comment-editor-content_.ProseMirror]:pb-2" 59 69 onSubmitShortcut={handleSubmit} 60 70 /> 61 - <div className="flex items-center justify-end border-border/70 border-t px-2 py-2"> 71 + <div className="flex items-center justify-end gap-2 border-border/70 border-t px-2 py-2"> 72 + <Button 73 + size="xs" 74 + variant="ghost" 75 + onClick={() => attachAction?.()} 76 + disabled={!attachAction} 77 + className="text-muted-foreground" 78 + aria-label="Attach file" 79 + > 80 + <Paperclip className="size-3.5" /> 81 + </Button> 62 82 <TooltipProvider> 63 83 <Tooltip> 64 84 <TooltipTrigger asChild>
+107
apps/web/src/components/task/extensions/attachment-card.tsx
··· 1 + import { mergeAttributes, Node } from "@tiptap/core"; 2 + import type { NodeViewProps } from "@tiptap/react"; 3 + import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; 4 + import { FileText } from "lucide-react"; 5 + 6 + function formatBytes(size: number) { 7 + if (!Number.isFinite(size) || size <= 0) return ""; 8 + if (size < 1024) return `${size} B`; 9 + if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`; 10 + if (size < 1024 * 1024 * 1024) 11 + return `${(size / (1024 * 1024)).toFixed(2)} MB`; 12 + return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`; 13 + } 14 + 15 + function AttachmentCardView({ node }: NodeViewProps) { 16 + const url = String(node.attrs.url || ""); 17 + const filename = String(node.attrs.filename || "Attachment"); 18 + const mimeType = String(node.attrs.mimeType || ""); 19 + const size = Number(node.attrs.size || 0); 20 + 21 + return ( 22 + <NodeViewWrapper as="span" className="kaneo-attachment-node"> 23 + <a 24 + href={url} 25 + target="_blank" 26 + rel="noopener noreferrer" 27 + className="kaneo-attachment-card" 28 + title={filename} 29 + > 30 + <span className="kaneo-attachment-card-icon"> 31 + <FileText className="size-4" /> 32 + </span> 33 + <span className="kaneo-attachment-card-content"> 34 + <span className="kaneo-attachment-card-title">{filename}</span> 35 + <span className="kaneo-attachment-card-meta"> 36 + {formatBytes(size)} 37 + {mimeType ? ` · ${mimeType}` : ""} 38 + </span> 39 + </span> 40 + </a> 41 + </NodeViewWrapper> 42 + ); 43 + } 44 + 45 + export const AttachmentCard = Node.create({ 46 + name: "attachmentCard", 47 + group: "inline", 48 + inline: true, 49 + atom: true, 50 + selectable: false, 51 + 52 + addAttributes() { 53 + return { 54 + url: { default: "" }, 55 + filename: { default: "" }, 56 + mimeType: { default: "" }, 57 + size: { default: 0 }, 58 + }; 59 + }, 60 + 61 + parseHTML() { 62 + return [ 63 + { tag: "kaneo-attachment[url]" }, 64 + { tag: "span[data-type='attachment-card'][data-url]" }, 65 + ]; 66 + }, 67 + 68 + renderHTML({ HTMLAttributes }) { 69 + return [ 70 + "kaneo-attachment", 71 + mergeAttributes(HTMLAttributes, { 72 + "data-type": "attachment-card", 73 + "data-url": HTMLAttributes.url, 74 + "data-filename": HTMLAttributes.filename, 75 + "data-mime-type": HTMLAttributes.mimeType, 76 + "data-size": HTMLAttributes.size, 77 + url: HTMLAttributes.url, 78 + }), 79 + ]; 80 + }, 81 + 82 + addNodeView() { 83 + return ReactNodeViewRenderer(AttachmentCardView); 84 + }, 85 + 86 + renderMarkdown( 87 + node: { 88 + attrs?: { 89 + url?: string; 90 + filename?: string; 91 + mimeType?: string; 92 + size?: number; 93 + }; 94 + }, 95 + _helpers: unknown, 96 + _context: unknown, 97 + ) { 98 + const url = String(node.attrs?.url || ""); 99 + const filename = String(node.attrs?.filename || ""); 100 + const mimeType = String(node.attrs?.mimeType || ""); 101 + const size = Number(node.attrs?.size || 0); 102 + 103 + if (!url) return ""; 104 + 105 + return `\n<kaneo-attachment url="${url}" filename="${filename}" mime-type="${mimeType}" size="${size}" />\n`; 106 + }, 107 + });
+88 -46
apps/web/src/components/task/task-description.tsx
··· 25 25 List, 26 26 ListOrdered, 27 27 ListTodo, 28 + Paperclip, 28 29 Quote, 29 30 Strikethrough, 30 31 Table2, ··· 58 59 import { getSharedShikiHighlighter } from "@/lib/shiki-highlighter"; 59 60 import { toast } from "@/lib/toast"; 60 61 import { uploadTaskImage } from "@/lib/upload-task-image"; 62 + import { AttachmentCard } from "./extensions/attachment-card"; 61 63 import { EmbedBlock } from "./extensions/embed-block"; 62 64 import { KaneoIssueLink } from "./extensions/kaneo-issue-link"; 63 65 import { ··· 324 326 [], 325 327 ); 326 328 327 - const handleImageFileUpload = useCallback( 329 + const insertUploadedAsset = useCallback( 330 + ( 331 + activeEditor: Editor, 332 + asset: Awaited<ReturnType<typeof uploadTaskImage>>, 333 + range?: SlashRange, 334 + ) => { 335 + const chain = activeEditor.chain().focus(); 336 + 337 + if (range) { 338 + chain.deleteRange(range); 339 + } else { 340 + const { selection } = activeEditor.state; 341 + if (!selection.empty) { 342 + chain.setTextSelection(selection.to); 343 + } 344 + } 345 + 346 + if (asset.kind === "image") { 347 + chain 348 + .setImage({ 349 + src: asset.url, 350 + alt: asset.alt, 351 + }) 352 + .run(); 353 + return; 354 + } 355 + 356 + chain 357 + .insertContent({ 358 + type: "attachmentCard", 359 + attrs: { 360 + url: asset.url, 361 + filename: asset.filename, 362 + mimeType: asset.mimeType, 363 + size: asset.size, 364 + }, 365 + }) 366 + .run(); 367 + }, 368 + [], 369 + ); 370 + 371 + const handleAssetFileUpload = useCallback( 328 372 async (file: File, targetEditor?: Editor | null, range?: SlashRange) => { 329 373 const activeEditor = targetEditor || lastEditorRef.current; 330 374 331 375 if (!activeEditor) { 332 - toast.error("Image upload failed"); 376 + toast.error("File upload failed"); 333 377 return; 334 378 } 335 379 336 - const loadingToast = toast.loading("Uploading image..."); 380 + const loadingToast = toast.loading("Uploading file..."); 337 381 338 382 try { 339 - const uploadedImage = await uploadTaskImage({ 383 + const uploadedAsset = await uploadTaskImage({ 340 384 taskId, 341 385 surface: "description", 342 386 file, 343 387 }); 344 - 345 - const chain = activeEditor.chain().focus(); 346 - if (range) { 347 - chain.deleteRange(range); 348 - } 349 - 350 - chain 351 - .setImage({ 352 - src: uploadedImage.url, 353 - alt: uploadedImage.alt, 354 - }) 355 - .run(); 388 + insertUploadedAsset(activeEditor, uploadedAsset, range); 356 389 357 390 toast.dismiss(loadingToast); 358 - toast.success("Image uploaded"); 391 + toast.success( 392 + uploadedAsset.kind === "image" ? "Image uploaded" : "File attached", 393 + ); 359 394 } catch (error) { 360 395 toast.dismiss(loadingToast); 361 396 toast.error( 362 - error instanceof Error ? error.message : "Failed to upload image", 397 + error instanceof Error ? error.message : "Failed to upload file", 363 398 ); 364 399 } 365 400 }, 366 - [taskId], 401 + [insertUploadedAsset, taskId], 367 402 ); 368 403 369 404 const openImagePicker = useCallback( ··· 376 411 [], 377 412 ); 378 413 379 - const hasImageDrag = useCallback((event: React.DragEvent<HTMLElement>) => { 380 - return Array.from(event.dataTransfer?.items || []).some((item) => 381 - item.type.toLowerCase().startsWith("image/"), 414 + const hasFileDrag = useCallback((event: React.DragEvent<HTMLElement>) => { 415 + return Array.from(event.dataTransfer?.items || []).some( 416 + (item) => item.kind === "file", 382 417 ); 383 418 }, []); 384 419 385 420 const handleShellDragEnter = useCallback( 386 421 (event: React.DragEvent<HTMLElement>) => { 387 - if (!taskId || !hasImageDrag(event)) return; 422 + if (!taskId || !hasFileDrag(event)) return; 388 423 event.preventDefault(); 389 424 dragDepthRef.current += 1; 390 425 setIsDragActive(true); 391 426 }, 392 - [hasImageDrag, taskId], 427 + [hasFileDrag, taskId], 393 428 ); 394 429 395 430 const handleShellDragOver = useCallback( 396 431 (event: React.DragEvent<HTMLElement>) => { 397 - if (!taskId || !hasImageDrag(event)) return; 432 + if (!taskId || !hasFileDrag(event)) return; 398 433 event.preventDefault(); 399 434 event.dataTransfer.dropEffect = "copy"; 400 435 if (!isDragActive) { 401 436 setIsDragActive(true); 402 437 } 403 438 }, 404 - [hasImageDrag, isDragActive, taskId], 439 + [hasFileDrag, isDragActive, taskId], 405 440 ); 406 441 407 442 const handleShellDragLeave = useCallback( 408 443 (event: React.DragEvent<HTMLElement>) => { 409 - if (!taskId || !hasImageDrag(event)) return; 444 + if (!taskId || !hasFileDrag(event)) return; 410 445 event.preventDefault(); 411 446 dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); 412 447 if (dragDepthRef.current === 0) { 413 448 setIsDragActive(false); 414 449 } 415 450 }, 416 - [hasImageDrag, taskId], 451 + [hasFileDrag, taskId], 417 452 ); 418 453 419 454 const handleShellDrop = useCallback( 420 455 (event: React.DragEvent<HTMLElement>) => { 421 - if (!taskId || !hasImageDrag(event)) return; 456 + if (!taskId || !hasFileDrag(event)) return; 422 457 dragDepthRef.current = 0; 423 458 setIsDragActive(false); 424 459 }, 425 - [hasImageDrag, taskId], 460 + [hasFileDrag, taskId], 426 461 ); 427 462 428 463 const slashCommands = useMemo( 429 464 () => [ 430 465 ...SLASH_COMMANDS, 431 466 { 432 - id: "image", 433 - label: "Image", 467 + id: "file", 468 + label: "File", 434 469 group: "insert" as const, 435 - search: "image photo picture upload", 470 + search: "file attachment image photo picture upload", 436 471 run: (activeEditor: Editor, range: SlashRange) => { 437 472 activeEditor.chain().focus().deleteRange(range).run(); 438 473 openImagePicker(activeEditor); ··· 508 543 themeLight: "github-light", 509 544 }), 510 545 EmbedBlock, 546 + AttachmentCard, 511 547 KaneoIssueLink, 512 548 TaskList, 513 549 Image.configure({ ··· 535 571 }, 536 572 handlePaste: (view, event) => { 537 573 const pastedFiles = Array.from(event.clipboardData?.files || []); 538 - const pastedImage = pastedFiles.find((file) => 539 - file.type.toLowerCase().startsWith("image/"), 540 - ); 574 + const pastedFile = pastedFiles[0]; 541 575 542 - if (pastedImage) { 576 + if (pastedFile) { 543 577 event.preventDefault(); 544 - void handleImageFileUpload(pastedImage, editor); 578 + void handleAssetFileUpload(pastedFile, editor); 545 579 return true; 546 580 } 547 581 ··· 599 633 }, 600 634 handleDrop: (view, event) => { 601 635 const droppedFiles = Array.from(event.dataTransfer?.files || []); 602 - const droppedImage = droppedFiles.find((file) => 603 - file.type.toLowerCase().startsWith("image/"), 604 - ); 636 + const droppedFile = droppedFiles[0]; 605 637 606 - if (!droppedImage) return false; 638 + if (!droppedFile) return false; 607 639 608 640 event.preventDefault(); 609 641 const coordinates = view.posAtCoords({ ··· 614 646 ? { from: coordinates.pos, to: coordinates.pos } 615 647 : undefined; 616 648 617 - void handleImageFileUpload(droppedImage, editor, dropRange); 649 + void handleAssetFileUpload(droppedFile, editor, dropRange); 618 650 return true; 619 651 }, 620 652 handleKeyDown: (view, event) => { ··· 650 682 debouncedUpdate(markdown); 651 683 }, 652 684 }, 653 - [getOverlayPosition, handleImageFileUpload, toShikiLanguage], 685 + [getOverlayPosition, handleAssetFileUpload, toShikiLanguage], 654 686 ); 655 687 656 688 useEffect(() => { ··· 1199 1231 <input 1200 1232 ref={imageInputRef} 1201 1233 type="file" 1202 - accept="image/*" 1203 1234 className="sr-only" 1204 1235 onChange={(event) => { 1205 1236 const file = event.target.files?.[0]; ··· 1207 1238 1208 1239 const pendingInsert = pendingImageInsertRef.current; 1209 1240 pendingImageInsertRef.current = null; 1210 - void handleImageFileUpload( 1241 + void handleAssetFileUpload( 1211 1242 file, 1212 1243 pendingInsert?.editor, 1213 1244 pendingInsert?.range, ··· 1614 1645 onMouseMove={handleEditorMouseMove} 1615 1646 onMouseLeave={handleEditorMouseLeave} 1616 1647 /> 1648 + <button 1649 + type="button" 1650 + className="kaneo-editor-quick-attach" 1651 + onMouseDown={(event) => { 1652 + event.preventDefault(); 1653 + }} 1654 + onClick={() => openImagePicker(editor)} 1655 + aria-label="Attach file" 1656 + > 1657 + <Paperclip className="size-3.5" /> 1658 + </button> 1617 1659 {isDragActive && ( 1618 1660 <div className="kaneo-editor-drop-indicator"> 1619 1661 <span>Drop image to upload</span>
+34
apps/web/src/fetchers/task/create-image-upload.ts
··· 31 31 return response.json(); 32 32 } 33 33 34 + export async function finalizeImageUpload({ 35 + taskId, 36 + key, 37 + filename, 38 + contentType, 39 + size, 40 + surface, 41 + }: { 42 + taskId: string; 43 + key: string; 44 + filename: string; 45 + contentType: string; 46 + size: number; 47 + surface: "description" | "comment"; 48 + }) { 49 + const response = await client.task["image-upload"][":id"].finalize.$post({ 50 + param: { id: taskId }, 51 + json: { 52 + key, 53 + filename, 54 + contentType, 55 + size, 56 + surface, 57 + }, 58 + }); 59 + 60 + if (!response.ok) { 61 + const error = await response.text(); 62 + throw new Error(error); 63 + } 64 + 65 + return response.json(); 66 + } 67 + 34 68 export default createImageUpload;
+114 -2
apps/web/src/index.css
··· 235 235 z-index: 90; 236 236 } 237 237 238 + .kaneo-editor-quick-attach { 239 + position: absolute; 240 + right: 0.75rem; 241 + bottom: 0.7rem; 242 + z-index: 10; 243 + display: inline-flex; 244 + align-items: center; 245 + justify-content: center; 246 + width: 2rem; 247 + height: 2rem; 248 + border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); 249 + border-radius: 999px; 250 + background: color-mix(in srgb, var(--background) 82%, transparent); 251 + color: color-mix(in srgb, var(--foreground) 76%, transparent); 252 + box-shadow: 0 4px 16px color-mix(in srgb, var(--background) 68%, transparent); 253 + transition: 254 + background-color 0.15s ease, 255 + border-color 0.15s ease, 256 + color 0.15s ease, 257 + transform 0.15s ease; 258 + } 259 + 260 + .kaneo-editor-quick-attach:hover { 261 + border-color: color-mix(in srgb, var(--foreground) 18%, var(--border)); 262 + background: color-mix(in srgb, var(--accent) 72%, transparent); 263 + color: var(--foreground); 264 + transform: translateY(-1px); 265 + } 266 + 267 + .kaneo-editor-quick-attach:focus-visible { 268 + outline: none; 269 + box-shadow: 270 + 0 0 0 2px color-mix(in srgb, var(--ring) 24%, transparent), 271 + 0 4px 16px color-mix(in srgb, var(--background) 68%, transparent); 272 + } 273 + 238 274 .kaneo-tiptap-bubble { 239 275 display: inline-flex; 240 276 align-items: center; ··· 307 343 308 344 .kaneo-tiptap-content .ProseMirror { 309 345 min-height: 1.85rem; 310 - padding: 0.2rem 0 0.24rem; 346 + padding: 0.2rem 0 2.9rem; 311 347 font-family: var(--font-sans); 312 348 font-size: 0.965rem; 313 349 line-height: 1.72; ··· 739 775 color: color-mix(in srgb, var(--foreground) 72%, transparent); 740 776 } 741 777 778 + .kaneo-attachment-node { 779 + display: inline-flex; 780 + max-width: 100%; 781 + vertical-align: middle; 782 + margin: 0.35rem 0; 783 + } 784 + 785 + .kaneo-attachment-card { 786 + display: inline-flex; 787 + align-items: center; 788 + gap: 0.75rem; 789 + min-width: min(22rem, 100%); 790 + max-width: min(28rem, 100%); 791 + padding: 0.75rem 0.95rem; 792 + border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); 793 + border-radius: calc(var(--radius) + 1px); 794 + background: linear-gradient( 795 + 180deg, 796 + color-mix(in srgb, var(--accent) 48%, transparent), 797 + color-mix(in srgb, var(--accent) 20%, transparent) 798 + ); 799 + text-decoration: none; 800 + transition: 801 + border-color 0.18s ease, 802 + background-color 0.18s ease, 803 + transform 0.18s ease; 804 + } 805 + 806 + .kaneo-attachment-card:hover { 807 + border-color: color-mix(in srgb, var(--foreground) 18%, var(--border)); 808 + background: linear-gradient( 809 + 180deg, 810 + color-mix(in srgb, var(--accent) 62%, transparent), 811 + color-mix(in srgb, var(--accent) 30%, transparent) 812 + ); 813 + transform: translateY(-1px); 814 + } 815 + 816 + .kaneo-attachment-card-icon { 817 + display: inline-flex; 818 + align-items: center; 819 + justify-content: center; 820 + width: 1.8rem; 821 + height: 1.8rem; 822 + color: color-mix(in srgb, var(--foreground) 72%, transparent); 823 + background: color-mix(in srgb, var(--foreground) 6%, transparent); 824 + border-radius: 0.55rem; 825 + flex-shrink: 0; 826 + } 827 + 828 + .kaneo-attachment-card-content { 829 + display: flex; 830 + min-width: 0; 831 + flex-direction: column; 832 + } 833 + 834 + .kaneo-attachment-card-title { 835 + color: var(--foreground); 836 + font-size: 0.96rem; 837 + font-weight: 600; 838 + line-height: 1.2; 839 + white-space: nowrap; 840 + overflow: hidden; 841 + text-overflow: ellipsis; 842 + } 843 + 844 + .kaneo-attachment-card-meta { 845 + margin-top: 0.18rem; 846 + color: color-mix(in srgb, var(--foreground) 58%, transparent); 847 + font-size: 0.76rem; 848 + line-height: 1.2; 849 + white-space: nowrap; 850 + overflow: hidden; 851 + text-overflow: ellipsis; 852 + } 853 + 742 854 .kaneo-tiptap-content .ProseMirror [data-type="kaneo-embed"]:focus-within { 743 855 border-color: color-mix(in srgb, var(--ring) 80%, var(--border)); 744 856 box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 34%, transparent); ··· 864 976 min-height: 2.2rem; 865 977 max-height: 18rem; 866 978 overflow-y: auto; 867 - padding: 0.62rem 0.75rem; 979 + padding: 0.62rem 0.75rem 2.9rem; 868 980 font-family: var(--font-sans); 869 981 font-size: 0.92rem; 870 982 line-height: 1.62;
+25 -4
apps/web/src/lib/upload-task-image.ts
··· 1 - import createImageUpload from "@/fetchers/task/create-image-upload"; 1 + import createImageUpload, { 2 + finalizeImageUpload, 3 + } from "@/fetchers/task/create-image-upload"; 2 4 3 5 const allowedImageMimeTypes = new Set([ 4 6 "image/apng", ··· 17 19 18 20 export function isSupportedImageFile(file: File) { 19 21 return allowedImageMimeTypes.has(file.type.toLowerCase()); 22 + } 23 + 24 + export function isSupportedTaskAsset(file: File) { 25 + return file.size > 0; 20 26 } 21 27 22 28 export function getImageAltText(filename: string) { ··· 36 42 file: File; 37 43 }) { 38 44 if (!isSupportedImageFile(file)) { 39 - throw new Error("Only image uploads are supported."); 45 + if (!isSupportedTaskAsset(file)) { 46 + throw new Error("Only non-empty file uploads are supported."); 47 + } 40 48 } 41 49 42 50 const upload = await createImageUpload({ ··· 54 62 }); 55 63 56 64 if (!response.ok) { 57 - throw new Error("Failed to upload image to storage."); 65 + throw new Error("Failed to upload file to storage."); 58 66 } 59 67 68 + const asset = await finalizeImageUpload({ 69 + taskId, 70 + key: upload.key, 71 + filename: file.name || "image", 72 + contentType: file.type, 73 + size: file.size, 74 + surface, 75 + }); 76 + 60 77 return { 61 - url: upload.assetUrl, 78 + url: asset.url, 62 79 alt: getImageAltText(file.name || "image"), 80 + filename: file.name || "file", 81 + kind: isSupportedImageFile(file) ? "image" : "attachment", 82 + mimeType: file.type, 83 + size: file.size, 63 84 }; 64 85 }