Implement E2E-compliant anonymous read-only logbook sharing links

This commit is contained in:
2026-05-28 20:47:52 +02:00
parent b3978ed294
commit 20ff2a0baa
14 changed files with 1172 additions and 359 deletions
+159
View File
@@ -54,6 +54,58 @@ router.get('/invite-details', async (req: any, res) => {
}
})
// 1b. Public read-only share pull endpoint (does not require authentication)
router.get('/share-pull', async (req: any, res) => {
try {
const { token } = req.query
if (!token) {
return res.status(400).json({ error: 'Token is required' })
}
const invitation = await prisma.invitation.findUnique({
where: { token },
include: {
logbook: true
}
})
if (!invitation) {
return res.status(404).json({ error: 'Share link not found' })
}
if (new Date() > invitation.expiresAt) {
return res.status(410).json({ error: 'Share link has expired' })
}
if (invitation.role !== 'READ') {
return res.status(403).json({ error: 'Forbidden: Invalid role for public pull' })
}
const logbookId = invitation.logbookId
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
return res.json({
title: invitation.logbook.encryptedTitle,
yacht,
deviation,
crews,
entries,
photos,
gpsTracks
})
} catch (error: any) {
console.error('Error in share-pull:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 2. Accept invitation (requires authenticated invitee)
router.post('/accept', requireUser, async (req: any, res) => {
try {
@@ -240,4 +292,111 @@ router.delete('/collaborators/:id', async (req: any, res) => {
}
})
// 6. Get public share link for a logbook (Owner only)
router.get('/share-link', async (req: any, res) => {
try {
const { logbookId } = req.query
if (!logbookId) {
return res.status(400).json({ error: 'logbookId is required' })
}
const logbook = await prisma.logbook.findUnique({
where: { id: logbookId }
})
if (!logbook) {
return res.status(404).json({ error: 'Logbook not found' })
}
if (logbook.userId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
const invitation = await prisma.invitation.findFirst({
where: {
logbookId,
role: 'READ',
expiresAt: {
gt: new Date()
}
}
})
return res.json({
token: invitation ? invitation.token : null,
expiresAt: invitation ? invitation.expiresAt : null
})
} catch (error: any) {
console.error('Error fetching share link:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 7. Toggle public share link for a logbook (Owner only)
router.post('/share-link', async (req: any, res) => {
try {
const { logbookId, enabled } = req.body
if (!logbookId || typeof enabled !== 'boolean') {
return res.status(400).json({ error: 'logbookId and enabled are required' })
}
const logbook = await prisma.logbook.findUnique({
where: { id: logbookId }
})
if (!logbook) {
return res.status(404).json({ error: 'Logbook not found' })
}
if (logbook.userId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
if (enabled) {
// Find existing active read-only invitation
let invitation = await prisma.invitation.findFirst({
where: {
logbookId,
role: 'READ',
expiresAt: {
gt: new Date()
}
}
})
if (!invitation) {
// Create one that lasts 100 years
const expiresAt = new Date()
expiresAt.setFullYear(expiresAt.getFullYear() + 100)
invitation = await prisma.invitation.create({
data: {
logbookId,
role: 'READ',
expiresAt
}
})
}
return res.json({
token: invitation.token,
expiresAt: invitation.expiresAt
})
} else {
// Delete any READ invitations
await prisma.invitation.deleteMany({
where: {
logbookId,
role: 'READ'
}
})
return res.json({ success: true })
}
} catch (error: any) {
console.error('Error toggling share link:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router