[feature] Add uploading of PDF signing certificate to Admin Panel

This commit is contained in:
Sergey Konovalov
2026-01-22 03:01:22 +03:00
parent a4100fd311
commit fa71edb3ce
12 changed files with 516 additions and 26 deletions

View File

@ -244,6 +244,61 @@ const callCommandService = async body => {
return response.json();
};
export const getSigningCertificateStatus = async () => {
const response = await safeFetch(`${API_BASE_PATH}/config/signing-certificate/status`, {
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401) throw new Error('UNAUTHORIZED');
throw new Error('Failed to check certificate status');
}
return response.json();
};
export const uploadSigningCertificate = async file => {
const response = await safeFetch(`${API_BASE_PATH}/config/signing-certificate`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream'
},
body: file
});
if (!response.ok) {
if (response.status === 401) throw new Error('UNAUTHORIZED');
if (response.status === 403) throw new Error('Only admin can upload signing certificates');
let errorMessage = 'Failed to upload certificate';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
// JSON parsing failed, use default message
}
throw new Error(errorMessage);
}
return response.json();
};
export const deleteSigningCertificate = async () => {
const response = await safeFetch(`${API_BASE_PATH}/config/signing-certificate`, {
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401) throw new Error('UNAUTHORIZED');
if (response.status === 403) throw new Error('Only admin can delete signing certificates');
let errorMessage = 'Failed to delete certificate';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
// JSON parsing failed, use default message
}
throw new Error(errorMessage);
}
return response.json();
};
export const getForgottenList = async () => {
const result = await callCommandService({c: 'getForgottenList'});
const files = result.keys || [];

View File

@ -1,9 +1,55 @@
import {resetConfiguration} from '../../api';
import {useState, useEffect, useRef, useCallback} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {resetConfiguration, uploadSigningCertificate, deleteSigningCertificate, getSigningCertificateStatus} from '../../api';
import {saveConfig, selectConfig} from '../../store/slices/configSlice';
import {getNestedValue} from '../../utils/getNestedValue';
import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import Button from '../../components/Button/Button';
import Input from '../../components/Input/Input';
import Section from '../../components/Section/Section';
import PasswordInput from '../../components/PasswordInput/PasswordInput';
import Note from '../../components/Note/Note';
import './Settings.scss';
const Settings = () => {
const dispatch = useDispatch();
const config = useSelector(selectConfig);
const fileInputRef = useRef(null);
// PDF Signing state
const [certificateExists, setCertificateExists] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [signingPassphrase, setSigningPassphrase] = useState('');
const [savedPassphrase, setSavedPassphrase] = useState('');
const [error, setError] = useState(null);
const [successMessage, setSuccessMessage] = useState(null);
// Check certificate status on server
const checkCertificateStatus = useCallback(async () => {
try {
const status = await getSigningCertificateStatus();
setCertificateExists(status.exists);
} catch (err) {
console.error('Failed to check certificate status:', err);
setCertificateExists(false);
}
}, []);
// Load config data and check certificate status
useEffect(() => {
if (config) {
const passphrase = getNestedValue(config, 'FileConverter.converter.spawnOptions.env.SIGNING_KEYSTORE_PASSPHRASE') || '';
setSigningPassphrase(passphrase);
setSavedPassphrase(passphrase);
checkCertificateStatus();
}
}, [config, checkCertificateStatus]);
const showSuccess = message => {
setSuccessMessage(message);
setTimeout(() => setSuccessMessage(null), 3000);
};
const handleResetConfig = async () => {
if (!window.confirm('Are you sure you want to reset the configuration? This action cannot be undone.')) {
throw new Error('Operation cancelled');
@ -12,6 +58,130 @@ const Settings = () => {
await resetConfiguration();
};
// Handle file selection - stores file in browser state
const handleFileSelect = event => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file extension
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.p12') && !fileName.endsWith('.pfx')) {
setError('Invalid file format. Please select a .p12 or .pfx file.');
setSelectedFile(null);
return;
}
setError(null);
setSelectedFile(file);
};
const handleSelectFileClick = () => {
fileInputRef.current?.click();
};
// Save - uploads file (if selected) AND saves/removes passphrase
const handleSave = async () => {
try {
setError(null);
let fileUploaded = false;
// If file is selected, upload it first
if (selectedFile) {
await uploadSigningCertificate(selectedFile);
fileUploaded = true;
}
// Handle passphrase: empty means remove, non-empty means save
const passphraseChanged = signingPassphrase !== savedPassphrase;
if (signingPassphrase) {
// Save passphrase to config
const configUpdate = {
'FileConverter.converter.spawnOptions': {
env: {
SIGNING_KEYSTORE_PASSPHRASE: signingPassphrase
}
}
};
const mergedConfig = mergeNestedObjects([configUpdate]);
await dispatch(saveConfig(mergedConfig)).unwrap();
} else if (passphraseChanged) {
// Empty passphrase means remove the key from config (only if changed)
await resetConfiguration(['FileConverter.converter.spawnOptions.env.SIGNING_KEYSTORE_PASSPHRASE']);
}
if (fileUploaded) {
setCertificateExists(true);
}
setSavedPassphrase(signingPassphrase);
setSelectedFile(null);
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
// Show appropriate success message
let message;
if (fileUploaded && passphraseChanged) {
message = signingPassphrase ? 'Certificate uploaded and passphrase saved' : 'Certificate uploaded and passphrase cleared';
} else if (fileUploaded) {
message = 'Certificate uploaded successfully';
} else if (passphraseChanged) {
message = signingPassphrase ? 'Passphrase saved successfully' : 'Passphrase cleared successfully';
} else {
message = 'Settings saved';
}
showSuccess(message);
} catch (err) {
setError(err.message || 'Failed to save');
}
};
// Remove - deletes file AND resets passphrase in config
const handleRemove = async () => {
if (!certificateExists && !selectedFile) return;
if (!window.confirm('Are you sure you want to remove the signing certificate? This will also clear the passphrase.')) {
return;
}
try {
setError(null);
// Delete file from server if exists
if (certificateExists) {
await deleteSigningCertificate();
setCertificateExists(false);
}
// Reset passphrase in config only if it was set
if (savedPassphrase) {
await resetConfiguration(['FileConverter.converter.spawnOptions.env.SIGNING_KEYSTORE_PASSPHRASE']);
}
setSelectedFile(null);
setSigningPassphrase('');
setSavedPassphrase('');
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
showSuccess('Certificate removed successfully');
} catch (err) {
setError(err.message || 'Failed to remove certificate');
}
};
const handlePassphraseChange = value => {
setSigningPassphrase(value);
};
const hasChanges = selectedFile || signingPassphrase !== savedPassphrase;
const canRemove = certificateExists || selectedFile;
return (
<div className='settings-page'>
<div className='page-header'>
@ -25,6 +195,66 @@ const Settings = () => {
>
<Button onClick={handleResetConfig}>Reset</Button>
</Section>
<Section title='PDF Digital Signature' description='Configure PKCS#12 (.p12/.pfx) certificate for digitally signing submitted PDF forms'>
<Note type='note'>
The signing certificate will be used to digitally sign PDF forms when they are submitted. Only submitted PDF forms will be signed, not
regular PDF conversions.
</Note>
<div className='form-row'>
<div className='certificate-status'>
<span className='certificate-label'>Certificate Status:</span>
{certificateExists ? (
<span className='certificate-installed'>Certificate installed</span>
) : (
<span className='certificate-not-installed'>No certificate</span>
)}
</div>
</div>
<div className='form-row'>
<input ref={fileInputRef} type='file' accept='.p12,.pfx' onChange={handleFileSelect} style={{display: 'none'}} />
<div className='file-input-row'>
<Input
label='Certificate File'
value={selectedFile ? selectedFile.name : ''}
onChange={() => {}}
placeholder='No file selected'
readOnly
/>
<Button onClick={handleSelectFileClick} disableResult>
Browse
</Button>
</div>
</div>
<div className='form-row'>
<PasswordInput
label='Certificate Passphrase'
value={signingPassphrase}
onChange={handlePassphraseChange}
placeholder='Leave empty if certificate is not encrypted'
description='Passphrase to unlock the PKCS#12 certificate. Leave empty if the certificate is not password-protected.'
/>
</div>
<div className='form-row'>
<div className='actions-section'>
<Button onClick={handleSave} disabled={!hasChanges}>
Save
</Button>
{canRemove && (
<Button onClick={handleRemove} className='delete-button'>
Remove
</Button>
)}
</div>
</div>
{error && <div className='message-error'>{error}</div>}
{successMessage && <div className='message-success'>{successMessage}</div>}
</Section>
</div>
</div>
);

View File

@ -1,5 +1,6 @@
.settings-page {
margin: 0 auto;
padding-bottom: 40px;
.page-header {
margin-bottom: 32px;
@ -89,32 +90,91 @@
}
}
.error-message {
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
.form-row {
margin-bottom: 24px;
p {
margin: 0;
color: #721c24;
font-size: 14px;
&:last-child {
margin-bottom: 0;
}
}
.success-message {
background: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
.certificate-status {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #e0e0e0;
p {
margin: 0;
color: #155724;
font-size: 14px;
.certificate-label {
font-weight: 500;
color: #333;
}
.certificate-installed {
color: #007b14;
font-weight: 500;
&::before {
content: '';
}
}
.certificate-not-installed {
color: #666;
}
}
.file-input-row {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
input[readonly] {
cursor: default;
background: #f5f5f5;
}
> div {
width: 100%;
}
}
.actions-section {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.delete-button {
background-color: #f5f5f5 !important;
color: #666 !important;
border: 1px solid #e0e0e0 !important;
&:hover {
background-color: #e8e8e8 !important;
}
}
.message-error {
color: #cb0000;
font-size: 13px;
margin-top: 8px;
padding: 8px 12px;
background: #fef2f2;
border-radius: 4px;
}
.message-success {
color: #007b14;
font-size: 13px;
margin-top: 8px;
padding: 8px 12px;
background: #f0fdf4;
border-radius: 4px;
}
}
}

View File

@ -2,6 +2,8 @@
const config = require('config');
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');
const fs = require('fs');
const tenantManager = require('../../../../../Common/sources/tenantManager');
const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager');
const {getScopedConfig, getScopedBaseConfig, validateScoped, getDiffFromBase} = require('./config.service');
@ -113,9 +115,9 @@ router.post('/reset', validateJWT, rawFileParser, async (req, res) => {
} else {
resetConfig = JSON.parse(JSON.stringify(currentConfig));
paths.forEach(path => {
if (path && path !== '*') {
const pathParts = path.split('.');
paths.forEach(pathItem => {
if (pathItem && pathItem !== '*') {
const pathParts = pathItem.split('.');
let current = resetConfig;
for (let i = 0; i < pathParts.length - 1; i++) {
@ -160,4 +162,112 @@ router.post('/reset', validateJWT, rawFileParser, async (req, res) => {
}
});
// Get the fixed signing certificate path from config
function getSigningCertPath() {
return config.get('FileConverter.converter.signingKeyStorePath') || '';
}
// Check signing certificate status (does file exist on disk)
router.get('/signing-certificate/status', validateJWT, async (req, res) => {
const ctx = req.ctx;
try {
// Only admin can check certificate status
if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) {
return res.status(403).json({error: 'Only admin can check signing certificate status'});
}
const certPath = getSigningCertPath();
if (!certPath) {
return res.status(200).json({exists: false, configured: false});
}
const fileExists = fs.existsSync(certPath);
// Don't expose full path - only return existence status
res.status(200).json({exists: fileExists, configured: true});
} catch (error) {
ctx.logger.error('Signing certificate status check error: %s', error.stack);
res.status(500).json({error: 'Failed to check certificate status'});
}
});
// Upload signing certificate (.p12/.pfx file) - replaces file at fixed path from config
router.post('/signing-certificate', validateJWT, rawFileParser, async (req, res) => {
const ctx = req.ctx;
try {
ctx.logger.info('signing certificate upload start');
// Only admin can upload certificates
if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) {
return res.status(403).json({error: 'Only admin can upload signing certificates'});
}
if (!req.body || req.body.length === 0) {
return res.status(400).json({error: 'No file uploaded'});
}
// Basic validation: P12/PFX files should have reasonable size (1KB - 100KB typically)
const MAX_CERT_SIZE = 1024 * 1024; // 1MB max
if (req.body.length > MAX_CERT_SIZE) {
return res.status(400).json({error: 'File too large. Certificate files should be less than 1MB'});
}
const certPath = getSigningCertPath();
if (!certPath) {
return res.status(400).json({error: 'signingKeyStorePath is not configured'});
}
// Ensure directory exists
const certDir = path.dirname(certPath);
if (!fs.existsSync(certDir)) {
fs.mkdirSync(certDir, {recursive: true});
}
// Write the file (overwrites existing)
fs.writeFileSync(certPath, req.body);
ctx.logger.info('Signing certificate uploaded successfully: %s', certPath);
// Don't expose path in response
res.status(200).json({success: true});
} catch (error) {
ctx.logger.error('Signing certificate upload error: %s', error.stack);
res.status(500).json({error: 'Failed to upload certificate'});
} finally {
ctx.logger.info('signing certificate upload end');
}
});
// Delete signing certificate - removes file at fixed path from config
router.delete('/signing-certificate', validateJWT, async (req, res) => {
const ctx = req.ctx;
try {
ctx.logger.info('signing certificate delete start');
// Only admin can delete certificates
if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) {
return res.status(403).json({error: 'Only admin can delete signing certificates'});
}
const certPath = getSigningCertPath();
if (!certPath) {
return res.status(404).json({error: 'signingKeyStorePath is not configured'});
}
// Delete the file if it exists
if (fs.existsSync(certPath)) {
fs.unlinkSync(certPath);
ctx.logger.info('Signing certificate deleted: %s', certPath);
}
res.status(200).json({success: true});
} catch (error) {
ctx.logger.error('Signing certificate delete error: %s', error.stack);
res.status(500).json({error: 'Failed to delete certificate'});
} finally {
ctx.logger.info('signing certificate delete end');
}
});
module.exports = router;

View File

@ -563,6 +563,7 @@
"presentationThemesDir": "null",
"x2tPath": "null",
"docbuilderPath": "null",
"signingKeyStorePath": "",
"args": "",
"spawnOptions": {},
"errorfiles": "",

View File

@ -80,6 +80,7 @@
"presentationThemesDir": "../../sdkjs/slide/themes",
"x2tPath": "../FileConverter/bin/x2t",
"docbuilderPath": "../FileConverter/bin/docbuilder",
"signingKeyStorePath": "./../signing-keystore.p12",
"spawnOptions": {
"env": {
"X2T_MEMORY_LIMIT": "16GB"

View File

@ -86,6 +86,7 @@
"presentationThemesDir": "../../sdkjs/slide/themes",
"x2tPath": "../FileConverter/bin/x2t",
"docbuilderPath": "../FileConverter/Bin/docbuilder",
"signingKeyStorePath": "./../signing-keystore.p12",
"spawnOptions": {
"env": {
"X2T_MEMORY_LIMIT": "16GB"

View File

@ -86,6 +86,7 @@
"presentationThemesDir": "../../sdkjs/slide/themes",
"x2tPath": "../FileConverter/Bin/x2t.exe",
"docbuilderPath": "../FileConverter/Bin/docbuilder.exe",
"signingKeyStorePath": "./../signing-keystore.p12",
"spawnOptions": {
"env": {
"X2T_MEMORY_LIMIT": "16GB"

View File

@ -71,7 +71,8 @@
"fontDir": "/usr/share/fonts",
"presentationThemesDir": "/var/www/onlyoffice/documentserver/sdkjs/slide/themes",
"x2tPath": "/var/www/onlyoffice/documentserver/server/FileConverter/bin/x2t",
"docbuilderPath": "/var/www/onlyoffice/documentserver/server/FileConverter/bin/docbuilder"
"docbuilderPath": "/var/www/onlyoffice/documentserver/server/FileConverter/bin/docbuilder",
"signingKeyStorePath": "/var/www/onlyoffice/documentserver/../Data/signing-keystore.p12"
}
},
"SpellChecker": {

View File

@ -67,7 +67,8 @@
"fontDir": "",
"presentationThemesDir": "../../sdkjs/slide/themes",
"x2tPath": "../FileConverter/bin/x2t.exe",
"docbuilderPath": "../FileConverter/bin/docbuilder.exe"
"docbuilderPath": "../FileConverter/bin/docbuilder.exe",
"signingKeyStorePath": "./../signing-keystore.p12"
}
},
"SpellChecker": {

View File

@ -217,6 +217,28 @@
"additionalProperties": false,
"properties": {
"maxDownloadBytes": {"type": "integer", "minimum": 0, "maximum": 10485760000, "x-scope": ["admin", "tenant"]},
"signingKeyStorePath": {
"type": "string",
"description": "Path to PKCS#12 (.p12/.pfx) keystore file for PDF document signing",
"x-scope": "admin"
},
"spawnOptions": {
"type": "object",
"additionalProperties": true,
"x-scope": "admin",
"properties": {
"env": {
"type": "object",
"additionalProperties": true,
"properties": {
"SIGNING_KEYSTORE_PASSPHRASE": {
"type": "string",
"description": "Passphrase for the PKCS#12 keystore file"
}
}
}
}
},
"inputLimits": {
"type": "array",
"x-scope": ["admin", "tenant"],

View File

@ -69,6 +69,7 @@ const cfgSpawnOptions = config.util.cloneDeep(config.get('FileConverter.converte
const cfgErrorFiles = config.get('FileConverter.converter.errorfiles');
const cfgInputLimits = config.get('FileConverter.converter.inputLimits');
const cfgStreamWriterBufferSize = config.get('FileConverter.converter.streamWriterBufferSize');
const cfgSigningKeyStorePath = config.get('FileConverter.converter.signingKeyStorePath');
//cfgMaxRequestChanges was obtained as a result of the test: 84408 changes - 5,16 MB
const cfgMaxRequestChanges = config.get('services.CoAuthoring.server.maxRequestChanges');
const cfgForgottenFiles = config.get('services.CoAuthoring.server.forgottenfiles');
@ -182,6 +183,12 @@ TaskQueueDataConvert.prototype = {
xml += this.serializeXmlProp('m_oTimestamp', this.timestamp.toISOString());
xml += this.serializeXmlProp('m_bIsNoBase64', this.noBase64);
xml += this.serializeXmlProp('m_sConvertToOrigin', this.convertToOrigin);
if (this.formatTo === constants.AVS_OFFICESTUDIO_FILE_DOCUMENT_OFORM_PDF) {
const signingKeyStorePath = ctx.getCfg('FileConverter.converter.signingKeyStorePath', cfgSigningKeyStorePath);
if (signingKeyStorePath && fs.existsSync(signingKeyStorePath)) {
xml += this.serializeXmlProp('m_sSigningKeyStorePath', signingKeyStorePath);
}
}
xml += this.serializeLimit(ctx);
xml += this.serializeOptions(ctx, false, this.oformAsPdf);
xml += '</TaskQueueDataConvert>';