@cedarjs/cli 7.0.0-canary.3113 → 7.0.0-canary.3114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ const UPLOAD_MODEL = `model Upload {
2
+ id String @id @default(cuid())
3
+ /// Name of the storage target, as configured in api/src/lib/uploads
4
+ target String
5
+ /// "pending" | "completed" | "failed"
6
+ status String @default("pending")
7
+ filename String
8
+ mimeType String
9
+ /// Bytes. BigInt because Int caps at 2 GB.
10
+ size BigInt
11
+ /// Provider-specific reference: S3 object key, filename on disk, or null
12
+ /// for files stored inline
13
+ storageKey String?
14
+ /// Inline bytes, only for DB targets
15
+ data Bytes?
16
+ /// The user the file belongs to, when it belongs to one
17
+ userId String?
18
+ /// Id of the upload token that created this row
19
+ tokenId String?
20
+ /// The organization the file belongs to, in multi-tenant apps
21
+ organizationId String?
22
+ createdAt DateTime @default(now())
23
+ updatedAt DateTime @updatedAt
24
+ }`;
25
+ function hasModel(schema, modelName) {
26
+ return new RegExp(`\\bmodel\\s+${modelName}\\s*\\{`).test(schema);
27
+ }
28
+ function addUploadModel(schema) {
29
+ if (hasModel(schema, "Upload")) {
30
+ return schema;
31
+ }
32
+ return `${schema.trimEnd()}
33
+
34
+ ${UPLOAD_MODEL}
35
+ `;
36
+ }
37
+ export {
38
+ UPLOAD_MODEL,
39
+ addUploadModel,
40
+ hasModel
41
+ };
@@ -0,0 +1,151 @@
1
+ const START_LINE = /^([ \t]*)await server\.start\(\)/m;
2
+ const FACTORY_IMPORT = /import\s*\{[^}]*\bcreateAuthDecoder\b[^}]*\}\s*from\s*['"](@cedarjs\/auth-[a-z0-9-]+-api)['"]/;
3
+ const DECODER_IMPORT = /import\s*\{[^}]*\bauthDecoder\b[^}]*\}\s*from\s*['"](@cedarjs\/auth-[a-z0-9-]+-api)['"]/;
4
+ function detectServerAuth(graphqlSource) {
5
+ const factory = FACTORY_IMPORT.exec(graphqlSource);
6
+ if (factory) {
7
+ return { decoderPackage: factory[1], usesFactory: true };
8
+ }
9
+ const decoder = DECODER_IMPORT.exec(graphqlSource);
10
+ if (decoder) {
11
+ return { decoderPackage: decoder[1], usesFactory: false };
12
+ }
13
+ return null;
14
+ }
15
+ function importedBindings(source) {
16
+ const names = /* @__PURE__ */ new Set();
17
+ const statements = source.matchAll(
18
+ /^import\s+(type\s+)?([^;]*?)\s+from\s+['"][^'"]+['"]/gm
19
+ );
20
+ for (const [, typeOnly, clause] of statements) {
21
+ if (typeOnly) {
22
+ continue;
23
+ }
24
+ const braces = /\{([^}]*)\}/.exec(clause);
25
+ const defaultOrNamespace = clause.replace(/\{[^}]*\}/, "");
26
+ for (const part of defaultOrNamespace.split(",")) {
27
+ const local = part.replace(/^\s*\*\s+as\s+/, "").trim();
28
+ if (local) {
29
+ names.add(local);
30
+ }
31
+ }
32
+ for (const specifier of braces?.[1].split(",") ?? []) {
33
+ const trimmed = specifier.trim();
34
+ if (!trimmed || trimmed.startsWith("type ")) {
35
+ continue;
36
+ }
37
+ const alias = / as (\w+)$/.exec(trimmed);
38
+ names.add(alias ? alias[1] : trimmed);
39
+ }
40
+ }
41
+ return names;
42
+ }
43
+ function hasBinding(source, name) {
44
+ const declared = new RegExp(
45
+ `^(?:export\\s+)?(?:const|let|var|function)\\s+${name}\\b`,
46
+ "m"
47
+ );
48
+ return importedBindings(source).has(name) || declared.test(source);
49
+ }
50
+ function uploadsServerImports(source, auth) {
51
+ const packageSpecs = [];
52
+ const srcSpecs = [];
53
+ if (auth) {
54
+ if (auth.usesFactory) {
55
+ if (!hasBinding(source, "authDecoder")) {
56
+ packageSpecs.push({
57
+ names: ["createAuthDecoder"],
58
+ from: auth.decoderPackage
59
+ });
60
+ }
61
+ } else {
62
+ packageSpecs.push({ names: ["authDecoder"], from: auth.decoderPackage });
63
+ }
64
+ }
65
+ packageSpecs.push({
66
+ names: auth ? ["cedarUploadsPlugin", "createUploadAuthenticator"] : ["cedarUploadsPlugin"],
67
+ from: "@cedarjs/uploads"
68
+ });
69
+ if (auth) {
70
+ const authNames = ["getCurrentUser"];
71
+ if (auth.usesFactory && !hasBinding(source, "authDecoder")) {
72
+ authNames.unshift("cookieName");
73
+ }
74
+ srcSpecs.push({ names: authNames, from: "src/lib/auth" });
75
+ }
76
+ srcSpecs.push({ names: ["db"], from: "src/lib/db" });
77
+ srcSpecs.push({ names: ["targets"], from: "src/lib/uploads" });
78
+ const render = (specs) => specs.map((spec) => ({
79
+ ...spec,
80
+ names: spec.names.filter((name) => !hasBinding(source, name))
81
+ })).filter((spec) => spec.names.length > 0).map((spec) => `import { ${spec.names.join(", ")} } from '${spec.from}'`);
82
+ const packageLines = render(packageSpecs);
83
+ const srcLines = render(srcSpecs);
84
+ if (packageLines.length > 0 && srcLines.length > 0) {
85
+ return [...packageLines, "", ...srcLines];
86
+ }
87
+ return [...packageLines, ...srcLines];
88
+ }
89
+ function uploadsServerRegistration(source, auth) {
90
+ if (!auth) {
91
+ return ` await server.register(cedarUploadsPlugin, {
92
+ tokenSecret: process.env.UPLOAD_TOKEN_SECRET,
93
+ targets,
94
+ db,
95
+ // Once the app has auth, bind upload tokens to the logged-in user:
96
+ //
97
+ // authenticate: createUploadAuthenticator({ authDecoder, getCurrentUser }),
98
+ })
99
+ `;
100
+ }
101
+ const decoder = auth.usesFactory && !hasBinding(source, "authDecoder") ? " const authDecoder = createAuthDecoder(cookieName)\n\n" : "";
102
+ return `${decoder} await server.register(cedarUploadsPlugin, {
103
+ tokenSecret: process.env.UPLOAD_TOKEN_SECRET,
104
+ targets,
105
+ db,
106
+ // Binds upload tokens to the logged-in user through the same auth
107
+ // pipeline the GraphQL server uses
108
+ authenticate: createUploadAuthenticator({ authDecoder, getCurrentUser }),
109
+ })
110
+ `;
111
+ }
112
+ function hasUploadsPlugin(source) {
113
+ return /^[ \t]*(?:(?:await|void)\s+|(?:const|let|var)\s+\w+\s*=\s*)?[\w.]+\.register\(\s*cedarUploadsPlugin\b/m.test(
114
+ source
115
+ );
116
+ }
117
+ function addUploadsPlugin(source, { auth = null } = {}) {
118
+ if (hasUploadsPlugin(source)) {
119
+ return source;
120
+ }
121
+ const start = START_LINE.exec(source);
122
+ if (!start) {
123
+ throw new Error("CEDAR_UPLOADS_ERR_NO_START");
124
+ }
125
+ const lines = source.split("\n");
126
+ let lastImport = -1;
127
+ for (const [index, line] of lines.entries()) {
128
+ if (/^import\s/.test(line)) {
129
+ lastImport = index;
130
+ }
131
+ }
132
+ const withImports = [
133
+ ...lines.slice(0, lastImport + 1),
134
+ ...uploadsServerImports(source, auth),
135
+ ...lines.slice(lastImport + 1)
136
+ ].join("\n");
137
+ return withImports.replace(
138
+ START_LINE,
139
+ (line) => `${uploadsServerRegistration(source, auth)}
140
+ ${line}`
141
+ );
142
+ }
143
+ export {
144
+ addUploadsPlugin,
145
+ detectServerAuth,
146
+ hasBinding,
147
+ hasUploadsPlugin,
148
+ importedBindings,
149
+ uploadsServerImports,
150
+ uploadsServerRegistration
151
+ };
@@ -0,0 +1,15 @@
1
+ import { createRequireUploadTokenDirective } from '@cedarjs/uploads'
2
+
3
+ /**
4
+ * Requires a valid upload token in the x-upload-token request header and
5
+ * makes its claims available to the resolver through
6
+ * `getUploadTokenPayload(context)`. The token must have been issued to the
7
+ * requesting user.
8
+ */
9
+ const requireUploadToken = createRequireUploadTokenDirective({
10
+ secret: process.env.UPLOAD_TOKEN_SECRET ?? '',
11
+ })
12
+
13
+ export const schema = requireUploadToken.schema
14
+
15
+ export default requireUploadToken
@@ -1,25 +1,24 @@
1
- import { createUploadsConfig, setupStorage } from '@cedarjs/storage'
2
- import { FileSystemStorage } from '@cedarjs/storage/FileSystemStorage'
3
- import { UrlSigner } from '@cedarjs/storage/UrlSigner'
1
+ __PATH_IMPORT____S3_IMPORTS__import {
2
+ __FS_IMPORT__
3
+ __DB_IMPORT__
4
+ __DB_MAX_IMPORT__
5
+ defineStorageTargets,
6
+ defineUploadProfiles,
7
+ } from '@cedarjs/uploads'
8
+ __GET_PATHS_IMPORT__
9
+ __S3_CLIENT__
10
+ /**
11
+ * Where files live. Each target is a provider instance; the Upload row
12
+ * records which target holds a file so it can be read, served, and deleted
13
+ * later. Look one up by name with `resolveTarget(targets, upload.target)`.
14
+ */
15
+ export const targets = defineStorageTargets({
16
+ __TARGETS__})
4
17
 
5
- const uploadsConfig = createUploadsConfig({
6
- // Configure your fields here
7
- // e.g. modelName: { fields: ['fieldWithUpload']}
8
- })
9
-
10
- export const fsStorage = new FileSystemStorage({
11
- baseDir: './uploads',
12
- })
13
-
14
- export const urlSigner = new UrlSigner({
15
- secret: process.env.UPLOADS_SECRET,
16
- endpoint: '/signedUrl',
17
- })
18
-
19
- const { saveFiles, storagePrismaExtension } = setupStorage({
20
- uploadsConfig,
21
- storageAdapter: fsStorage,
22
- urlSigner,
23
- })
24
-
25
- export { saveFiles, storagePrismaExtension }
18
+ /**
19
+ * What clients may upload. A profile names a target and the constraints the
20
+ * server signs into every upload token issued for it. The client never
21
+ * supplies constraints; it names a profile.
22
+ */
23
+ export const profiles = defineUploadProfiles({
24
+ __PROFILES__})
@@ -0,0 +1,79 @@
1
+ export const schema = gql`
2
+ type Upload {
3
+ id: String!
4
+ target: String!
5
+ status: String!
6
+ filename: String!
7
+ mimeType: String!
8
+ size: BigInt!
9
+ storageKey: String
10
+ userId: String
11
+ organizationId: String
12
+ createdAt: DateTime!
13
+ updatedAt: DateTime!
14
+ }
15
+
16
+ type UploadToken {
17
+ token: String!
18
+ """
19
+ The profile's constraints, echoed for client-side UX (file-picker
20
+ filters, pre-upload validation). The authoritative copy is inside the
21
+ signed token.
22
+ """
23
+ allowedMimeTypes: [String!]!
24
+ maxFileSize: BigInt!
25
+ maxFiles: Int!
26
+ }
27
+
28
+ type PresignedUploadUrl {
29
+ uploadId: String!
30
+ url: String!
31
+ method: String!
32
+ headers: JSON!
33
+ }
34
+
35
+ input CreatePresignedUploadUrlInput {
36
+ filename: String!
37
+ contentType: String!
38
+ size: BigInt!
39
+ }
40
+
41
+ input UploadFileInput {
42
+ filename: String!
43
+ mimeType: String!
44
+ """
45
+ The file's bytes, base64-encoded. Only for profiles whose target stores
46
+ files inline in the database.
47
+ """
48
+ data: String!
49
+ }
50
+
51
+ type Query {
52
+ """
53
+ Issues an upload token for a server-defined profile. The profile name
54
+ must match a key of \`profiles\` in api/src/lib/uploads.
55
+ """
56
+ requestUploadToken(profile: String!): UploadToken! @requireAuth
57
+ }
58
+
59
+ type Mutation {
60
+ """
61
+ Direct-to-storage uploads: returns a presigned URL for one file. Send the
62
+ upload token in the x-upload-token header.
63
+ """
64
+ createPresignedUploadUrl(
65
+ input: CreatePresignedUploadUrlInput!
66
+ ): PresignedUploadUrl! @requireAuth @requireUploadToken
67
+
68
+ """
69
+ Confirms a direct-to-storage upload once the client has PUT the bytes.
70
+ """
71
+ confirmUpload(uploadId: String!): Upload! @requireAuth
72
+
73
+ """
74
+ Stores a small file sent as base64, for profiles whose target keeps files
75
+ inline in the database.
76
+ """
77
+ uploadFile(profile: String!, input: UploadFileInput!): Upload! @requireAuth
78
+ }
79
+ `
@@ -0,0 +1,89 @@
1
+ import {
2
+ confirmUpload as confirmUploadRecord,
3
+ createPresignedUpload,
4
+ getUploadTokenPayload,
5
+ isMimeTypeAllowed,
6
+ issueUploadToken,
7
+ resolveProfile,
8
+ resolveTarget,
9
+ storeFile,
10
+ UploadError,
11
+ } from '@cedarjs/uploads'
12
+
13
+ import { db } from 'src/lib/db'
14
+ import { profiles, targets } from 'src/lib/uploads'
15
+
16
+ const secret = () => {
17
+ if (!process.env.UPLOAD_TOKEN_SECRET) {
18
+ throw new Error(
19
+ 'UPLOAD_TOKEN_SECRET is not set. Generate one with `yarn cedar generate secret` and add it to .env.',
20
+ )
21
+ }
22
+
23
+ return process.env.UPLOAD_TOKEN_SECRET
24
+ }
25
+
26
+ export const requestUploadToken = ({ profile }: { profile: string }) => {
27
+ return issueUploadToken({
28
+ profiles,
29
+ profile,
30
+ secret: secret(),
31
+ currentUser: context.currentUser,
32
+ })
33
+ }
34
+
35
+ export const createPresignedUploadUrl = ({
36
+ input,
37
+ }: {
38
+ input: { filename: string; contentType: string; size: bigint | number }
39
+ }) => {
40
+ return createPresignedUpload({
41
+ db,
42
+ targets,
43
+ tokenPayload: getUploadTokenPayload(context),
44
+ input,
45
+ })
46
+ }
47
+
48
+ export const confirmUpload = ({ uploadId }: { uploadId: string }) => {
49
+ return confirmUploadRecord({
50
+ db,
51
+ targets,
52
+ uploadId,
53
+ currentUser: context.currentUser,
54
+ })
55
+ }
56
+
57
+ export const uploadFile = async ({
58
+ profile: profileName,
59
+ input,
60
+ }: {
61
+ profile: string
62
+ input: { filename: string; mimeType: string; data: string }
63
+ }) => {
64
+ const profile = resolveProfile(profiles, profileName)
65
+ const target = resolveTarget(targets, profile.target)
66
+
67
+ if (target.providerType !== 'db') {
68
+ throw new UploadError(
69
+ 'NOT_SUPPORTED',
70
+ `Upload profile '${profile.name}' does not store files in the database. Use an upload token instead.`,
71
+ )
72
+ }
73
+
74
+ if (!isMimeTypeAllowed(profile.allowedMimeTypes, input.mimeType)) {
75
+ throw new UploadError(
76
+ 'MIME_TYPE_NOT_ALLOWED',
77
+ `File type '${input.mimeType}' is not allowed for upload profile '${profile.name}'.`,
78
+ )
79
+ }
80
+
81
+ return storeFile(target, {
82
+ db,
83
+ filename: input.filename,
84
+ mimeType: input.mimeType,
85
+ data: Buffer.from(input.data, 'base64'),
86
+ maxSize: profile.maxFileSize,
87
+ userId: context.currentUser ? String(context.currentUser.id) : undefined,
88
+ })
89
+ }
@@ -0,0 +1,14 @@
1
+ import { createWithDataUriDirective } from '@cedarjs/uploads'
2
+
3
+ import { db } from 'src/lib/db'
4
+ import { targets } from 'src/lib/uploads'
5
+
6
+ /**
7
+ * Resolves a field holding an Upload id to a base64 data: URI of the file.
8
+ * Every resolved field embeds the whole file, so use it for small files only.
9
+ */
10
+ const withDataUri = createWithDataUriDirective({ db, targets })
11
+
12
+ export const schema = withDataUri.schema
13
+
14
+ export default withDataUri
@@ -0,0 +1,16 @@
1
+ import { createWithSignedUrlDirective } from '@cedarjs/uploads'
2
+
3
+ import { db } from 'src/lib/db'
4
+ import { targets } from 'src/lib/uploads'
5
+
6
+ /**
7
+ * Resolves a field holding an Upload id to a time-limited URL for the file,
8
+ * from whichever storage target holds it. Files download by default; pass
9
+ * `disposition: 'inline'` to render them in the browser instead, and only
10
+ * for MIME types you trust.
11
+ */
12
+ const withSignedUrl = createWithSignedUrlDirective({ db, targets })
13
+
14
+ export const schema = withSignedUrl.schema
15
+
16
+ export default withSignedUrl
@@ -1,26 +1,68 @@
1
+ import prompts from "prompts";
1
2
  import { recordTelemetryAttributes } from "@cedarjs/cli-helpers/telemetry";
3
+ import { requireTTYOrExit } from "@cedarjs/cli-helpers/tty";
2
4
  const command = "uploads";
3
- const description = "Setup uploads and storage. This will install the required packages and add the required initial configuration to your redwood app.";
5
+ const description = "Set up file uploads and storage: storage targets, upload profiles, the upload routes, and the GraphQL API";
6
+ const TARGET_CHOICES = ["fs", "db", "s3"];
7
+ const TARGET_DESCRIPTIONS = {
8
+ fs: "Local filesystem, uploaded through the api server (development, single-server deploys)",
9
+ db: "Inline in the database, for small files like avatars and thumbnails",
10
+ s3: "S3 or S3-compatible storage, uploaded straight from the browser with presigned URLs"
11
+ };
4
12
  const builder = (yargs) => {
5
- yargs.option("force", {
13
+ return yargs.option("targets", {
14
+ choices: TARGET_CHOICES,
15
+ description: "Storage targets to configure: fs (local filesystem), db (inline in the database), s3 (direct-to-S3 presigned uploads). Prompts when omitted.",
16
+ type: "array"
17
+ }).option("force", {
6
18
  alias: "f",
7
19
  default: false,
8
20
  description: "Overwrite existing configuration",
9
21
  type: "boolean"
10
22
  });
11
23
  };
24
+ function toTargetChoices(values) {
25
+ const choices = TARGET_CHOICES;
26
+ return [...new Set(values)].filter(
27
+ (t) => choices.includes(t)
28
+ );
29
+ }
30
+ async function promptForTargets() {
31
+ requireTTYOrExit("--targets");
32
+ const response = await prompts({
33
+ type: "multiselect",
34
+ name: "targets",
35
+ message: "Which storage targets do you want to configure?",
36
+ instructions: false,
37
+ hint: "Space to toggle, enter to confirm",
38
+ min: 1,
39
+ choices: TARGET_CHOICES.map((value) => ({
40
+ title: value,
41
+ description: TARGET_DESCRIPTIONS[value],
42
+ value,
43
+ selected: value !== "s3"
44
+ }))
45
+ });
46
+ if (response.targets === void 0) {
47
+ process.exit(0);
48
+ }
49
+ return toTargetChoices(response.targets);
50
+ }
12
51
  const handler = async (options) => {
52
+ const targets = options.targets?.length ? toTargetChoices(options.targets) : await promptForTargets();
13
53
  recordTelemetryAttributes({
14
54
  command: "setup uploads",
15
- force: options.force,
16
- skipExamples: options.skipExamples
55
+ targets: targets.join(","),
56
+ force: options.force
17
57
  });
18
58
  const { handler: handler2 } = await import("./uploadsHandler.js");
19
- return handler2(options);
59
+ return handler2({ targets, force: options.force });
20
60
  };
21
61
  export {
62
+ TARGET_CHOICES,
22
63
  builder,
23
64
  command,
24
65
  description,
25
- handler
66
+ handler,
67
+ toTargetChoices
26
68
  };
@@ -1,91 +1,265 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
3
4
  import { Listr } from "listr2";
4
5
  import { format } from "prettier";
5
6
  import { terminalLink } from "termi-link";
6
7
  import { colors as c } from "@cedarjs/cli-helpers/colors";
7
8
  import { getPrettierOptions } from "@cedarjs/cli-helpers/fileHelpers";
8
- import { addApiPackages } from "@cedarjs/cli-helpers/installHelpers";
9
+ import {
10
+ addApiPackages,
11
+ addWebPackages
12
+ } from "@cedarjs/cli-helpers/installHelpers";
9
13
  import { formatCedarCommand } from "@cedarjs/cli-helpers/packageManager/display";
14
+ import { addEnvVar } from "@cedarjs/cli-helpers/project";
15
+ import { getSchemaPath } from "@cedarjs/project-config";
10
16
  import { errorTelemetry } from "@cedarjs/telemetry";
11
17
  import { getPaths, transformTSToJS, writeFile } from "../../../lib/index.js";
12
18
  import { isTypeScriptProject } from "../../../lib/project.js";
13
- import { runTransform } from "../../../lib/runTransform.js";
14
- const handler = async ({ force }) => {
19
+ import { generateSecret } from "../../generate/secret/secret.js";
20
+ import { setupServerFileTasks } from "../server-file/serverFileHandler.js";
21
+ import { addUploadModel } from "./schemaPrisma.js";
22
+ import {
23
+ addUploadsPlugin,
24
+ detectServerAuth,
25
+ hasUploadsPlugin
26
+ } from "./serverFile.js";
27
+ const UPPY_VERSION = "^6.0.0";
28
+ const S3_IMPORTS = `import { S3Client } from '@aws-sdk/client-s3'
29
+
30
+ import { createS3Provider } from '@cedarjs/uploads/s3'
31
+ `;
32
+ const S3_CLIENT = `
33
+ // Your own S3 client. It stays available for S3-specific work (lifecycle
34
+ // rules, CORS, and so on) alongside the storage target below. Credentials
35
+ // come from the SDK's default chain: AWS_ACCESS_KEY_ID and
36
+ // AWS_SECRET_ACCESS_KEY in .env, or an IAM role, SSO, or container
37
+ // credentials in production. For an S3-compatible service, add \`endpoint\`
38
+ // and \`forcePathStyle: true\`.
39
+ export const s3Client = new S3Client({
40
+ region: process.env.AWS_REGION,
41
+ })
42
+ `;
43
+ const TARGET_BLOCKS = {
44
+ fs: ` // Files uploaded through the api server and stored on local disk. Good for
45
+ // development and single-server deploys.
46
+ local: createFsProvider({
47
+ uploadDir: path.join(getPaths().api.base, '.uploads'),
48
+ serveBaseUrl: process.env.UPLOAD_SERVE_BASE_URL,
49
+ signSecret: process.env.UPLOAD_TOKEN_SECRET,
50
+ }),
51
+ `,
52
+ db: ` // Small files stored inline in the Upload row (avatars, thumbnails).
53
+ thumbnails: createDbProvider(),
54
+ `,
55
+ s3: ` // Files uploaded straight from the browser to S3 with presigned URLs.
56
+ files: createS3Provider({
57
+ client: s3Client,
58
+ bucket: process.env.S3_BUCKET_UPLOADS,
59
+ region: process.env.AWS_REGION,
60
+ keyPrefix: 'uploads/',
61
+ }),
62
+ `
63
+ };
64
+ const PROFILE_BLOCKS = {
65
+ fs: ` attachment: {
66
+ target: 'local',
67
+ allowedMimeTypes: ['application/pdf', 'image/png', 'image/jpeg'],
68
+ maxFileSize: 25 * 1024 * 1024,
69
+ maxFiles: 10,
70
+ },
71
+ `,
72
+ db: ` avatar: {
73
+ target: 'thumbnails',
74
+ allowedMimeTypes: ['image/png', 'image/jpeg'],
75
+ maxFileSize: DB_MAX_FILE_SIZE,
76
+ maxFiles: 1,
77
+ },
78
+ `,
79
+ s3: ` document: {
80
+ target: 'files',
81
+ allowedMimeTypes: ['application/pdf', 'image/*'],
82
+ maxFileSize: 100 * 1024 * 1024,
83
+ maxFiles: 5,
84
+ },
85
+ `
86
+ };
87
+ const handler = async ({ targets, force }) => {
88
+ const paths = getPaths();
15
89
  const projectIsTypescript = isTypeScriptProject();
16
- const packageJson = await import(path.join(getPaths().base, "package.json"), {
17
- with: { type: "json" }
18
- });
90
+ const ext = projectIsTypescript ? "ts" : "js";
91
+ const wantsFs = targets.includes("fs");
92
+ const wantsDb = targets.includes("db");
93
+ const wantsS3 = targets.includes("s3");
94
+ const wantsUppy = wantsFs || wantsS3;
95
+ const packageJson = await import(pathToFileURL(path.join(paths.base, "package.json")).href, { with: { type: "json" } });
19
96
  const cedarVersion = packageJson.default.devDependencies["@cedarjs/core"] ?? "latest";
97
+ const serverFilePath = path.join(paths.api.src, `server.${ext}`);
98
+ const writeTemplateFile = async (templateFile, outputPath, replacements = {}) => {
99
+ const templatePath = path.resolve(
100
+ import.meta.dirname,
101
+ "templates",
102
+ templateFile
103
+ );
104
+ let templateContent = fs.readFileSync(templatePath, "utf-8");
105
+ for (const [token, value] of Object.entries(replacements)) {
106
+ templateContent = templateContent.replaceAll(token, value);
107
+ }
108
+ const content = projectIsTypescript ? templateContent : await transformTSToJS(outputPath, templateContent);
109
+ return writeFile(outputPath, content, { overwriteExisting: force });
110
+ };
111
+ const apiPackages = [`@cedarjs/uploads@${cedarVersion}`];
112
+ const webPackages = [`@cedarjs/uploads@${cedarVersion}`];
113
+ if (wantsS3) {
114
+ apiPackages.push("@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner");
115
+ }
116
+ if (wantsUppy) {
117
+ webPackages.push(
118
+ `@uppy/core@${UPPY_VERSION}`,
119
+ `@uppy/react@${UPPY_VERSION}`,
120
+ `@uppy/dashboard@${UPPY_VERSION}`
121
+ );
122
+ }
123
+ if (wantsFs) {
124
+ webPackages.push(`@uppy/xhr-upload@${UPPY_VERSION}`);
125
+ }
126
+ if (wantsS3) {
127
+ webPackages.push(`@uppy/aws-s3@${UPPY_VERSION}`);
128
+ }
20
129
  const tasks = new Listr(
21
130
  [
22
131
  {
23
- title: `Adding api/src/lib/uploads.${projectIsTypescript ? "ts" : "js"}...`,
132
+ ...addApiPackages(apiPackages),
133
+ title: "Adding @cedarjs/uploads to your api side..."
134
+ },
135
+ {
136
+ ...addWebPackages(webPackages),
137
+ title: "Adding @cedarjs/uploads to your web side..."
138
+ },
139
+ {
140
+ title: "Adding the Upload model to schema.prisma...",
24
141
  task: async () => {
25
- const templatePath = path.resolve(
26
- import.meta.dirname,
27
- "templates",
28
- "srcLibUploads.ts.template"
29
- );
30
- const templateContent = fs.readFileSync(templatePath, {
31
- encoding: "utf8",
32
- flag: "r"
33
- });
34
- const uploadsPath = path.join(
35
- getPaths().api.lib,
36
- `uploads.${projectIsTypescript ? "ts" : "js"}`
142
+ const schemaPath = await getSchemaPath(paths.api.prismaConfig);
143
+ const schema = fs.readFileSync(schemaPath, "utf-8");
144
+ fs.writeFileSync(schemaPath, addUploadModel(schema));
145
+ }
146
+ },
147
+ {
148
+ title: `Adding api/src/lib/uploads.${ext}...`,
149
+ task: async () => {
150
+ await writeTemplateFile(
151
+ "srcLibUploads.ts.template",
152
+ path.join(paths.api.lib, `uploads.${ext}`),
153
+ {
154
+ __S3_IMPORTS__: wantsS3 ? S3_IMPORTS : "",
155
+ __S3_CLIENT__: wantsS3 ? S3_CLIENT : "",
156
+ __FS_IMPORT__: wantsFs ? "createFsProvider," : "",
157
+ __DB_IMPORT__: wantsDb ? "createDbProvider," : "",
158
+ __DB_MAX_IMPORT__: wantsDb ? "DB_MAX_FILE_SIZE," : "",
159
+ __PATH_IMPORT__: wantsFs ? "import path from 'node:path'\n\n" : "",
160
+ __GET_PATHS_IMPORT__: wantsFs ? "import { getPaths } from '@cedarjs/project-config'\n" : "",
161
+ __TARGETS__: targets.map((t) => TARGET_BLOCKS[t]).join("\n"),
162
+ __PROFILES__: targets.map((t) => PROFILE_BLOCKS[t]).join("\n")
163
+ }
37
164
  );
38
- const uploadsContent = projectIsTypescript ? templateContent : await transformTSToJS(uploadsPath, templateContent);
39
- return writeFile(uploadsPath, uploadsContent, {
40
- overwriteExisting: force
41
- });
42
165
  }
43
166
  },
44
167
  {
45
- title: `Adding signedUrl function...`,
168
+ title: "Adding the uploads SDL and service...",
46
169
  task: async () => {
47
- const templatePath = path.resolve(
48
- import.meta.dirname,
49
- "templates",
50
- "signedUrl.ts.template"
170
+ await writeTemplateFile(
171
+ "uploads.sdl.ts.template",
172
+ path.join(paths.api.graphql, `uploads.sdl.${ext}`)
51
173
  );
52
- const templateContent = fs.readFileSync(templatePath, {
53
- encoding: "utf8",
54
- flag: "r"
55
- });
56
- const uploadsPath = path.join(
57
- getPaths().api.functions,
58
- `signedUrl.${projectIsTypescript ? "ts" : "js"}`
174
+ await writeTemplateFile(
175
+ "uploads.ts.template",
176
+ path.join(paths.api.services, "uploads", `uploads.${ext}`)
59
177
  );
60
- const uploadsContent = projectIsTypescript ? templateContent : await transformTSToJS(uploadsPath, templateContent);
61
- return writeFile(uploadsPath, uploadsContent, {
62
- overwriteExisting: force
63
- });
64
178
  }
65
179
  },
66
180
  {
67
- ...addApiPackages([`@cedarjs/storage@${cedarVersion}`]),
68
- title: "Adding dependencies to your api side..."
181
+ title: "Adding the upload directives...",
182
+ task: async () => {
183
+ const directives = ["requireUploadToken", "withSignedUrl"];
184
+ if (wantsDb) {
185
+ directives.push("withDataUri");
186
+ }
187
+ for (const name of directives) {
188
+ await writeTemplateFile(
189
+ `${name}.ts.template`,
190
+ path.join(paths.api.directives, name, `${name}.${ext}`)
191
+ );
192
+ }
193
+ }
194
+ },
195
+ {
196
+ title: "Adding the server file...",
197
+ task: (_ctx, task) => task.newListr(setupServerFileTasks({ force: false })),
198
+ skip: () => fs.existsSync(serverFilePath) ? "api/src/server already exists; skipping." : false
69
199
  },
70
200
  {
71
- title: "Modifying api/src/lib/db to add uploads prisma extension..",
72
- task: async () => {
73
- const dbPath = path.join(
74
- getPaths().api.lib,
75
- `db.${projectIsTypescript ? "ts" : "js"}`
76
- );
77
- const transformResult = await runTransform({
78
- transformPath: path.join(import.meta.dirname, "dbCodemod.js"),
79
- targetPaths: [dbPath]
80
- });
81
- if (transformResult.error) {
82
- if (transformResult.error === "RW_CODEMOD_ERR_OLD_FORMAT") {
83
- throw new Error(
84
- "It looks like your src/lib/db file is using the old format. Please update it as per the v8 upgrade guide: https://cedarjs.com/docs/8.x/upgrade-guides/v8#database-file-structure-change. And run again. \n\nYou can also manually modify your api/src/lib/db to include the prisma extension: https://cedarjs.com/docs/uploads/#attaching-the-prisma-extension"
85
- );
86
- }
201
+ title: "Registering the upload plugin in api/src/server...",
202
+ task: async (_ctx, task) => {
203
+ const source = fs.readFileSync(serverFilePath, "utf-8");
204
+ if (hasUploadsPlugin(source)) {
205
+ task.skip("The upload plugin is already registered; skipping.");
206
+ return;
207
+ }
208
+ const graphqlPath = path.join(paths.api.functions, `graphql.${ext}`);
209
+ const authPath = path.join(paths.api.lib, `auth.${ext}`);
210
+ const auth = fs.existsSync(graphqlPath) && fs.existsSync(authPath) && fs.readFileSync(authPath, "utf-8").includes("getCurrentUser") ? detectServerAuth(fs.readFileSync(graphqlPath, "utf-8")) : null;
211
+ let updated;
212
+ try {
213
+ updated = addUploadsPlugin(source, { auth });
214
+ } catch {
87
215
  throw new Error(
88
- "Could not add the prisma extension. \n Please modify your api/src/lib/db to include the prisma extension: https://cedarjs.com/docs/uploads/#attaching-the-prisma-extension"
216
+ `Could not find \`await server.start()\` in ${path.relative(
217
+ paths.base,
218
+ serverFilePath
219
+ )}. Register the plugin by hand before the server starts:
220
+
221
+ import { cedarUploadsPlugin } from '@cedarjs/uploads'
222
+ import { db } from 'src/lib/db'
223
+ import { targets } from 'src/lib/uploads'
224
+
225
+ await server.register(cedarUploadsPlugin, {
226
+ tokenSecret: process.env.UPLOAD_TOKEN_SECRET,
227
+ targets,
228
+ db,
229
+ })
230
+ `
231
+ );
232
+ }
233
+ fs.writeFileSync(serverFilePath, updated);
234
+ }
235
+ },
236
+ {
237
+ title: "Adding environment variables to .env...",
238
+ task: () => {
239
+ addEnvVar(
240
+ "UPLOAD_TOKEN_SECRET",
241
+ generateSecret(),
242
+ "Signs upload tokens and file-serving URLs. Keep it secret and use a different value per environment."
243
+ );
244
+ if (wantsFs) {
245
+ addEnvVar(
246
+ "UPLOAD_SERVE_BASE_URL",
247
+ "http://localhost:8911",
248
+ "Origin the api server is reachable at, used to build signed URLs for locally stored files."
249
+ );
250
+ }
251
+ if (wantsS3) {
252
+ addEnvVar(
253
+ "AWS_REGION",
254
+ "us-east-1",
255
+ "Region of the uploads bucket."
256
+ );
257
+ addEnvVar("AWS_ACCESS_KEY_ID", "replace-me", "");
258
+ addEnvVar("AWS_SECRET_ACCESS_KEY", "replace-me", "");
259
+ addEnvVar(
260
+ "S3_BUCKET_UPLOADS",
261
+ "replace-me",
262
+ "Bucket direct uploads land in."
89
263
  );
90
264
  }
91
265
  }
@@ -94,10 +268,10 @@ const handler = async ({ force }) => {
94
268
  title: "Prettifying changed files",
95
269
  task: async (_ctx, task) => {
96
270
  const prettifyPaths = [
97
- path.join(getPaths().api.lib, "db.js"),
98
- path.join(getPaths().api.lib, "db.ts"),
99
- path.join(getPaths().api.lib, "uploads.js"),
100
- path.join(getPaths().api.lib, "uploads.ts")
271
+ serverFilePath,
272
+ path.join(paths.api.lib, `uploads.${ext}`),
273
+ path.join(paths.api.graphql, `uploads.sdl.${ext}`),
274
+ path.join(paths.api.services, "uploads", `uploads.${ext}`)
101
275
  ];
102
276
  for (const prettifyPath of prettifyPaths) {
103
277
  try {
@@ -106,11 +280,11 @@ const handler = async ({ force }) => {
106
280
  }
107
281
  const source = fs.readFileSync(prettifyPath, "utf-8");
108
282
  const prettierOptions = await getPrettierOptions();
109
- const prettifiedApp = await format(source, {
283
+ const prettified = await format(source, {
110
284
  ...prettierOptions,
111
285
  parser: "babel-ts"
112
286
  });
113
- fs.writeFileSync(prettifyPath, prettifiedApp, "utf-8");
287
+ fs.writeFileSync(prettifyPath, prettified, "utf-8");
114
288
  } catch {
115
289
  task.output = "Couldn't prettify the changes. Please reformat the files manually if needed.";
116
290
  }
@@ -120,15 +294,30 @@ const handler = async ({ force }) => {
120
294
  {
121
295
  title: "One more thing...",
122
296
  task: (_ctx, task) => {
297
+ const migrate = c.highlight(
298
+ formatCedarCommand(["prisma", "migrate", "dev"])
299
+ );
300
+ const types = c.highlight(formatCedarCommand(["generate", "types"]));
123
301
  task.title = `One more thing...
124
302
 
125
- ${c.success("\nUploads and storage configured!\n")}
303
+ ${c.success("Uploads configured!")}
304
+
305
+ Next steps:
306
+
307
+ 1. Apply the schema change: ${migrate}
308
+ 2. Regenerate types: ${types}
309
+ 3. Review the storage targets and upload profiles in api/src/lib/uploads.${ext}${wantsS3 ? "\n 4. Fill in the AWS_* and S3_BUCKET_UPLOADS values in .env" : ""}
126
310
 
127
- Remember to add UPLOADS_SECRET to your .env file. You can generate one with ${c.highlight(formatCedarCommand(["generate", "secret"]))}
311
+ Upload tokens are bound to the logged-in user when the app has auth.
312
+ If you add auth later, pass
313
+ \`authenticate: createUploadAuthenticator({ authDecoder, getCurrentUser })\`
314
+ to the plugin in api/src/server.${ext}.
315
+
316
+ Schedule cleanupStaleUploads() from a recurring job to sweep uploads
317
+ that never completed.
128
318
 
129
319
  Check out the docs for more info:
130
320
  ${terminalLink("", "https://cedarjs.com/docs/uploads")}
131
-
132
321
  `;
133
322
  }
134
323
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cedarjs/cli",
3
- "version": "7.0.0-canary.3113",
3
+ "version": "7.0.0-canary.3114",
4
4
  "description": "The CedarJS Command Line",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,17 +36,17 @@
36
36
  "@babel/preset-typescript": "7.29.7",
37
37
  "@babel/traverse": "7.29.8",
38
38
  "@babel/types": "7.29.8",
39
- "@cedarjs/api-server": "7.0.0-canary.3113",
40
- "@cedarjs/babel-config": "7.0.0-canary.3113",
41
- "@cedarjs/cli-helpers": "7.0.0-canary.3113",
42
- "@cedarjs/internal": "7.0.0-canary.3113",
43
- "@cedarjs/prerender": "7.0.0-canary.3113",
44
- "@cedarjs/project-config": "7.0.0-canary.3113",
45
- "@cedarjs/structure": "7.0.0-canary.3113",
46
- "@cedarjs/telemetry": "7.0.0-canary.3113",
47
- "@cedarjs/utils": "7.0.0-canary.3113",
48
- "@cedarjs/vite": "7.0.0-canary.3113",
49
- "@cedarjs/web-server": "7.0.0-canary.3113",
39
+ "@cedarjs/api-server": "7.0.0-canary.3114",
40
+ "@cedarjs/babel-config": "7.0.0-canary.3114",
41
+ "@cedarjs/cli-helpers": "7.0.0-canary.3114",
42
+ "@cedarjs/internal": "7.0.0-canary.3114",
43
+ "@cedarjs/prerender": "7.0.0-canary.3114",
44
+ "@cedarjs/project-config": "7.0.0-canary.3114",
45
+ "@cedarjs/structure": "7.0.0-canary.3114",
46
+ "@cedarjs/telemetry": "7.0.0-canary.3114",
47
+ "@cedarjs/utils": "7.0.0-canary.3114",
48
+ "@cedarjs/vite": "7.0.0-canary.3114",
49
+ "@cedarjs/web-server": "7.0.0-canary.3114",
50
50
  "@listr2/prompt-adapter-enquirer": "4.3.2",
51
51
  "@opentelemetry/api": "1.9.1",
52
52
  "@opentelemetry/core": "1.30.1",
@@ -94,7 +94,7 @@
94
94
  "yargs": "17.7.3"
95
95
  },
96
96
  "devDependencies": {
97
- "@cedarjs/framework-tools": "7.0.0-canary.3113",
97
+ "@cedarjs/framework-tools": "7.0.0-canary.3114",
98
98
  "@prisma/dmmf": "7.10.0",
99
99
  "@types/archiver": "^7.0.0",
100
100
  "memfs": "4.68.2",
@@ -1,28 +0,0 @@
1
- import j from "jscodeshift";
2
- function transform(fileInfo) {
3
- const root = j(fileInfo.source);
4
- const imports = root.find(j.ImportDeclaration);
5
- imports.at(-1).insertAfter(
6
- j.importDeclaration(
7
- [j.importSpecifier(j.identifier("storagePrismaExtension"))],
8
- j.literal("./uploads.js")
9
- )
10
- );
11
- root.find(j.VariableDeclaration, { declarations: [{ id: { name: "db" } }] }).forEach((path) => {
12
- const dbDeclaration = path.node.declarations[0];
13
- if (j.VariableDeclarator.check(dbDeclaration) && j.NewExpression.check(dbDeclaration.init)) {
14
- throw new Error("RW_CODEMOD_ERR_OLD_FORMAT");
15
- }
16
- if (j.VariableDeclarator.check(dbDeclaration) && j.Expression.check(dbDeclaration.init)) {
17
- const newInit = j.callExpression(
18
- j.memberExpression(dbDeclaration.init, j.identifier("$extends")),
19
- [j.identifier("storagePrismaExtension")]
20
- );
21
- dbDeclaration.init = newInit;
22
- }
23
- });
24
- return root.toSource();
25
- }
26
- export {
27
- transform as default
28
- };
@@ -1,21 +0,0 @@
1
- import type { APIGatewayEvent, Context } from 'aws-lambda'
2
-
3
- import type { SignatureValidationArgs } from '@cedarjs/storage/UrlSigner'
4
-
5
- import { urlSigner, fsStorage } from 'src/lib/uploads'
6
-
7
- export const handler = async (event: APIGatewayEvent, _context: Context) => {
8
- const fileToReturn = urlSigner.validateSignature(
9
- event.queryStringParameters as SignatureValidationArgs
10
- )
11
-
12
- const { contents, type } = await fsStorage.read(fileToReturn)
13
-
14
- return {
15
- statusCode: 200,
16
- headers: {
17
- 'Content-Type': type,
18
- },
19
- body: contents,
20
- }
21
- }