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:
@@ -7,6 +7,15 @@ import { Button } from '@/components/ui/button'
|
||||
import { FileText, ImageIcon, Plus, Trash2 } from 'lucide-react'
|
||||
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 {
|
||||
id: string
|
||||
path: string
|
||||
@@ -145,27 +154,22 @@ function PhotoSection({
|
||||
return (
|
||||
<div key={photo.id} className="relative group">
|
||||
{isPdf ? (
|
||||
<a
|
||||
href={`/v1/files/serve/${photo.path}`}
|
||||
target="_blank"
|
||||
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"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSignedFile(photo.id)}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={`/v1/files/serve/${photo.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<button type="button" onClick={() => openSignedFile(photo.id)}>
|
||||
<img
|
||||
src={`/v1/files/serve/${photo.path}`}
|
||||
alt={photo.filename}
|
||||
className="h-20 w-20 object-cover rounded-md border cursor-pointer hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -112,6 +112,61 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
|
||||
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
|
||||
app.delete('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
Reference in New Issue
Block a user