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
1 change: 1 addition & 0 deletions src/lib/flags.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ const flagsList = () => {
flag('drive.pdf-editor.enabled')
flag('sharing.auto-open-settings.enabled')
flag('sharing.generate-link-button.enabled')
flag('drive.sign.enabled')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be removed. I think this file doesn't work well ATM.

}
4 changes: 3 additions & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,9 @@
"personalizeFolder": {
"label": "Personalize folder"
},
"summariseByAI": "Summarise"
"summariseByAI": "Summarise",
"signWithEuDss": "Sign",
"verifyWithEuDss": "Verify signature"
},
"DuplicateModal": {
"subTitle": "Duplicate to:",
Expand Down
4 changes: 3 additions & 1 deletion src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,9 @@
"personalizeFolder": {
"label": "Personnaliser le dossier"
},
"summariseByAI": "Résumer"
"summariseByAI": "Résumer",
"signWithEuDss": "Signer",
"verifyWithEuDss": "Vérifier la signature"
},
"FolderCustomizer": {
"title": "Personnaliser le dossier",
Expand Down
70 changes: 70 additions & 0 deletions src/modules/actions/helpers/euDss.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { DOCTYPE_FILES } from '@/lib/doctypes'

const DOCTYPE_PERMISSIONS = 'io.cozy.permissions'

export const EU_DSS_SCHEME = 'eudss'
export const EU_DSS_SIGN = 'sign'
export const EU_DSS_VERIFY = 'verify'

// The signed document comes back as an ASiC-E container, the verification as a
// DSS simple report. The desktop app writes either back to the parent folder.
const SIGNED_FILE_EXTENSION = 'asice'
const VERIFICATION_REPORT_SUFFIX = '-verification.xml'

// cozy-stack invalidates the write permission after this delay, so the deeplink
// can only be used to push the result back for a short window.
const CALLBACK_PERMISSION_TTL = '10m'

const getCallbackFileName = (file, operation) =>
operation === EU_DSS_VERIFY
? `${file.name}${VERIFICATION_REPORT_SUFFIX}`
: `${file.name}.${SIGNED_FILE_EXTENSION}`

const fetchPublicDownloadUrl = async (client, file) => {
const downloadPath = await client
.collection(DOCTYPE_FILES, { driveId: file.driveId })
.getDownloadLinkById(file._id, file.name)
return client.getStackClient().fullpath(downloadPath)
}

const fetchCallbackToken = async (client, file) => {
const { data: permission } = await client
.collection(DOCTYPE_PERMISSIONS)
.createSharingLink(
{ _id: file.dir_id, _type: DOCTYPE_FILES },
{ verbs: ['POST'], ttl: CALLBACK_PERMISSION_TTL }
)
return permission.attributes?.shortcodes?.code ?? null
}

// Files inside a shared drive live behind the /sharings/drives/<driveId>
// proxy, so writing the result back must target that route instead of the
// member's own VFS (which does not hold the document).
const getFilesApiPrefix = file =>
file.driveId ? `/sharings/drives/${file.driveId}/files` : '/files'

// cozy-stack only authenticates via the Authorization header, never a query
// param. The token is passed in the URL by convention: the eu-dss desktop app
// reads it and replays it as a Bearer header on its POST to the callback.
const buildCallbackUrl = (client, file, operation, token) => {
const stackUri = client.getStackClient().uri
const params = new URLSearchParams({
Type: 'file',
Name: getCallbackFileName(file, operation),
token
})
return `${stackUri}${getFilesApiPrefix(file)}/${file.dir_id}?${params}`
}

export const buildEuDssDeeplink = async (client, file, operation) => {
const docUrl = await fetchPublicDownloadUrl(client, file)
const token = await fetchCallbackToken(client, file)
const callbackUrl = buildCallbackUrl(client, file, operation, token)

Comment on lines +37 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when callback token creation does not return a shortcode.

Line 37 can return null, and then Line 54 serializes it as token=null, producing a deeplink that will fail later during the EU-DSS callback. Throwing here gives a deterministic failure path and lets the action fallback alert handle it cleanly.

Suggested fix
 const fetchCallbackToken = async (client, file) => {
   const { data: permission } = await client
     .collection(DOCTYPE_PERMISSIONS)
     .createSharingLink(
       { _id: file.dir_id, _type: DOCTYPE_FILES },
       { verbs: ['POST'], ttl: CALLBACK_PERMISSION_TTL }
     )
-  return permission.attributes?.shortcodes?.code ?? null
+  const code = permission.attributes?.shortcodes?.code
+  if (!code) {
+    throw new Error('Unable to create EU-DSS callback token')
+  }
+  return code
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/actions/helpers/euDss.js` around lines 37 - 63, The function
containing the return statement at line 37 allows null to be returned when the
shortcode is missing, which then gets passed to buildCallbackUrl and serialized
as token=null in the URL parameters, creating an invalid deeplink. Instead of
returning null in the shortcode extraction logic, throw an error to fail
immediately when the callback token cannot be created. This ensures the null
value never reaches buildCallbackUrl and allows proper error handling in
buildEuDssDeeplink.

const params = new URLSearchParams({
doc_url: docUrl,
callback_url: callbackUrl,
state: file._id
})
return `${EU_DSS_SCHEME}://${operation}?${params}`
}
132 changes: 132 additions & 0 deletions src/modules/actions/helpers/euDss.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { buildEuDssDeeplink, EU_DSS_SIGN, EU_DSS_VERIFY } from './euDss'

const setupClient = () => {
const getDownloadLinkById = jest
.fn()
.mockResolvedValue('/files/downloads/secret/contract.pdf')
const createSharingLink = jest.fn().mockResolvedValue({
data: { attributes: { shortcodes: { code: 'TOKEN123' } } }
})
const client = {
collection: jest.fn(doctype =>
doctype === 'io.cozy.permissions'
? { createSharingLink }
: { getDownloadLinkById }
),
getStackClient: () => ({
uri: 'http://cozy.tools',
fullpath: path =>
path.startsWith('http') ? path : 'http://cozy.tools' + path
})
}
return { client, getDownloadLinkById, createSharingLink }
}

const file = {
_id: 'file-123',
name: 'contract.pdf',
dir_id: 'dir-456'
}

const getQueryParams = deeplink =>
new URLSearchParams(deeplink.substring(deeplink.indexOf('?') + 1))

describe('buildEuDssDeeplink', () => {
it('targets the eudss sign endpoint', async () => {
const { client } = setupClient()

const deeplink = await buildEuDssDeeplink(client, file, EU_DSS_SIGN)

expect(deeplink.startsWith('eudss://sign?')).toBe(true)
})

it('embeds the public download link as doc_url', async () => {
const { client, getDownloadLinkById } = setupClient()

const deeplink = await buildEuDssDeeplink(client, file, EU_DSS_SIGN)

expect(getDownloadLinkById).toHaveBeenCalledWith('file-123', 'contract.pdf')
expect(getQueryParams(deeplink).get('doc_url')).toBe(
'http://cozy.tools/files/downloads/secret/contract.pdf'
)
})

it('requests a short-lived write permission on the parent folder', async () => {
const { client, createSharingLink } = setupClient()

await buildEuDssDeeplink(client, file, EU_DSS_SIGN)

expect(createSharingLink).toHaveBeenCalledWith(
{ _id: 'dir-456', _type: 'io.cozy.files' },
{ verbs: ['POST'], ttl: '10m' }
)
})

it('embeds the write token and the signed file name in callback_url', async () => {
const { client } = setupClient()

const deeplink = await buildEuDssDeeplink(client, file, EU_DSS_SIGN)

const callbackUrl = new URL(getQueryParams(deeplink).get('callback_url'))
expect(callbackUrl.origin + callbackUrl.pathname).toBe(
'http://cozy.tools/files/dir-456'
)
expect(callbackUrl.searchParams.get('Type')).toBe('file')
expect(callbackUrl.searchParams.get('Name')).toBe('contract.pdf.asice')
expect(callbackUrl.searchParams.get('token')).toBe('TOKEN123')
})

it('writes an xml report on verify', async () => {
const { client } = setupClient()

const deeplink = await buildEuDssDeeplink(client, file, EU_DSS_VERIFY)

expect(deeplink.startsWith('eudss://verify?')).toBe(true)
const callbackUrl = new URL(getQueryParams(deeplink).get('callback_url'))
expect(callbackUrl.searchParams.get('Name')).toBe(
'contract.pdf-verification.xml'
)
})

it('sets state to the file id for correlation', async () => {
const { client } = setupClient()

const deeplink = await buildEuDssDeeplink(client, file, EU_DSS_SIGN)

expect(getQueryParams(deeplink).get('state')).toBe('file-123')
})
})

describe('buildEuDssDeeplink in a shared drive', () => {
const sharedDriveFile = {
_id: 'file-123',
name: 'contract.pdf',
dir_id: 'dir-456',
driveId: 'drive-789'
}

it('reads the document through the shared drive collection', async () => {
const { client } = setupClient()

await buildEuDssDeeplink(client, sharedDriveFile, EU_DSS_SIGN)

expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {
driveId: 'drive-789'
})
})

it('writes the result back through the shared drive route', async () => {
const { client } = setupClient()

const deeplink = await buildEuDssDeeplink(
client,
sharedDriveFile,
EU_DSS_SIGN
)

const callbackUrl = new URL(getQueryParams(deeplink).get('callback_url'))
expect(callbackUrl.origin + callbackUrl.pathname).toBe(
'http://cozy.tools/sharings/drives/drive-789/files/dir-456'
)
})
})
2 changes: 2 additions & 0 deletions src/modules/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ export { infos } from './infos'
export { addItems } from './addItems'
export { selectAllItems } from './selectAll'
export { summariseByAI } from './summariseByAI'
export { signWithEuDss } from './signWithEuDss'
export { verifyWithEuDss } from './verifyWithEuDss'
export { filterActionsByPolicy, hasAnyInfectedFile } from './policies'
63 changes: 63 additions & 0 deletions src/modules/actions/signWithEuDss.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { forwardRef } from 'react'

import flag from 'cozy-flags'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import PenIcon from 'cozy-ui/transpiled/react/Icons/Pen'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'

import { buildEuDssDeeplink, EU_DSS_SIGN } from './helpers/euDss'

const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
<ActionsMenuItem {...props} ref={ref}>
<ListItemIcon>
<Icon icon={icon} />
</ListItemIcon>
<ListItemText primary={label} />
</ActionsMenuItem>
)
})
Component.displayName = 'signWithEuDss'

