Add signed URLs for file access, fix unauthorized file viewing

New /files/signed-url/:id endpoint generates a 15-minute JWT-signed URL
for any file. New /files/s/* endpoint serves files using the token from
the query string without requiring auth headers. This allows files to
open in new browser tabs without authentication issues. Photos and
documents in repair tickets now use signed URLs when clicked.
This commit is contained in:
Ryan Moon
2026-03-29 11:02:24 -05:00
parent 77e56b7837
commit bf2091365d
2 changed files with 71 additions and 12 deletions

View File

@@ -7,6 +7,15 @@ import { Button } from '@/components/ui/button'
import { FileText, ImageIcon, Plus, Trash2 } from 'lucide-react' import { FileText, ImageIcon, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
async function openSignedFile(fileId: string) {
try {
const res = await api.get<{ url: string }>(`/v1/files/signed-url/${fileId}`)
window.open(res.url, '_blank')
} catch {
toast.error('Failed to open file')
}
}
interface FileRecord { interface FileRecord {
id: string id: string
path: string path: string
@@ -145,27 +154,22 @@ function PhotoSection({
return ( return (
<div key={photo.id} className="relative group"> <div key={photo.id} className="relative group">
{isPdf ? ( {isPdf ? (
<a <button
href={`/v1/files/serve/${photo.path}`} type="button"
target="_blank" onClick={() => openSignedFile(photo.id)}
rel="noopener noreferrer" className="h-20 w-20 rounded-md border flex flex-col items-center justify-center bg-muted hover:bg-muted/80 transition-colors cursor-pointer"
className="h-20 w-20 rounded-md border flex flex-col items-center justify-center bg-muted hover:bg-muted/80 transition-colors"
> >
<FileText className="h-6 w-6 text-muted-foreground" /> <FileText className="h-6 w-6 text-muted-foreground" />
<span className="text-[9px] text-muted-foreground mt-1 px-1 truncate max-w-full">{photo.filename}</span> <span className="text-[9px] text-muted-foreground mt-1 px-1 truncate max-w-full">{photo.filename}</span>
</a> </button>
) : ( ) : (
<a <button type="button" onClick={() => openSignedFile(photo.id)}>
href={`/v1/files/serve/${photo.path}`}
target="_blank"
rel="noopener noreferrer"
>
<img <img
src={`/v1/files/serve/${photo.path}`} src={`/v1/files/serve/${photo.path}`}
alt={photo.filename} alt={photo.filename}
className="h-20 w-20 object-cover rounded-md border cursor-pointer hover:opacity-80 transition-opacity" className="h-20 w-20 object-cover rounded-md border cursor-pointer hover:opacity-80 transition-opacity"
/> />
</a> </button>
)} )}
<button <button
type="button" type="button"

View File

@@ -112,6 +112,61 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ ...file, url }) return reply.send({ ...file, url })
}) })
// Generate signed URL for a file (short-lived token in query string)
app.get('/files/signed-url/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const file = await FileService.getById(app.db, request.companyId, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
// Sign a short-lived token with the file path
const token = app.jwt.sign(
{ path: file.path, companyId: request.companyId, purpose: 'file-access' } as any,
{ expiresIn: '15m' },
)
const signedUrl = `/v1/files/s/${file.path}?token=${token}`
return reply.send({ url: signedUrl })
})
// Serve file via signed URL (no auth header required — token in query string)
app.get('/files/s/*', async (request, reply) => {
const filePath = (request.params as { '*': string })['*']
const { token } = request.query as { token?: string }
if (!filePath || !token) {
return reply.status(400).send({ error: { message: 'Path and token required', statusCode: 400 } })
}
// Verify the signed token
try {
const payload = app.jwt.verify(token) as { path: string; companyId: string; purpose: string }
if (payload.purpose !== 'file-access' || payload.path !== filePath) {
return reply.status(403).send({ error: { message: 'Invalid token', statusCode: 403 } })
}
} catch {
return reply.status(403).send({ error: { message: 'Token expired or invalid', statusCode: 403 } })
}
// Path traversal protection
if (filePath.includes('..')) {
return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
}
try {
const data = await app.storage.get(filePath)
const ext = filePath.split('.').pop()?.toLowerCase()
const contentTypeMap: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', pdf: 'application/pdf',
}
return reply
.header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream')
.header('Cache-Control', 'private, max-age=900')
.send(data)
} catch {
return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
}
})
// Delete a file // Delete a file
app.delete('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => { app.delete('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }