Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions graphile/graphile-settings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,6 @@ export * from './presets/index';
// Re-export makePgService for convenience
export { makePgService };

// Upload utilities
export { streamToStorage } from './upload-resolver';

// Presigned URL utilities
export { getPresignedUrlS3Config } from './presigned-url-resolver';

Expand Down
25 changes: 0 additions & 25 deletions graphile/graphile-settings/src/upload-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import uploadNames from '@constructive-io/upload-names';
import { getEnvOptions } from '@constructive-io/graphql-env';
import { Logger } from '@pgpmjs/logger';
import { randomBytes } from 'crypto';
import type { Readable } from 'stream';
import type {
FileUpload,
UploadFieldDefinition,
Expand Down Expand Up @@ -78,30 +77,6 @@ function generateKey(filename: string): string {
return `${rand}-${uploadNames(filename)}`;
}

/**
* Streams a file to S3/MinIO storage and returns the URL and metadata.
*
* Reusable by both the GraphQL upload resolver and REST /upload endpoint.
*/
export async function streamToStorage(
readStream: Readable,
filename: string,
): Promise<{ url: string; filename: string; mime: string }> {
const s3 = getStreamer();
const key = generateKey(filename);
const result = await s3.upload({
readStream,
filename,
key,
bucket: bucketName,
});
return {
url: result.upload.Location,
filename,
mime: result.contentType,
};
}

/**
* Upload resolver that streams files to S3/MinIO.
*
Expand Down
126 changes: 28 additions & 98 deletions graphile/graphile-upload-plugin/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ This document is the single source of truth for Constructive upload behavior acr

- `graphile/graphile-upload-plugin` (GraphQL upload field wrapping)
- `graphile/graphile-settings` (upload resolver + storage helpers)
- `graphql/server` (`POST /upload` endpoint + upload auth middleware)

It replaces the previous upload handoff notes so upload behavior is documented
in one place.
Expand All @@ -20,11 +19,6 @@ phase (before resolver-time upload transformation), causing:

- `"Attempted to update a record, but no new values were specified."`

The stable production pattern is now:

1. Upload file to `POST /upload` (REST multipart endpoint)
2. Patch DB row with returned metadata via normal JSON GraphQL mutation

## Current Design

### 1) GraphQL Upload Plugin (Resolver-Based Compatibility Layer)
Expand Down Expand Up @@ -64,8 +58,7 @@ Files:
- `uploads/s3-streamer/src/streamer.ts`
- `uploads/s3-streamer/src/utils.ts`

`uploadResolver` and `streamToStorage` are shared by GraphQL resolver path and
REST endpoint path.
`uploadResolver` streams GraphQL upload values to configured object storage.

Security behavior:

Expand All @@ -76,73 +69,6 @@ Security behavior:
This prevents writing disallowed files to object storage and avoids orphaned
objects from post-upload MIME rejection.

### 3) REST `/upload` Endpoint + Upload-Specific Auth

File:
- `graphql/server/src/middleware/upload.ts`

This file now contains both:

- `createUploadAuthenticateMiddleware(opts)`
- `uploadRoute`

`upload-auth.ts` was intentionally consolidated into `upload.ts`.

Server wiring:
- `graphql/server/src/server.ts`
- `app.post('/upload', uploadAuthenticate, ...uploadRoute)`

#### Upload auth semantics

`/upload` is strict:

- Requires bearer token
- Returns HTTP `401` for missing/invalid auth
- Requires `req.token.user_id`

It does not rely on shared GraphQL auth behavior for anonymous fallback.

RLS module resolution for `/upload`:

1. Use `req.api.rlsModule` when present (API-scoped)
2. Fallback to database-scoped lookup by `database_id`
3. Fallback to database-scoped lookup by `dbname`

This preserves existing GraphQL route semantics while making `/upload` work for
both:

- meta API hosts (`api.localhost:3000`)
- data API hosts (`app-public-...localhost:3000`)

#### Upload endpoint behavior

Request:

- `POST /upload`
- `Content-Type: multipart/form-data`
- one file field named `file`
- `Authorization: Bearer <token>`

Success response (`200`):

```json
{
"url": "https://bucket.s3.amazonaws.com/a8f3k2m9x1-photo.jpg",
"filename": "photo.jpg",
"mime": "image/jpeg",
"size": 123456
}
```

Errors:

- `401` auth missing/invalid
- `400` no `file` field
- `413` payload too large (multer limit)

Current route file-size limit:
- `10 MB` (`graphql/server/src/middleware/upload.ts`)

## Resolver-Based vs Plan-Based (Decision)

The upload plugin remains resolver-based on purpose.
Expand All @@ -163,18 +89,13 @@ Revisit if:

## Frontend Integration Contract

Use two-step flow:
Use GraphQL-backed upload flows. Do not derive a REST upload endpoint from the
GraphQL endpoint.

1. Upload file to `/upload`
2. Patch actual DB column field in JSON GraphQL mutation
For domain-backed row fields, use GraphQL multipart direct upload when the
generated schema exposes `*Upload` input fields.

Do not patch `*Upload` fields in CRUD inputs for this flow.

Upload URL derivation from GraphQL endpoint:

```ts
const uploadUrl = graphqlEndpoint.replace(/\/graphql\/?$/, '/upload');
```
For storage-module file records, use presigned URL upload.

Column patch shape depends on DB domain:

Expand All @@ -189,13 +110,8 @@ From `graphql/server/src/server.ts`:
- `graphqlUploadExpress()`
- `multipartBridge`
- shared API/auth/graphile chain
- `/upload`
- shared API resolution (`api`)
- upload-specific auth (`createUploadAuthenticateMiddleware`)
- multipart parsing + storage (`uploadRoute`)

`graphqlUploadExpress` remains scoped to `/graphql` and does not interfere with
`/upload`.
`graphqlUploadExpress` remains scoped to `/graphql`.

## Test Coverage

Expand All @@ -205,12 +121,26 @@ Current key tests:
- max size enforcement on resolver-consumed streams
- `graphile/graphile-settings/__tests__/upload-resolver.test.ts`
- MIME validation before storage upload
- `graphql/server/src/middleware/__tests__/upload.test.ts`
- upload auth behavior, fallback RLS resolution, strict auth handling

## Operational Notes
## Removed REST `/upload` Endpoint

The historical REST `POST /upload` endpoint and its upload-specific auth
middleware have been removed from `graphql/server`.

Removed server pieces included:

- `graphql/server/src/middleware/upload.ts`
- `createUploadAuthenticateMiddleware(opts)`
- `uploadRoute`
- `multer`
- `streamToStorage()`

Clients must stop deriving upload URLs with:

```ts
const uploadUrl = graphqlEndpoint.replace(/\/graphql\/?$/, '/upload');
```

Known dashboard impact is documented in:

- Auth failures on `/upload` are intentionally HTTP `401` JSON errors.
- GraphQL shared auth behavior (including anon fallback patterns) is unchanged.
- This separation avoids cross-route behavior regressions while fixing data API
upload auth.
- `graphile/graphile-upload-plugin/DASHBOARD_UPLOAD_FOLLOWUP.md`
94 changes: 94 additions & 0 deletions graphile/graphile-upload-plugin/DASHBOARD_UPLOAD_FOLLOWUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Dashboard Upload Follow-Up

This document records dashboard impact from removing the historical Constructive
REST upload endpoint:

```text
POST /upload
```

Constructive no longer serves that endpoint. Dashboard code that derives
`/upload` from a GraphQL endpoint must migrate to a GraphQL-backed upload path.

Snapshot checked:

- `constructive`: `main` at `6d810d1b80`
- `dashboard`: `main` at `c92bd5c427`

## Affected Dashboard Runtime Code

The current dashboard `packages/sheets` package still has runtime dependencies
on REST `/upload`.

### `packages/sheets/src/hooks/use-sheets-upload.ts`

Current flow:

```text
REST /upload
-> returns { url, filename, mime, size }
-> GraphQL row update mutation patches the upload/image/attachment field
```

The code derives the endpoint with:

```ts
const uploadUrl = endpoint.replace(/\/graphql\/?$/, '/upload');
```

This will fail after the REST endpoint is removed.

### `packages/sheets/src/context/sheets-execute.ts`

`createSheetsUpload()` also derives `/upload` from the configured GraphQL
endpoint and returns `{ url }` for the caller.

This should be migrated with the hook above so sheets has one upload path.

### `packages/sheets/src/hooks/use-sheets-upload.test.ts`

The test currently expects a REST request to `/upload` followed by a GraphQL
mutation to `/graphql`.

The test should be rewritten around the replacement GraphQL upload flow.

## Not Blocking This Removal

These dashboard areas are not direct REST `/upload` dependencies:

- Admin app GraphQL upload fields such as `logoUpload`, `faviconUpload`, and
`ogImageUpload`
- Generated SDK hooks such as `requestUploadUrl` and `confirmUpload`
- Presigned upload helpers such as `uploadAppFile` and `putToPresignedUrl`
- `packages/sheets/src/grid/editors/upload-editor.tsx`, where
`https://example.com/uploads/...` is only a mock URL

## Recommended Migration

For sheets row fields backed by Constructive upload domains, prefer GraphQL
multipart direct upload.

Expected direction:

```text
executeFieldUpload()
-> build a GraphQL multipart request to /graphql
-> call the relevant update mutation
-> patch with <fieldName>Upload: File
-> read the updated field value from the returned row
```

This keeps the existing row-field model close to the old REST response shape:

- `image` / `upload` domains return `{ url, filename, mime }`
- `attachment` returns a URL string

Before implementing the dashboard migration, verify:

- The target generated schema exposes `<fieldName>Upload` input fields.
- Existing sheets UI code can still consume the returned field values.
- Cookie-authenticated dashboard requests satisfy `/graphql` CSRF behavior.
- Bearer-token authenticated requests continue to skip CSRF as expected.

If sheets should instead model files as storage-module records, use presigned
URL upload as a separate product/data-model migration.
4 changes: 0 additions & 4 deletions graphql/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,12 @@
"@constructive-io/graphql-env": "workspace:^",
"@constructive-io/graphql-types": "workspace:^",
"@constructive-io/s3-utils": "workspace:^",
"@constructive-io/upload-names": "workspace:^",
"@constructive-io/url-domains": "workspace:^",
"@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
"@pgpmjs/env": "workspace:^",
"@pgpmjs/logger": "workspace:^",
"@pgpmjs/server-utils": "workspace:^",
"@pgpmjs/types": "workspace:^",
"@pgsql/quotes": "^17.1.0",
"cors": "^2.8.6",
"deepmerge": "^4.3.1",
"express": "^5.2.1",
Expand All @@ -71,7 +69,6 @@
"graphql": "16.13.0",
"graphql-upload": "^13.0.0",
"lru-cache": "^11.2.7",
"multer": "^2.1.1",
"pg": "^8.21.0",
"pg-cache": "workspace:^",
"pg-env": "workspace:^",
Expand All @@ -86,7 +83,6 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/graphql-upload": "^8.0.12",
"@types/multer": "^2.1.0",
"@types/pg": "^8.20.0",
"@types/request-ip": "^0.0.41",
"cookie-parser": "^1.4.7",
Expand Down
1 change: 0 additions & 1 deletion graphql/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ export * from './server';
// Export middleware for use in testing packages
export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api';
export { createAuthenticateMiddleware } from './middleware/auth';
export { createUploadAuthenticateMiddleware } from './middleware/upload';
export { cors } from './middleware/cors';
export { graphile } from './middleware/graphile';
export { flush, flushService } from './middleware/flush';
Loading
Loading