return Component
}

export const signWithEuDss = ({
client,
t,
hasWriteAccess,
isPublic,
showAlert
}) => {
const label = t('actions.signWithEuDss')
const icon = PenIcon

return {
name: 'signWithEuDss',
label,
icon,
allowMultiple: false,
allowFolders: false,
displayCondition: files =>
flag('drive.sign.enabled') &&
files.length === 1 &&
hasWriteAccess &&
!isPublic,
action: async files => {
try {
const deeplink = await buildEuDssDeeplink(client, files[0], EU_DSS_SIGN)
window.location.assign(deeplink)
} catch {
showAlert({
message: t('alert.try_again'),
severity: 'error',
duration: 4000
})
}
},
Component: makeComponent(label, icon)
}
}
67 changes: 67 additions & 0 deletions src/modules/actions/verifyWithEuDss.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { forwardRef } from 'react'

import flag from 'cozy-flags'
import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'
import Icon from 'cozy-ui/transpiled/react/Icon'
import CertifiedIcon from 'cozy-ui/transpiled/react/Icons/Certified'
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'

import { buildEuDssDeeplink, EU_DSS_VERIFY } from './helpers/euDss'

const makeComponent = (label, icon) => {
const Component = forwardRef((props, ref) => {
return (
<ActionsMenuItem {...props} ref={ref}>
<ListItemIcon>
<Icon icon={icon} />
</ListItemIcon>
<ListItemText primary={label} />
</ActionsMenuItem>
)
})
Component.displayName = 'verifyWithEuDss'

return Component
}

export const verifyWithEuDss = ({
client,
t,
hasWriteAccess,
isPublic,
showAlert
}) => {
const label = t('actions.verifyWithEuDss')
const icon = CertifiedIcon

return {
name: 'verifyWithEuDss',
label,
icon,
allowMultiple: false,
allowFolders: false,
displayCondition: files =>
flag('drive.sign.enabled') &&
files.length === 1 &&
hasWriteAccess &&
!isPublic,
action: async files => {
try {
const deeplink = await buildEuDssDeeplink(
client,
files[0],
EU_DSS_VERIFY
)
window.location.assign(deeplink)
} catch {
showAlert({
message: t('alert.try_again'),
severity: 'error',
duration: 4000
})
}
},
Component: makeComponent(label, icon)
}
}
6 changes: 5 additions & 1 deletion src/modules/views/Drive/DriveFolderView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import {
versions,
hr,
selectAllItems,
summariseByAI
summariseByAI,
signWithEuDss,
verifyWithEuDss
} from '@/modules/actions'
import { addToFavorites } from '@/modules/actions/components/addToFavorites'
import { duplicateTo } from '@/modules/actions/components/duplicateTo'
Expand Down Expand Up @@ -124,6 +126,8 @@ const DriveFolderView = () => {
download,
hr,
summariseByAI,
signWithEuDss,
verifyWithEuDss,
Comment on lines +129 to +130

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Getting worse: Complex Method
DriveFolderView already has high cyclomatic complexity, and now it increases in Lines of Code from 169 to 171

Suppress

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll not do a refactoring right now. But this file needs a cleanup for sure!

hr,
rename,
moveTo,
Expand Down
Loading