Skip to content

Commit 71872e9

Browse files
authored
feat: save preview notebooks to r2 (#5546)
* chore: regenerate `lock` file * chore: upgrade aws `deps` and install `presigner` * feat: implement `notebook` preview graphql `query` and `mutations` * fix: remove `explicit` casts * chore: register `saveNotebookPreview` callback * feat: implement `r2` image save * chore: run `codegen` scripts * chore: run `codegen` scripts * chore: address `PR` feedback * fix: eslint `config` issue * fix: `eslint` warning issue
1 parent 6673112 commit 71872e9

File tree

18 files changed

+1330
-691
lines changed

18 files changed

+1330
-691
lines changed

.lintstagedrc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"**/!(.eslintrc)*.{js,jsx,ts,tsx,sol}": [
3-
"eslint --fix --max-warnings 0 --no-ignore",
2+
"**/*.{js,jsx,ts,tsx,sol}": [
3+
"eslint --flag v10_config_lookup_from_file --fix --max-warnings 0",
44
"prettier --ignore-path .gitignore --write",
55
"prettier --ignore-path .gitignore --log-level warn --check"
66
],
@@ -14,4 +14,4 @@
1414
"uv run isort",
1515
"pnpm pyright"
1616
]
17-
}
17+
}

apps/frontend/app/api/v1/osograph/schema.graphql

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,23 @@ type Invitation @key(fields: "id") {
8080
acceptedBy: User
8181
}
8282

