chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { getClientIp } from '@/lib/core/utils/request'
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
/** Metadata reads are cheap (one indexed lookup) — generous per-IP budget. */
|
||||
const METADATA_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 120,
|
||||
refillRate: 120,
|
||||
refillIntervalMs: 60_000,
|
||||
}
|
||||
|
||||
/** Content reads stream bytes from storage (S3 egress) — tighter per-IP budget. */
|
||||
const CONTENT_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 60,
|
||||
refillRate: 60,
|
||||
refillIntervalMs: 60_000,
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-IP rate limit for the unauthenticated public share endpoints, returning a
|
||||
* `429` response when exceeded (or `null` to proceed). The token is unguessable,
|
||||
* so this defends a *known* link against hammering (DoS / S3 egress) rather than
|
||||
* enumeration. Fails open on storage errors (availability over strictness),
|
||||
* matching the chat public route.
|
||||
*/
|
||||
export async function enforcePublicFileRateLimit(
|
||||
request: { headers: { get(name: string): string | null } },
|
||||
scope: 'metadata' | 'content'
|
||||
): Promise<NextResponse | null> {
|
||||
const ip = getClientIp(request)
|
||||
const config = scope === 'content' ? CONTENT_RATE_LIMIT : METADATA_RATE_LIMIT
|
||||
const result = await rateLimiter.checkRateLimitDirect(`public-file:${scope}:${ip}`, config)
|
||||
if (result.allowed) return null
|
||||
|
||||
const headers =
|
||||
result.retryAfterMs != null
|
||||
? { 'Retry-After': String(Math.ceil(result.retryAfterMs / 1000)) }
|
||||
: undefined
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429, headers }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { db } from '@sim/db'
|
||||
import { publicShare, user, workspace, workspaceFiles } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId, generateShortId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { z } from 'zod'
|
||||
import type {
|
||||
ShareAuthType,
|
||||
ShareRecord,
|
||||
shareResourceTypeSchema,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('PublicShareManager')
|
||||
|
||||
/** Thrown when share auth config is invalid (e.g. enabling a password share with no password). Maps to a 400. */
|
||||
export class ShareValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ShareValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
type ShareResourceType = z.infer<typeof shareResourceTypeSchema>
|
||||
|
||||
type PublicShareRow = typeof publicShare.$inferSelect
|
||||
|
||||
/** Public share URL for a token: `{baseUrl}/f/{token}`. */
|
||||
export function buildShareUrl(token: string): string {
|
||||
return `${getBaseUrl()}/f/${token}`
|
||||
}
|
||||
|
||||
function mapShareRecord(row: PublicShareRow): ShareRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
token: row.token,
|
||||
url: buildShareUrl(row.token),
|
||||
isActive: row.isActive,
|
||||
resourceType: row.resourceType as ShareResourceType,
|
||||
resourceId: row.resourceId,
|
||||
authType: row.authType as ShareAuthType,
|
||||
hasPassword: Boolean(row.password),
|
||||
allowedEmails: Array.isArray(row.allowedEmails) ? (row.allowedEmails as string[]) : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function getShareForResource(
|
||||
resourceType: ShareResourceType,
|
||||
resourceId: string
|
||||
): Promise<ShareRecord | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(publicShare)
|
||||
.where(and(eq(publicShare.resourceType, resourceType), eq(publicShare.resourceId, resourceId)))
|
||||
.limit(1)
|
||||
|
||||
return row ? mapShareRecord(row) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch shares for many resources of the same type, keyed by `resourceId`.
|
||||
* Used to enrich the files list without an N+1 query.
|
||||
*/
|
||||
export async function getSharesForResources(
|
||||
resourceType: ShareResourceType,
|
||||
resourceIds: string[]
|
||||
): Promise<Map<string, ShareRecord>> {
|
||||
const result = new Map<string, ShareRecord>()
|
||||
if (resourceIds.length === 0) return result
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(publicShare)
|
||||
.where(
|
||||
and(eq(publicShare.resourceType, resourceType), inArray(publicShare.resourceId, resourceIds))
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
result.set(row.resourceId, mapShareRecord(row))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
interface UpsertFileShareInput {
|
||||
workspaceId: string
|
||||
fileId: string
|
||||
userId: string
|
||||
isActive: boolean
|
||||
/** Defaults to the existing share's authType (or `'public'` for a new share). */
|
||||
authType?: ShareAuthType
|
||||
/** Plaintext password to set; encrypted at rest. Required to first enable a password share. */
|
||||
password?: string
|
||||
/** Allowed emails/domains; required to enable an `email`/`sso` share without an existing list. */
|
||||
allowedEmails?: string[]
|
||||
/** Client-reserved token to persist on first insert; ignored when the share already exists. */
|
||||
token?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the public share for a file. First enable inserts a row with
|
||||
* a fresh unguessable token; subsequent calls flip `isActive`/`authType` and keep
|
||||
* the token stable (so an existing link resolves again after re-enable).
|
||||
*
|
||||
* Auth validation only applies when **enabling** (`isActive: true`): `password`
|
||||
* requires a plaintext `password` unless one is already stored (encrypted via
|
||||
* {@link encryptSecret}); `email`/`sso` require a non-empty `allowedEmails`.
|
||||
* Disabling (going Private) always succeeds and preserves the stored config so a
|
||||
* later re-enable restores it. Validation failures throw {@link ShareValidationError}.
|
||||
*/
|
||||
export async function upsertFileShare({
|
||||
workspaceId,
|
||||
fileId,
|
||||
userId,
|
||||
isActive,
|
||||
authType,
|
||||
password,
|
||||
allowedEmails,
|
||||
token,
|
||||
}: UpsertFileShareInput): Promise<ShareRecord> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(publicShare)
|
||||
.where(and(eq(publicShare.resourceType, 'file'), eq(publicShare.resourceId, fileId)))
|
||||
.limit(1)
|
||||
|
||||
const finalAuthType: ShareAuthType =
|
||||
authType ?? (existing?.authType as ShareAuthType | undefined) ?? 'public'
|
||||
const existingAllowedEmails = Array.isArray(existing?.allowedEmails)
|
||||
? (existing.allowedEmails as string[])
|
||||
: []
|
||||
|
||||
// Disabling preserves the stored config (and skips validation) so turning
|
||||
// sharing off always succeeds; only enabling validates the chosen auth mode.
|
||||
let finalPassword: string | null = existing?.password ?? null
|
||||
let finalAllowedEmails: string[] = existingAllowedEmails
|
||||
if (isActive) {
|
||||
if (finalAuthType === 'password') {
|
||||
if (password) {
|
||||
finalPassword = (await encryptSecret(password)).encrypted
|
||||
} else if (existing?.password) {
|
||||
finalPassword = existing.password
|
||||
} else {
|
||||
throw new ShareValidationError('Password is required for password-protected shares')
|
||||
}
|
||||
finalAllowedEmails = []
|
||||
} else if (finalAuthType === 'email' || finalAuthType === 'sso') {
|
||||
finalAllowedEmails = allowedEmails ?? existingAllowedEmails
|
||||
if (finalAllowedEmails.length === 0) {
|
||||
throw new ShareValidationError(
|
||||
'At least one allowed email is required for email/SSO shares'
|
||||
)
|
||||
}
|
||||
finalPassword = null
|
||||
} else {
|
||||
finalPassword = null
|
||||
finalAllowedEmails = []
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(publicShare)
|
||||
.values({
|
||||
id: generateId(),
|
||||
resourceType: 'file',
|
||||
resourceId: fileId,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
token: token ?? generateShortId(),
|
||||
isActive,
|
||||
authType: finalAuthType,
|
||||
password: finalPassword,
|
||||
allowedEmails: finalAllowedEmails,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [publicShare.resourceType, publicShare.resourceId],
|
||||
set: {
|
||||
isActive,
|
||||
authType: finalAuthType,
|
||||
password: finalPassword,
|
||||
allowedEmails: finalAllowedEmails,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info('Upserted file share', {
|
||||
fileId,
|
||||
workspaceId,
|
||||
isActive,
|
||||
authType: finalAuthType,
|
||||
token: row.token,
|
||||
})
|
||||
return mapShareRecord(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a public token to its active share and the underlying (non-deleted)
|
||||
* file. Returns null if the token is unknown, the share is inactive, or the file
|
||||
* is gone. The caller treats null as a 404 — the existence of a file is never
|
||||
* leaked through this path.
|
||||
*/
|
||||
export interface ResolvedShare {
|
||||
share: PublicShareRow
|
||||
file: typeof workspaceFiles.$inferSelect
|
||||
/** Owning workspace name, for provenance on the public page. */
|
||||
workspaceName: string | null
|
||||
/** Display name of the file's uploader. */
|
||||
ownerName: string | null
|
||||
}
|
||||
|
||||
export async function resolveActiveShareByToken(token: string): Promise<ResolvedShare | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
share: publicShare,
|
||||
file: workspaceFiles,
|
||||
workspaceName: workspace.name,
|
||||
ownerName: user.name,
|
||||
})
|
||||
.from(publicShare)
|
||||
.innerJoin(workspaceFiles, eq(workspaceFiles.id, publicShare.resourceId))
|
||||
.leftJoin(workspace, eq(workspace.id, workspaceFiles.workspaceId))
|
||||
.leftJoin(user, eq(user.id, workspaceFiles.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(publicShare.token, token),
|
||||
eq(publicShare.isActive, true),
|
||||
eq(publicShare.resourceType, 'file'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) return null
|
||||
|
||||
return {
|
||||
share: row.share,
|
||||
file: row.file,
|
||||
workspaceName: row.workspaceName,
|
||||
ownerName: row.ownerName,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user