-
Notifications
You must be signed in to change notification settings - Fork 161
feat(sign): add eu-dss sign and verify file actions #3943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast when callback token creation does not return a shortcode. Line 37 can return 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 |
||
| const params = new URLSearchParams({ | ||
| doc_url: docUrl, | ||
| callback_url: callbackUrl, | ||
| state: file._id | ||
| }) | ||
| return `${EU_DSS_SCHEME}://${operation}?${params}` | ||
| } | ||
| 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' | ||
| ) | ||
| }) | ||
| }) |
| 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) | ||
| } | ||
| } |
| 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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -124,6 +126,8 @@ const DriveFolderView = () => { | |
| download, | ||
| hr, | ||
| summariseByAI, | ||
| signWithEuDss, | ||
| verifyWithEuDss, | ||
|
Comment on lines
+129
to
+130
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❌ Getting worse: Complex Method
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
||
There was a problem hiding this comment.
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.