Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"dependencies": {
"@haverstack/adapter-local": "^0.6.0",
"@haverstack/core": "^0.6.0",
"@haverstack/core": "^0.8.0",
"@haverstack/wire-types": "^0.6.0",
"@hono/node-server": "^1.13.7",
"hono": "^4.6.0",
Expand Down
9 changes: 7 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion src/middleware/errors.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import type { ErrorHandler } from 'hono';
import type { Logger } from 'pino';
import type { AppEnv } from '../types.js';
import { StackPermissionError, StackValidationError, StackNotFoundError } from '@haverstack/core';
import {
StackPermissionError,
StackValidationError,
StackNotFoundError,
StackConflictError,
} from '@haverstack/core';

export function errorMiddleware(logger: Logger): ErrorHandler<AppEnv> {
return (err, c) => {
if (err instanceof StackNotFoundError) return c.json({ error: 'Not found' }, 404);
if (err instanceof StackPermissionError) return c.json({ error: 'Forbidden' }, 403);
if (err instanceof StackConflictError) return c.json({ error: err.message }, 409);
if (err instanceof StackValidationError)
return c.json({ error: err.message, details: err.errors }, 422);
logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error');
Expand Down
74 changes: 5 additions & 69 deletions src/routes/attachments.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Hono } from 'hono';
import { SYSTEM_TYPES } from '@haverstack/core';
import { StackPermissionError } from '@haverstack/core';
import type { AppEnv } from '../types.js';
import type { StackContext } from '../stack.js';
import { requireAuth, requireOwner } from '../middleware/auth.js';
Expand Down Expand Up @@ -33,13 +33,11 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number):
const fileId = c.req.param('fileId');
const auth = c.get('auth');

const accessible = await isAttachmentAccessible(fileId, auth?.entityId ?? null, ctx);
if (!accessible) return c.json({ error: 'Unauthorized' }, 401);

let data: Uint8Array;
try {
data = await adapter.getAttachment(fileId);
} catch {
data = await stack.asEntity(auth?.entityId ?? null).getAttachment(fileId);
} catch (e) {
if (e instanceof StackPermissionError) return c.json({ error: 'Unauthorized' }, 401);
return c.json({ error: 'Attachment not found' }, 404);
}

Expand All @@ -63,32 +61,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number):
// DELETE /attachments/:fileId
app.delete('/:fileId', requireOwner(ownerEntityId), async (c) => {
const fileId = c.req.param('fileId');

// Find any _attachment@1 metadata record(s) for this file
const metaResult = await stack.query({
filter: { typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, content: { fileId } },
});

// If no metadata record, verify the bytes actually exist before proceeding
if (!metaResult.records.length) {
try {
await adapter.getAttachment(fileId);
} catch {
return c.json({ error: 'Attachment not found' }, 404);
}
}

// Refuse if any record in the stack still references this file
const refResult = await stack.query({ filter: { attachmentFileId: fileId }, limit: 1 });
if (refResult.records.length > 0) {
return c.json({ error: 'Attachment is still referenced by one or more records' }, 409);
}

for (const record of metaResult.records) {
await stack.delete(record.id, { hard: true });
}

await adapter.deleteAttachment(fileId);
await stack.deleteAttachment(fileId);
return c.body(null, 204);
});

Expand Down Expand Up @@ -143,40 +116,3 @@ function resolveMimeType(declared: string, filename: string | undefined): string
const ext = filename.split('.').pop()?.toLowerCase();
return (ext && EXTENSION_MIME[ext]) || declared;
}

/**
* An attachment is accessible if:
* 1. The requester is the stack owner, OR
* 2. The requester can read at least one Record that references the file, OR
* 3. The requester uploaded the file (owns its _attachment@1 record) and it
* hasn't been associated with any record yet.
*/
async function isAttachmentAccessible(
fileId: string,
requesterEntityId: string | null,
ctx: StackContext,
): Promise<boolean> {
const { stack } = ctx;
if (requesterEntityId && requesterEntityId === stack.ownerEntityId) return true;

const result = await stack.asEntity(requesterEntityId).query({
filter: { attachmentFileId: fileId },
limit: 1,
});
if (result.records.length > 0) return true;

// Unassociated file: check if the requester uploaded it
if (requesterEntityId) {
const uploadResult = await stack.query({
filter: {
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
entityId: requesterEntityId,
content: { fileId },
},
limit: 1,
});
if (uploadResult.records.length > 0) return true;
}

return false;
}
Loading