83+
"""
84+
Notebook entity - represents a computational notebook
85+
"""
86+
type Notebook @key(fields: "id") {
87+
id: ID!
88+
notebookName: String!
89+
description: String
90+
createdAt: DateTime!
91+
updatedAt: DateTime!
92+
organization: Organization!
93+
94+
"""
95+
Get preview image with signed URL
96+
"""
97+
preview: NotebookPreview
98+
}
99+
83100
"""
84101
Dataset entity - represents a dataset within an organization
85102
"""
@@ -136,6 +153,16 @@ type Query {
136153
"""
137154
osoApp_invitation(id: ID!): Invitation
138155

156+
"""
157+
Get notebooks, optionally filtered by organization name
158+
"""
159+
osoApp_notebooks(orgName: String): [Notebook!]!
160+
161+
"""
162+
Get a specific notebook by ID
163+
"""
164+
osoApp_notebook(notebookId: ID!): Notebook
165+
139166
"""
140167
Get list of datasets for a given organization
141168
"""
@@ -194,6 +221,13 @@ type Mutation {
194221
email: String!
195222
role: MemberRole!
196223
): AddUserByEmailPayload!
224+
225+
"""
226+
Save notebook preview PNG image
227+
"""
228+
osoApp_saveNotebookPreview(
229+
input: SaveNotebookPreviewInput!
230+
): SaveNotebookPreviewPayload!
197231
}
198232

199233
input CreateInvitationInput {
@@ -262,3 +296,23 @@ type UpdateProfilePayload {
262296
message: String!
263297
success: Boolean!
264298
}
299+
300+
type NotebookPreview {
301+
notebookId: ID!
302+
signedUrl: String!
303+
expiresAt: DateTime!
304+
}
305+
306+
input SaveNotebookPreviewInput {
307+
notebookId: ID!
308+
orgName: String!
309+
"""
310+
PNG image as data URL (data:image/png;base64,...)
311+
"""
312+
previewImage: String!
313+
}
314+
315+
type SaveNotebookPreviewPayload {
316+
success: Boolean!
317+
message: String!
318+
}

apps/frontend/app/api/v1/osograph/schema/resolvers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { organizationResolvers } from "@/app/api/v1/osograph/schema/resolvers/or
55
import { userResolvers } from "@/app/api/v1/osograph/schema/resolvers/user";
66
import { memberResolvers } from "@/app/api/v1/osograph/schema/resolvers/member";
77
import { invitationResolvers } from "@/app/api/v1/osograph/schema/resolvers/invitation";
8+
import { notebookResolvers } from "@/app/api/v1/osograph/schema/resolvers/notebook";
89
import { datasetResolver } from "@/app/api/v1/osograph/schema/resolvers/dataset";
910

1011
const dateTimeScalar = new GraphQLScalarType({
@@ -39,16 +40,19 @@ export const resolvers: GraphQLResolverMap<GraphQLContext> = {
3940
...userResolvers.Query,
4041
...organizationResolvers.Query,
4142
...invitationResolvers.Query,
43+
...notebookResolvers.Query,
4244
...datasetResolver.Query,
4345
},
4446
Mutation: {
4547
...userResolvers.Mutation,
4648
...memberResolvers.Mutation,
4749
...invitationResolvers.Mutation,
50+
...notebookResolvers.Mutation,
4851
},
4952
User: userResolvers.User,
5053
Organization: organizationResolvers.Organization,
5154
OrganizationMember: memberResolvers.OrganizationMember,
5255
Invitation: invitationResolvers.Invitation,
56+
Notebook: notebookResolvers.Notebook,
5357
Dataset: datasetResolver.Dataset,
5458
};
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { createAdminClient } from "@/lib/supabase/admin";
2+
import type { GraphQLResolverModule } from "@/app/api/v1/osograph/utils/types";
3+
import {
4+
requireAuthentication,
5+
requireOrgMembership,
6+
getOrganizationByName,
7+
getOrganization,
8+
type GraphQLContext,
9+
} from "@/app/api/v1/osograph/utils/auth";
10+
import {
11+
ServerErrors,
12+
NotebookErrors,
13+
OrganizationErrors,
14+
} from "@/app/api/v1/osograph/utils/errors";
15+
import { validateBase64PngImage } from "@/app/api/v1/osograph/utils/validation";
16+
import {
17+
putBase64Image,
18+
getPreviewSignedUrl,
19+
} from "@/lib/clients/cloudflare-r2";
20+
import { logger } from "@/lib/logger";
21+
22+
const PREVIEWS_BUCKET = "notebook-previews";
23+
const SIGNED_URL_EXPIRY = 900;
24+
25+
export const notebookResolvers: GraphQLResolverModule<GraphQLContext> = {
26+
Query: {
27+
osoApp_notebooks: async (
28+
_: unknown,
29+
args: { orgName?: string },
30+
context: GraphQLContext,
31+
) => {
32+
const authenticatedUser = requireAuthentication(context.user);
33+
const supabase = createAdminClient();
34+
35+
let query = supabase
36+
.from("notebooks")
37+
.select(
38+
"id, notebook_name, description, created_at, updated_at, org_id",
39+
)
40+
.is("deleted_at", null);
41+
42+
if (args.orgName) {
43+
const org = await getOrganizationByName(args.orgName);
44+
if (!org) {
45+
throw OrganizationErrors.notFound();
46+
}
47+
await requireOrgMembership(authenticatedUser.userId, org.id);
48+
query = query.eq("org_id", org.id);
49+
} else {
50+
const { data: memberships } = await supabase
51+
.from("users_by_organization")
52+
.select("org_id")
53+
.eq("user_id", authenticatedUser.userId)
54+
.is("deleted_at", null);
55+
56+
const orgIds = memberships?.map((m) => m.org_id) || [];
57+
if (orgIds.length === 0) {
58+
return [];
59+
}
60+
query = query.in("org_id", orgIds);
61+
}
62+
63+
const { data: notebooks, error } = await query;
64+
65+
if (error) {
66+
logger.error(`Failed to fetch notebooks: ${error}`);
67+
throw ServerErrors.database("Failed to fetch notebooks");
68+
}
69+
70+
return notebooks || [];
71+
},
72+
73+
osoApp_notebook: async (
74+
_: unknown,
75+
args: { notebookId: string },
76+
context: GraphQLContext,
77+
) => {
78+
const authenticatedUser = requireAuthentication(context.user);
79+
const supabase = createAdminClient();
80+
81+
const { data: notebook, error } = await supabase
82+
.from("notebooks")
83+
.select(
84+
"id, notebook_name, description, created_at, updated_at, org_id",
85+
)
86+
.eq("id", args.notebookId)
87+
.is("deleted_at", null)
88+
.single();
89+
90+
if (error || !notebook) {
91+
throw NotebookErrors.notFound();
92+
}
93+
94+
await requireOrgMembership(authenticatedUser.userId, notebook.org_id);
95+
96+
return notebook;
97+
},
98+
},
99+
100+
Mutation: {
101+
osoApp_saveNotebookPreview: async (
102+
_: unknown,
103+
args: {
104+
input: {
105+
notebookId: string;
106+
orgName: string;
107+
previewImage: string;
108+
};
109+
},
110+
context: GraphQLContext,
111+
) => {
112+
const authenticatedUser = requireAuthentication(context.user);
113+
const { notebookId, orgName, previewImage } = args.input;
114+
115+
const org = await getOrganizationByName(orgName);
116+
if (!org) {
117+
throw OrganizationErrors.notFound();
118+
}
119+
120+
await requireOrgMembership(authenticatedUser.userId, org.id);
121+
122+
try {
123+
logger.log(`Validating base64 PNG image for notebook ${notebookId}`);
124+
validateBase64PngImage(previewImage);
125+
126+
const objectKey = `${notebookId}.png`;
127+
logger.log(
128+
`Uploading notebook preview for ${notebookId} to bucket "${PREVIEWS_BUCKET}" with key "${objectKey}". Image size: ${previewImage.length} bytes`,
129+
);
130+
131+
await putBase64Image(PREVIEWS_BUCKET, objectKey, previewImage);
132+
133+
const response = {
134+
success: true,
135+
message: "Notebook preview saved successfully",
136+
};
137+
138+
logger.log(
139+
`Successfully saved notebook preview for ${notebookId} to bucket "${PREVIEWS_BUCKET}"`,
140+
);
141+
142+
return response;
143+
} catch (error) {
144+
logger.error(
145+
`Failed to save notebook preview for notebook ${notebookId}: ${error}`,
146+
);
147+
throw ServerErrors.storage("Failed to save notebook preview");
148+
}
149+
},
150+
},
151+
152+
Notebook: {
153+
__resolveReference: async (reference: { id: string }) => {
154+
const supabase = createAdminClient();
155+
const { data } = await supabase
156+
.from("notebooks")
157+
.select(
158+
"id, notebook_name, description, created_at, updated_at, org_id",
159+
)
160+
.eq("id", reference.id)
161+
.is("deleted_at", null)
162+
.single();
163+
return data;
164+
},
165+
166+
notebookName: (parent: { notebook_name: string }) => parent.notebook_name,
167+
createdAt: (parent: { created_at: string }) => parent.created_at,
168+
updatedAt: (parent: { updated_at: string }) => parent.updated_at,
169+
organization: (parent: { org_id: string }) =>
170+
getOrganization(parent.org_id),
171+
172+
preview: async (parent: { id: string }) => {
173+
try {
174+
const objectKey = `${parent.id}.png`;
175+
const signedUrl = await getPreviewSignedUrl(
176+
PREVIEWS_BUCKET,
177+
objectKey,
178+
SIGNED_URL_EXPIRY,
179+
);
180+
181+
return {
182+
notebookId: parent.id,
183+
signedUrl,
184+
expiresAt: new Date(Date.now() + SIGNED_URL_EXPIRY * 1000),
185+
};
186+
} catch (error) {
187+
logger.error(
188+
`Failed to generate preview URL for notebook ${parent.id}: ${error}`,
189+
);
190+
return null;
191+
}
192+
},
193+
},
194+
};

apps/frontend/app/api/v1/osograph/utils/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ export const ServerErrors = {
126126

127127
externalService: (rawErrorMessage?: string) =>
128128
createError(ErrorCode.EXTERNAL_SERVICE_ERROR, rawErrorMessage),
129+
130+
storage: (rawErrorMessage?: string) =>
131+
createError(ErrorCode.EXTERNAL_SERVICE_ERROR, rawErrorMessage),
129132
} as const;
130133

131134
export const OrganizationErrors = {
@@ -191,4 +194,8 @@ export const UserErrors = {
191194
createError(ErrorCode.BAD_USER_INPUT, "No fields provided to update"),
192195
} as const;
193196

197+
export const NotebookErrors = {
198+
notFound: () => createError(ErrorCode.NOT_FOUND, "Notebook not found"),
199+
} as const;
200+
194201
export const CatalogErrors = {};

apps/frontend/app/api/v1/osograph/utils/validation.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
11
import { z } from "zod";
22
import { ValidationErrors } from "@/app/api/v1/osograph/utils/errors";
33

4+
const MAX_PREVIEW_SIZE_MB = 1 * 1024 * 1024;
5+
const PNG_HEADER = Buffer.from([
6+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
7+
]); // PNG magic bytes
8+
9+
export function validateBase64PngImage(base64Data: string): void {
10+
if (!base64Data.startsWith("data:image/png;base64,")) {
11+
throw new Error("Invalid image format. Expected PNG data URL.");
12+
}
13+
14+
const base64Content = base64Data.replace(/^data:[^;]+;base64,/, "");
15+
16+
if (base64Content.length > (MAX_PREVIEW_SIZE_MB * 4) / 3) {
17+
throw new Error("Image is too large.");
18+
}
19+
20+
try {
21+
const buffer = Buffer.from(base64Content, "base64");
22+
23+
if (!buffer.subarray(0, 8).equals(PNG_HEADER)) {
24+
throw new Error("Invalid PNG header.");
25+
}
26+
27+
if (buffer.length > MAX_PREVIEW_SIZE_MB) {
28+
throw new Error("Image is too large.");
29+
}
30+
} catch (error) {
31+
if (error instanceof Error && error.message.includes("Invalid PNG")) {
32+
throw error;
33+
}
34+
throw new Error("Invalid base64 encoding or corrupted image data.");
35+
}
36+
}
37+
438
export const CreateInvitationSchema = z.object({
539
email: z.string().email("Invalid email address"),
640
orgName: z.string().min(1, "Organization name is required"),

0 commit comments

Comments
 (0)