Compare commits
15 Commits
fix/taskpr
...
jtr/refact
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e127253245 | ||
|
|
6a010dcfef | ||
|
|
e17e610987 | ||
|
|
f1f1b14e1f | ||
|
|
9447c564a5 | ||
|
|
01eba7a2a1 | ||
|
|
a8b7a01ef3 | ||
|
|
d0b9a608ad | ||
|
|
aab70ac84b | ||
|
|
d4e2146ab5 | ||
|
|
cb1a6f79e7 | ||
|
|
2fd87955e6 | ||
|
|
62539ecacf | ||
|
|
ba01412389 | ||
|
|
769ec69bb3 |
@@ -8,6 +8,7 @@
|
||||
namespace OCA\Encryption\Crypto;
|
||||
|
||||
use OC\Encryption\Exceptions\DecryptionFailedException;
|
||||
use OC\Encryption\Exceptions\EncryptionFailedException;
|
||||
use OC\Files\Cache\Scanner;
|
||||
use OC\Files\View;
|
||||
use OCA\Encryption\Exceptions\MultiKeyEncryptException;
|
||||
@@ -21,42 +22,108 @@ use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Implements an encryption module for Nextcloud's Server-side Encryption (SSE).
|
||||
*
|
||||
* @since 9.1.0
|
||||
*/
|
||||
class Encryption implements IEncryptionModule {
|
||||
// The technical ID for this encryption module. Must be unique.
|
||||
public const ID = 'OC_DEFAULT_MODULE';
|
||||
// Human-readable name of this encryption module.
|
||||
public const DISPLAY_NAME = 'Default encryption module';
|
||||
|
||||
/** @var string */
|
||||
private $cipher;
|
||||
/**
|
||||
* The cipher algorithm used for encrypting and decrypting file contents (e.g., AES-256-CFB).
|
||||
* Set during file operations based on file header or defaults.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $cipher;
|
||||
|
||||
/** @var string */
|
||||
private $path;
|
||||
/**
|
||||
* The absolute path to the file being processed.
|
||||
* Used for key management and file operations.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $path;
|
||||
|
||||
/** @var string */
|
||||
private $user;
|
||||
/**
|
||||
* Username of the user performing the read/write operation.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $user;
|
||||
|
||||
/**
|
||||
* Cached map of file paths to their respective owners.
|
||||
* Used to avoid repeated lookups.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $owner;
|
||||
|
||||
/** @var string */
|
||||
private $fileKey;
|
||||
/**
|
||||
* The encryption key used for the current file operation.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $fileKey;
|
||||
|
||||
/** @var string */
|
||||
private $writeCache;
|
||||
/**
|
||||
* Buffer/cache for data that has not yet been encrypted and written.
|
||||
* Used for block-wise encryption.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $writeCache;
|
||||
|
||||
/** @var array */
|
||||
private $accessList;
|
||||
/**
|
||||
* List of users and public entities that have access to the file.
|
||||
* Contains the keys 'users' and 'public'.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private array $accessList;
|
||||
|
||||
/** @var boolean */
|
||||
private $isWriteOperation;
|
||||
/**
|
||||
* Indicates whether the current operation is a write operation.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private bool $isWriteOperation;
|
||||
|
||||
/**
|
||||
* Indicates whether the master password (master key) is being used for encryption.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private bool $useMasterPassword;
|
||||
|
||||
/**
|
||||
* Flag for whether legacy base64 encoding is used for file encryption.
|
||||
* Legacy encoding affects block size calculation and compatibility.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private bool $useLegacyBase64Encoding = false;
|
||||
|
||||
/** @var int Current version of the file */
|
||||
/**
|
||||
* The current version of the file being processed.
|
||||
* Used for key and signature versioning.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private int $version = 0;
|
||||
|
||||
/** @var array remember encryption signature version */
|
||||
private static $rememberVersion = [];
|
||||
/**
|
||||
* Static cache mapping file paths to remembered encryption signature versions.
|
||||
* Used during multipart and update operations.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private static array $rememberVersion = [];
|
||||
|
||||
public function __construct(
|
||||
private Crypt $crypt,
|
||||
@@ -73,103 +140,118 @@ class Encryption implements IEncryptionModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string defining the technical unique id
|
||||
* Returns the technical unique ID of the encryption module.
|
||||
*
|
||||
* @return string Technical unique ID.
|
||||
*/
|
||||
public function getId() {
|
||||
public function getId(): string {
|
||||
return self::ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* In comparison to getKey() this function returns a human readable (maybe translated) name
|
||||
* Unlike getId(), this function returns a human-readable (possibly translated) name of the encryption module.
|
||||
*
|
||||
* @return string
|
||||
* @return string Display name of the encryption module.
|
||||
*/
|
||||
public function getDisplayName() {
|
||||
public function getDisplayName(): string {
|
||||
return self::DISPLAY_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* start receiving chunks from a file. This is the place where you can
|
||||
* perform some initial step before starting encrypting/decrypting the
|
||||
* chunks
|
||||
* Initializes the encryption or decryption process for a file.
|
||||
*
|
||||
* @param string $path to the file
|
||||
* @param string $user who read/write the file
|
||||
* @param string $mode php stream open mode
|
||||
* @param array $header contains the header data read from the file
|
||||
* @param array $accessList who has access to the file contains the key 'users' and 'public'
|
||||
* i.e. Start receiving chunks from a file. This is the place to perform any initial steps
|
||||
* before starting encryption/decryption of the chunks.
|
||||
*
|
||||
* @return array $header contain data as key-value pairs which should be
|
||||
* written to the header, in case of a write operation
|
||||
* or if no additional data is needed return a empty array
|
||||
* @param string $path Path to the file being processed.
|
||||
* @param string $user User performing the operation.
|
||||
* @param string $mode Operation mode ('r' for read, 'w' for write).
|
||||
* @param array $header File encryption metadata/header.
|
||||
* @param array $accessList List of users/keys with access.
|
||||
* @return array Metadata for further processing (i.e. header data key-value pairs if writing or empty array if done).
|
||||
*/
|
||||
public function begin($path, $user, $mode, array $header, array $accessList) {
|
||||
public function begin(string $path, string $user, string $mode, array $header, array $accessList): array {
|
||||
// All write-capable modes supported by Nextcloud.
|
||||
$writeModes = ['w', 'w+', 'wb', 'wb+'];
|
||||
// TODO: Also sanitize non-write modes rather than just assuming read mode
|
||||
|
||||
// Initialize properties.
|
||||
$this->path = $this->getPathToRealFile($path);
|
||||
$this->accessList = $accessList;
|
||||
$this->user = $user;
|
||||
$this->isWriteOperation = false;
|
||||
$this->accessList = $accessList;
|
||||
$this->writeCache = '';
|
||||
$this->useLegacyBase64Encoding = true;
|
||||
|
||||
// Determine if this is a write operation
|
||||
$this->isWriteOperation = in_array($mode, $writeModes, true);
|
||||
|
||||
if (isset($header['encoding'])) {
|
||||
$this->useLegacyBase64Encoding = $header['encoding'] !== Crypt::BINARY_ENCODING_FORMAT;
|
||||
// Ensure encryption session is ready; if using master key initialize it
|
||||
if (!$this->session->isReady() && $this->util->isMasterKeyEnabled()) {
|
||||
$this->keyManager->init('', ''); // empty password and username
|
||||
}
|
||||
|
||||
if ($this->session->isReady() === false) {
|
||||
// if the master key is enabled we can initialize encryption
|
||||
// with a empty password and user name
|
||||
if ($this->util->isMasterKeyEnabled()) {
|
||||
$this->keyManager->init('', '');
|
||||
}
|
||||
// Detect legacy file key usage.
|
||||
$useLegacyFileKey = null;
|
||||
if (isset($header['useLegacyFileKey'])) {
|
||||
// NOTE: null means "try both" {@see OCA\Encryption\KeyManager::getFileKey()}
|
||||
// XXX: If 'true' we could probably explicitly treat it as bool true rather than null/both?
|
||||
$useLegacyFileKey = ($header['useLegacyFileKey'] === 'false') ? false : null;
|
||||
}
|
||||
|
||||
/* If useLegacyFileKey is not specified in header, auto-detect, to be safe */
|
||||
$useLegacyFileKey = (($header['useLegacyFileKey'] ?? '') == 'false' ? false : null);
|
||||
// Always use the version from the original file.
|
||||
// Also part files need to have a correct version number if they get moved to the final location.
|
||||
$strippedPath = $this->util->stripPartialFileExtension($path);
|
||||
$this->version = (int)$this->keyManager->getVersion($strippedPath, new View());
|
||||
|
||||
$this->fileKey = $this->keyManager->getFileKey($this->path, $useLegacyFileKey, $this->session->decryptAllModeActivated());
|
||||
|
||||
// always use the version from the original file, also part files
|
||||
// need to have a correct version number if they get moved over to the
|
||||
// final location
|
||||
$this->version = (int)$this->keyManager->getVersion($this->stripPartFileExtension($path), new View());
|
||||
|
||||
if (
|
||||
$mode === 'w'
|
||||
|| $mode === 'w+'
|
||||
|| $mode === 'wb'
|
||||
|| $mode === 'wb+'
|
||||
) {
|
||||
$this->isWriteOperation = true;
|
||||
// Determine if "decrypt all" mode is enabled for the current session (influences how file key is retrieved)
|
||||
$decryptAllMode = $this->session->decryptAllModeActivated();
|
||||
|
||||
// Retrieve appropriate file key based on mode/etc for the user
|
||||
$this->fileKey = $this->keyManager->getFileKey($this->path, $useLegacyFileKey, $decryptAllMode);
|
||||
|
||||
// Set cipher and and encoding file key based on operation mode.
|
||||
if ($this->isWriteOperation) {
|
||||
// For write operations:
|
||||
// - Use the cipher configured for writing.
|
||||
// - Update encoding if required by cipher.
|
||||
// - Generate key file for the user (if it doesn't exist).
|
||||
$this->cipher = $this->crypt->getCipher();
|
||||
$this->useLegacyBase64Encoding = $this->crypt->useLegacyBase64Encoding();
|
||||
if (empty($this->fileKey)) {
|
||||
// Generate a new file key if none exists (new file or new encryption context)
|
||||
$this->fileKey = $this->crypt->generateFileKey();
|
||||
}
|
||||
} else {
|
||||
// if we read a part file we need to increase the version by 1
|
||||
// because the version number was also increased by writing
|
||||
// the part file
|
||||
if (Scanner::isPartialFile($path)) {
|
||||
$this->version = $this->version + 1;
|
||||
// For read operations:
|
||||
// - Use the encoding specified in the header, otherwise assume legacy (<=oC6).
|
||||
// - Use the cipher specified in the header, otherwise assume legacy (<=oC6).
|
||||
if (isset($header['cipher'])) {
|
||||
$this->cipher = $header['cipher'];
|
||||
} else {
|
||||
$this->cipher = $this->crypt->getLegacyCipher();
|
||||
// TODO: Optionally log legacy / heading missing fallback (at debug or info level)
|
||||
}
|
||||
if (isset($header['encoding'])) {
|
||||
$this->useLegacyBase64Encoding = $header['encoding'] !== Crypt::BINARY_ENCODING_FORMAT;
|
||||
} else {
|
||||
$this->useLegacyBase64Encoding = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->isWriteOperation) {
|
||||
$this->cipher = $this->crypt->getCipher();
|
||||
$this->useLegacyBase64Encoding = $this->crypt->useLegacyBase64Encoding();
|
||||
} elseif (isset($header['cipher'])) {
|
||||
$this->cipher = $header['cipher'];
|
||||
} else {
|
||||
// if we read a file without a header we fall-back to the legacy cipher
|
||||
// which was used in <=oC6
|
||||
$this->cipher = $this->crypt->getLegacyCipher();
|
||||
// TODO: Decide when we can safely remove some of the legacy (<=oC6) stuff above (and elsewhere)
|
||||
|
||||
// If we read a part file we need to increase the version by 1
|
||||
// because the version number was also increased by writing the part file.
|
||||
if (!$this->isWriteOperation && Scanner::isPartialFile($path)) {
|
||||
$this->version = $this->version + 1;
|
||||
}
|
||||
|
||||
// Prepare metadata for the caller (used for header updates, logging, etc).
|
||||
$result = [
|
||||
'cipher' => $this->cipher,
|
||||
'signed' => 'true',
|
||||
// XXX: Confirm hardcoding this is correct; shouldn't this be set based on value of $useLegacyFileKey?
|
||||
'useLegacyFileKey' => 'false',
|
||||
];
|
||||
|
||||
if ($this->useLegacyBase64Encoding !== true) {
|
||||
$result['encoding'] = Crypt::BINARY_ENCODING_FORMAT;
|
||||
}
|
||||
@@ -178,223 +260,299 @@ class Encryption implements IEncryptionModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* last chunk received. This is the place where you can perform some final
|
||||
* operation and return some remaining data if something is left in your
|
||||
* buffer.
|
||||
* Finalizes the encryption process for a file, handling buffered data and key updates.
|
||||
*
|
||||
* @param string $path to the file
|
||||
* @param string $position
|
||||
* @return string remained data which should be written to the file in case
|
||||
* of a write operation
|
||||
* @param string $path Path to the file being finalized.
|
||||
* @param string $position Position in the file (for block-wise encryption).
|
||||
* @return string Remaining encrypted data to be written, if any.
|
||||
* @throws PublicKeyMissingException
|
||||
* @throws \Exception
|
||||
* @throws MultiKeyEncryptException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function end($path, $position = '0') {
|
||||
public function end(string $path, string $position = '0'): string {
|
||||
// Only perform actions if this is a write operation.
|
||||
if (!$this->isWriteOperation) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remember new signature version for partial files.
|
||||
if (Scanner::isPartialFile($path)) {
|
||||
self::$rememberVersion[$this->util->stripPartialFileExtension($path)] = $this->version + 1;
|
||||
}
|
||||
|
||||
// Encrypt any remaining data in the write cache.
|
||||
$result = '';
|
||||
if ($this->isWriteOperation) {
|
||||
// in case of a part file we remember the new signature versions
|
||||
// the version will be set later on update.
|
||||
// This way we make sure that other apps listening to the pre-hooks
|
||||
// still get the old version which should be the correct value for them
|
||||
if (Scanner::isPartialFile($path)) {
|
||||
self::$rememberVersion[$this->stripPartFileExtension($path)] = $this->version + 1;
|
||||
}
|
||||
if (!empty($this->writeCache)) {
|
||||
$result = $this->crypt->symmetricEncryptFileContent($this->writeCache, $this->fileKey, $this->version + 1, $position);
|
||||
if (!empty($this->writeCache)) {
|
||||
try {
|
||||
$result = $this->crypt->symmetricEncryptFileContent(
|
||||
$this->writeCache,
|
||||
$this->fileKey,
|
||||
$this->version + 1,
|
||||
$position
|
||||
);
|
||||
$this->writeCache = '';
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Encryption failure during final block: ' . $e->getMessage(), [
|
||||
'file' => $this->path ?? $path,
|
||||
'user' => $this->user ?? 'unknown',
|
||||
'app' => 'encryption'
|
||||
]);
|
||||
throw new EncryptionFailedException(
|
||||
'Encryption failed during final block.',
|
||||
$e->getMessage(),
|
||||
$e->getCode(),
|
||||
$e
|
||||
);
|
||||
}
|
||||
$publicKeys = [];
|
||||
if ($this->useMasterPassword === true) {
|
||||
$publicKeys[$this->keyManager->getMasterKeyId()] = $this->keyManager->getPublicMasterKey();
|
||||
} else {
|
||||
foreach ($this->accessList['users'] as $uid) {
|
||||
try {
|
||||
$publicKeys[$uid] = $this->keyManager->getPublicKey($uid);
|
||||
} catch (PublicKeyMissingException $e) {
|
||||
$this->logger->warning(
|
||||
'no public key found for user "{uid}", user will not be able to read the file',
|
||||
['app' => 'encryption', 'uid' => $uid]
|
||||
);
|
||||
// if the public key of the owner is missing we should fail
|
||||
if ($uid === $this->user) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the list of public keys required for the finalized access list.
|
||||
$publicKeys = [];
|
||||
if ($this->useMasterPassword === true) {
|
||||
$masterKeyId = $this->keyManager->getMasterKeyId();
|
||||
$publicKeys[$masterKeyId] = $this->keyManager->getPublicMasterKey();
|
||||
} else {
|
||||
foreach ($this->accessList['users'] as $accessUid) {
|
||||
try {
|
||||
$publicKeys[$accessUid] = $this->keyManager->getPublicKey($accessUid);
|
||||
} catch (PublicKeyMissingException $e) {
|
||||
$this->logger->warning(
|
||||
'No public key found for user "{uid}", user will not be able to read the file.',
|
||||
['uid' => $accessUid, 'error' => $e->getMessage(), 'app' => 'encryption']
|
||||
);
|
||||
// If the public key of the owner is missing we should fail.
|
||||
if ($accessUid === $this->user) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$publicKeys = $this->keyManager->addSystemKeys($this->accessList, $publicKeys, $this->getOwner($path));
|
||||
$shareKeys = $this->crypt->multiKeyEncrypt($this->fileKey, $publicKeys);
|
||||
if (!$this->keyManager->deleteLegacyFileKey($this->path)) {
|
||||
$this->logger->warning(
|
||||
'Failed to delete legacy filekey for {path}',
|
||||
['app' => 'encryption', 'path' => $path]
|
||||
);
|
||||
}
|
||||
foreach ($shareKeys as $uid => $keyFile) {
|
||||
$this->keyManager->setShareKey($this->path, $uid, $keyFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Add system-level keys (e.g. public link share keys, recovery keys).
|
||||
$owner = $this->getOwner($path);
|
||||
$publicKeys = $this->keyManager->addSystemKeys($this->accessList, $publicKeys, $owner);
|
||||
|
||||
// Encrypt the file key for all relevant public keys.
|
||||
$shareKeys = $this->crypt->multiKeyEncrypt($this->fileKey, $publicKeys);
|
||||
|
||||
// Remove legacy file keys for improved security.
|
||||
if (!$this->keyManager->deleteLegacyFileKey($this->path)) {
|
||||
$this->logger->warning(
|
||||
'Failed to delete legacy filekey for {path}.',
|
||||
['app' => 'encryption', 'path' => $path]
|
||||
);
|
||||
}
|
||||
|
||||
// Store the new share keys for each user.
|
||||
foreach ($shareKeys as $shareKeyUid => $keyFile) {
|
||||
$this->keyManager->setShareKey($this->path, $shareKeyUid, $keyFile);
|
||||
}
|
||||
|
||||
return $result ?: '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* encrypt data
|
||||
* Encrypts a chunk of file data.
|
||||
*
|
||||
* @param string $data you want to encrypt
|
||||
* @param int $position
|
||||
* @return string encrypted data
|
||||
* @param string $data The plaintext data to encrypt.
|
||||
* @param int $position The position in the file (for block-wise encryption).
|
||||
* @return string The encrypted data.
|
||||
* @throws EncryptionFailedException If encryption fails.
|
||||
*/
|
||||
public function encrypt($data, $position = 0) {
|
||||
// If extra data is left over from the last round, make sure it
|
||||
// is integrated into the next block
|
||||
if ($this->writeCache) {
|
||||
// Concat writeCache to start of $data
|
||||
public function encrypt(string $data, int $position = 0): string {
|
||||
// Integrate leftover buffered data from previous round.
|
||||
if (!empty($this->writeCache)) {
|
||||
$data = $this->writeCache . $data;
|
||||
|
||||
// Clear the write cache, ready for reuse - it has been
|
||||
// flushed and its old contents processed
|
||||
$this->writeCache = '';
|
||||
}
|
||||
|
||||
$encrypted = '';
|
||||
// While there still remains some data to be processed & written
|
||||
$blockSize = $this->getUnencryptedBlockSize(true);
|
||||
|
||||
// Process data in block-sized chunks.
|
||||
while (strlen($data) > 0) {
|
||||
// Remaining length for this iteration, not of the
|
||||
// entire file (may be greater than 8192 bytes)
|
||||
$remainingLength = strlen($data);
|
||||
|
||||
// If data remaining to be written is less than the
|
||||
// size of 1 unencrypted block
|
||||
if ($remainingLength < $this->getUnencryptedBlockSize(true)) {
|
||||
// Set writeCache to contents of $data
|
||||
// The writeCache will be carried over to the
|
||||
// next write round, and added to the start of
|
||||
// $data to ensure that written blocks are
|
||||
// always the correct length. If there is still
|
||||
// data in writeCache after the writing round
|
||||
// has finished, then the data will be written
|
||||
// to disk by $this->flush().
|
||||
// Buffer incomplete blocks for future encryption.
|
||||
if ($remainingLength < $blockSize) {
|
||||
$this->writeCache = $data;
|
||||
|
||||
// Clear $data ready for next round
|
||||
$data = '';
|
||||
} else {
|
||||
// Read the chunk from the start of $data
|
||||
$chunk = substr($data, 0, $this->getUnencryptedBlockSize(true));
|
||||
|
||||
$encrypted .= $this->crypt->symmetricEncryptFileContent($chunk, $this->fileKey, $this->version + 1, (string)$position);
|
||||
|
||||
// Remove the chunk we just processed from
|
||||
// $data, leaving only unprocessed data in $data
|
||||
// var, for handling on the next round
|
||||
$data = substr($data, $this->getUnencryptedBlockSize(true));
|
||||
break;
|
||||
}
|
||||
// Encrypt a full block.
|
||||
$chunk = substr($data, 0, $blockSize);
|
||||
|
||||
try {
|
||||
$encryptedChunk = $this->crypt->symmetricEncryptFileContent(
|
||||
$chunk,
|
||||
$this->fileKey,
|
||||
$this->version + 1,
|
||||
(string)$position
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Encryption failure: ' . $e->getMessage(), [
|
||||
'file' => $this->path ?? 'unknown',
|
||||
'user' => $this->user ?? 'unknown',
|
||||
'app' => 'encryption'
|
||||
]);
|
||||
throw new \OC\Encryption\Exceptions\EncryptionFailedException(
|
||||
'Encryption failed.',
|
||||
$e->getMessage(),
|
||||
$e->getCode(),
|
||||
$e
|
||||
);
|
||||
}
|
||||
|
||||
$encrypted .= $encryptedChunk;
|
||||
|
||||
// Remove processed chunk from data.
|
||||
$data = substr($data, $blockSize);
|
||||
}
|
||||
|
||||
return $encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* decrypt data
|
||||
* Decrypts a chunk of encrypted file data.
|
||||
*
|
||||
* @param string $data you want to decrypt
|
||||
* @param int|string $position
|
||||
* @return string decrypted data
|
||||
* @throws DecryptionFailedException
|
||||
* @param string $data The encrypted data to decrypt.
|
||||
* @param int|string $position Position in the file (for block-wise decryption).
|
||||
* @return string The decrypted data chunk.
|
||||
* @throws DecryptionFailedException If the file key is missing or decryption fails.
|
||||
*/
|
||||
public function decrypt($data, $position = 0) {
|
||||
public function decrypt(string $data, int|string $position = 0): string {
|
||||
// Robustness: Ensure we have the file key before attempting decryption.
|
||||
if (empty($this->fileKey)) {
|
||||
$msg = 'Cannot decrypt this file, probably this is a shared file. Please ask the file owner to reshare the file with you.';
|
||||
$hint = $this->l->t('Cannot decrypt this file, probably this is a shared file. Please ask the file owner to reshare the file with you.');
|
||||
$this->logger->error($msg);
|
||||
|
||||
throw new DecryptionFailedException($msg, $hint);
|
||||
$message = 'Cannot decrypt this file; this is probably a shared file. Please ask the file owner to reshare the file with you.';
|
||||
$hint = $this->l->t($message);
|
||||
$this->logger->error($message, [
|
||||
'file' => $this->path ?? 'unknown',
|
||||
'user' => $this->user ?? 'unknown',
|
||||
'app' => 'encryption'
|
||||
]);
|
||||
throw new DecryptionFailedException($message, $hint);
|
||||
}
|
||||
|
||||
return $this->crypt->symmetricDecryptFileContent($data, $this->fileKey, $this->cipher, $this->version, $position, !$this->useLegacyBase64Encoding);
|
||||
// Perform decryption using the cryptographic service.
|
||||
try {
|
||||
return $this->crypt->symmetricDecryptFileContent(
|
||||
$data,
|
||||
$this->fileKey,
|
||||
$this->cipher,
|
||||
$this->version,
|
||||
(string)$position,
|
||||
!$this->useLegacyBase64Encoding
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Robustness: Catch all exceptions, log, and throw a uniform decryption failure.
|
||||
$errorMsg = 'Decryption failure: ' . $e->getMessage();
|
||||
$this->logger->error($errorMsg, [
|
||||
'file' => $this->path ?? 'unknown',
|
||||
'user' => $this->user ?? 'unknown',
|
||||
'app' => 'encryption'
|
||||
]);
|
||||
throw new DecryptionFailedException('Decryption failed.', $errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* update encrypted file, e.g. give additional users access to the file
|
||||
* Updates the encrypted file’s access keys for new access permissions.
|
||||
*
|
||||
* @param string $path path to the file which should be updated
|
||||
* @param string $uid ignored
|
||||
* @param array $accessList who has access to the file contains the key 'users' and 'public'
|
||||
* @return bool
|
||||
* @param string $path Path to the file to update.
|
||||
* @param string $uid User performing the operation.
|
||||
* @param array $accessList Access permissions: ['users' => [...], 'public' => [...]].
|
||||
* @return bool True on success, false otherwise.
|
||||
*/
|
||||
public function update($path, $uid, array $accessList) {
|
||||
public function update(string $path, string $uid, array $accessList): bool {
|
||||
// If no access list is provided, handle possible remembered version and return early.
|
||||
if (empty($accessList)) {
|
||||
if (isset(self::$rememberVersion[$path])) {
|
||||
$this->keyManager->setVersion($path, self::$rememberVersion[$path], new View());
|
||||
$version = self::$rememberVersion[$path];
|
||||
$this->keyManager->setVersion($path, $version, new View());
|
||||
unset(self::$rememberVersion[$path]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch the file encryption key.
|
||||
$fileKey = $this->keyManager->getFileKey($path, null);
|
||||
if (empty($fileKey)) {
|
||||
$this->logger->debug(
|
||||
'No file key found; assuming file "{file}" is not encrypted.',
|
||||
['file' => $path, 'app' => 'encryption']
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($fileKey)) {
|
||||
$publicKeys = [];
|
||||
if ($this->useMasterPassword === true) {
|
||||
$publicKeys[$this->keyManager->getMasterKeyId()] = $this->keyManager->getPublicMasterKey();
|
||||
} else {
|
||||
foreach ($accessList['users'] as $user) {
|
||||
try {
|
||||
$publicKeys[$user] = $this->keyManager->getPublicKey($user);
|
||||
} catch (PublicKeyMissingException $e) {
|
||||
$this->logger->warning('Could not encrypt file for ' . $user . ': ' . $e->getMessage());
|
||||
// Build the list of public keys required for the updated access list.
|
||||
$publicKeys = [];
|
||||
if ($this->useMasterPassword) {
|
||||
$masterKeyId = $this->keyManager->getMasterKeyId();
|
||||
$publicKeys[$masterKeyId] = $this->keyManager->getPublicMasterKey();
|
||||
} else {
|
||||
foreach ($accessList['users'] as $accessUid) {
|
||||
try {
|
||||
$publicKeys[$accessUid] = $this->keyManager->getPublicKey($accessUid);
|
||||
} catch (PublicKeyMissingException $e) {
|
||||
$this->logger->warning(
|
||||
'No public key found for user "{user}", user will not be able to read the file.',
|
||||
['user' => $accessUid, 'error' => $e->getMessage(), 'app' => 'encryption']
|
||||
);
|
||||
// Robustness: continue so file isn't left inaccessible, but missing keys/users won't have access.
|
||||
// Exception: If the public key of the owner is missing we should outright fail (or at least pass
|
||||
// the buck more strongly than just logging the warning like for others).
|
||||
if ($accessUid === $this->user) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$publicKeys = $this->keyManager->addSystemKeys($accessList, $publicKeys, $this->getOwner($path));
|
||||
// Add system-level keys (e.g. public link share and recovery keys).
|
||||
$owner = $this->getOwner($path);
|
||||
$publicKeys = $this->keyManager->addSystemKeys($accessList, $publicKeys, $owner);
|
||||
|
||||
$shareKeys = $this->crypt->multiKeyEncrypt($fileKey, $publicKeys);
|
||||
// Encrypt the file key for all relevant public keys.
|
||||
$shareKeys = $this->crypt->multiKeyEncrypt($fileKey, $publicKeys);
|
||||
|
||||
$this->keyManager->deleteAllFileKeys($path);
|
||||
// Remove all previous share keys (security: avoid stale access).
|
||||
$this->keyManager->deleteAllFileKeys($path);
|
||||
|
||||
foreach ($shareKeys as $uid => $keyFile) {
|
||||
$this->keyManager->setShareKey($path, $uid, $keyFile);
|
||||
}
|
||||
} else {
|
||||
$this->logger->debug('no file key found, we assume that the file "{file}" is not encrypted',
|
||||
['file' => $path, 'app' => 'encryption']);
|
||||
|
||||
return false;
|
||||
// Write the new share keys for each user.
|
||||
foreach ($shareKeys as $shareKeyUid => $keyFile) {
|
||||
$this->keyManager->setShareKey($path, $shareKeyUid, $keyFile);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* should the file be encrypted or not
|
||||
* Determines if a file at the given path should be encrypted based on storage type and path characteristics.
|
||||
*
|
||||
* @param string $path
|
||||
* @return boolean
|
||||
* @param string $path The file path to check.
|
||||
* @return bool True if the file should be encrypted, false otherwise.
|
||||
*/
|
||||
public function shouldEncrypt($path) {
|
||||
public function shouldEncrypt(string $path): bool {
|
||||
// If home storage encryption is disabled, and this is a home storage, don't encrypt
|
||||
if ($this->util->shouldEncryptHomeStorage() === false) {
|
||||
$storage = $this->util->getStorage($path);
|
||||
if ($storage && $storage->instanceOfStorage('\OCP\Files\IHomeStorage')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the path has enough segments to be valid for encryption
|
||||
$parts = explode('/', $path);
|
||||
if (count($parts) < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($parts[2] === 'files') {
|
||||
return true;
|
||||
}
|
||||
if ($parts[2] === 'files_versions') {
|
||||
return true;
|
||||
}
|
||||
if ($parts[2] === 'files_trashbin') {
|
||||
// Only encrypt files in certain folders
|
||||
$encryptFolders = [
|
||||
'files',
|
||||
'files_versions',
|
||||
'files_trashbin'
|
||||
];
|
||||
if (in_array($parts[2], $encryptFolders, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -402,56 +560,67 @@ class Encryption implements IEncryptionModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* get size of the unencrypted payload per block.
|
||||
* Nextcloud read/write files with a block size of 8192 byte
|
||||
* Returns the maximum number of bytes of unencrypted data that can fit in a single file block,
|
||||
* taking into account encryption overhead and encoding.
|
||||
*
|
||||
* Block structure details:
|
||||
* - Base block size: 8192 bytes
|
||||
* - Overheads:
|
||||
* - IV: 22 bytes
|
||||
* - Padding: 2 bytes (unsigned), +1 byte if signed
|
||||
* - Signature: 71 bytes (if signed)
|
||||
* - Legacy base64 encoding further reduces capacity by a factor of 0.75
|
||||
*
|
||||
* Encrypted blocks have a 22-byte IV and 2 bytes of padding, encrypted and
|
||||
* signed blocks have also a 71-byte signature and 1 more byte of padding,
|
||||
* resulting respectively in:
|
||||
* Final sizes:
|
||||
* - Unsigned binary: 8168 bytes
|
||||
* - Signed binary: 8096 bytes
|
||||
* - Unsigned base64: 6126 bytes
|
||||
* - Signed base64: 6072 bytes
|
||||
*
|
||||
* 8192 - 22 - 2 = 8168 bytes in each unsigned unencrypted block
|
||||
* 8192 - 22 - 2 - 71 - 1 = 8096 bytes in each signed unencrypted block
|
||||
*
|
||||
* Legacy base64 encoding then reduces the available size by a 3/4 factor:
|
||||
*
|
||||
* 8168 * (3/4) = 6126 bytes in each base64-encoded unsigned unencrypted block
|
||||
* 8096 * (3/4) = 6072 bytes in each base64-encoded signed unencrypted block
|
||||
*
|
||||
* @param bool $signed
|
||||
* @return int
|
||||
* @param bool $signed Whether the block is cryptographically signed.
|
||||
* @return int Number of bytes available for unencrypted data.
|
||||
*/
|
||||
public function getUnencryptedBlockSize($signed = false) {
|
||||
if ($this->useLegacyBase64Encoding) {
|
||||
return $signed ? 6072 : 6126;
|
||||
} else {
|
||||
public function getUnencryptedBlockSize(bool $signed = false): int {
|
||||
if (!$this->useLegacyBase64Encoding) {
|
||||
return $signed ? 8096 : 8168;
|
||||
} else {
|
||||
return $signed ? 6072 : 6126;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the encryption module is able to read the file,
|
||||
* e.g. if all encryption keys exists
|
||||
* Checks if the specified file is readable (decryptable) for the given user.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $uid user for whom we want to check if they can read the file
|
||||
* @return bool
|
||||
* @throws DecryptionFailedException
|
||||
* @param string $path Path to the file.
|
||||
* @param string $uid User for whom to check readability.
|
||||
* @return bool True if readable, false otherwise.
|
||||
* @throws DecryptionFailedException If the file is shared and the user can't read it.
|
||||
*/
|
||||
public function isReadable($path, $uid) {
|
||||
public function isReadable(string $path, string $uid): bool {
|
||||
$fileKey = $this->keyManager->getFileKey($path, null);
|
||||
|
||||
if (empty($fileKey)) {
|
||||
$owner = $this->util->getOwner($path);
|
||||
|
||||
if ($owner !== $uid) {
|
||||
// if it is a shared file we throw a exception with a useful
|
||||
// error message because in this case it means that the file was
|
||||
// shared with the user at a point where the user didn't had a
|
||||
// valid private/public key
|
||||
// If it is a shared file, throw an exception with a useful
|
||||
// error message, because this means the file was shared
|
||||
// with the user at a point where the user didn't have a
|
||||
// valid private/public key.
|
||||
$msg = 'Encryption module "' . $this->getDisplayName()
|
||||
. '" is not able to read ' . $path;
|
||||
$hint = $this->l->t('Cannot read this file, probably this is a shared file. Please ask the file owner to reshare the file with you.');
|
||||
$msg = sprintf(
|
||||
'Encryption module "%s" is not able to read %s',
|
||||
$this->getDisplayName(),
|
||||
$path
|
||||
);
|
||||
$hint = $this->l->t(
|
||||
'Cannot read this file, probably this is a shared file. Please ask the file owner to reshare the file with you.'
|
||||
);
|
||||
$this->logger->warning($msg);
|
||||
throw new DecryptionFailedException($msg, $hint);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -459,67 +628,63 @@ class Encryption implements IEncryptionModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial encryption of all files
|
||||
* Initiates encryption of all files using the encryption module.
|
||||
* This method delegates to the encryptAll service and streams status information to the provided output.
|
||||
*
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output write some status information to the terminal during encryption
|
||||
* @param InputInterface $input Input interface for user interaction (CLI or otherwise).
|
||||
* @param OutputInterface $output Output interface for status and progress updates.
|
||||
*/
|
||||
public function encryptAll(InputInterface $input, OutputInterface $output) {
|
||||
public function encryptAll(InputInterface $input, OutputInterface $output): void {
|
||||
$this->encryptAll->encryptAll($input, $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* prepare module to perform decrypt all operation
|
||||
* Prepares the encryption module for the 'decrypt all' operation.
|
||||
* Delegates to the DecryptAll service, which performs all necessary setup.
|
||||
*
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @param string $user
|
||||
* @return bool
|
||||
* @param InputInterface $input The input interface for user interaction.
|
||||
* @param OutputInterface $output The output interface for user feedback.
|
||||
* @param string $user (optional) User whose files should be prepared for decryption. If omitted, assumes all users.
|
||||
* @return bool True if preparation succeeded; false otherwise.
|
||||
*/
|
||||
public function prepareDecryptAll(InputInterface $input, OutputInterface $output, $user = '') {
|
||||
public function prepareDecryptAll(InputInterface $input, OutputInterface $output, string $user = ''): bool {
|
||||
return $this->decryptAll->prepare($input, $output, $user);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param string $path
|
||||
* @return string
|
||||
*/
|
||||
protected function getPathToRealFile($path) {
|
||||
$realPath = $path;
|
||||
$parts = explode('/', $path);
|
||||
if ($parts[2] === 'files_versions') {
|
||||
$realPath = '/' . $parts[1] . '/files/' . implode('/', array_slice($parts, 3));
|
||||
$length = strrpos($realPath, '.');
|
||||
$realPath = substr($realPath, 0, $length);
|
||||
}
|
||||
|
||||
return $realPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* remove .part file extension and the ocTransferId from the file to get the
|
||||
* original file name
|
||||
* Resolves the real file path from a versioned file path.
|
||||
* If the provided path points to a versioned file, this method reconstructs
|
||||
* the path to its corresponding original file and strips any version suffix.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
* @param string $path The (possibly versioned) file path.
|
||||
* @return string The canonical file path.
|
||||
*/
|
||||
protected function stripPartFileExtension($path) {
|
||||
if (pathinfo($path, PATHINFO_EXTENSION) === 'part') {
|
||||
$pos = strrpos($path, '.', -6);
|
||||
$path = substr($path, 0, $pos);
|
||||
}
|
||||
protected function getPathToRealFile(string $path): string {
|
||||
$parts = explode('/', $path);
|
||||
|
||||
// Robustness: ensure the path has enough segments for version detection
|
||||
if (count($parts) > 2 && $parts[2] === 'files_versions') {
|
||||
// Reconstruct the original file path
|
||||
$realPath = '/' . $parts[1] . '/files/' . implode('/', array_slice($parts, 3));
|
||||
|
||||
// Remove the file version suffix if present (e.g., .v123)
|
||||
$dotPos = strrpos($realPath, '.');
|
||||
if ($dotPos !== false) {
|
||||
return substr($realPath, 0, $dotPos);
|
||||
}
|
||||
return $realPath;
|
||||
}
|
||||
// If not a versioned path, return as is
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* get owner of a file
|
||||
* Retrieves the owner of a file, caching the result for future lookups.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
* @param string $path Path to the file.
|
||||
* @return string Owner of the file.
|
||||
*/
|
||||
protected function getOwner($path) {
|
||||
protected function getOwner(string $path): string {
|
||||
if (!isset($this->owner[$path])) {
|
||||
$this->owner[$path] = $this->util->getOwner($path);
|
||||
}
|
||||
@@ -527,16 +692,14 @@ class Encryption implements IEncryptionModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the module is ready to be used by that specific user.
|
||||
* In case a module is not ready - because e.g. key pairs have not been generated
|
||||
* upon login this method can return false before any operation starts and might
|
||||
* cause issues during operations.
|
||||
* Checks if the module is ready to be used by the specified user.
|
||||
* Returns false if key pairs have not been generated for the user.
|
||||
*
|
||||
* @param string $user
|
||||
* @return boolean
|
||||
* @param string $user User to check.
|
||||
* @return bool True if ready, false otherwise.
|
||||
* @since 9.1.0
|
||||
*/
|
||||
public function isReadyForUser($user) {
|
||||
public function isReadyForUser(string $user): bool {
|
||||
if ($this->util->isMasterKeyEnabled()) {
|
||||
return true;
|
||||
}
|
||||
@@ -544,11 +707,12 @@ class Encryption implements IEncryptionModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* We only need a detailed access list if the master key is not enabled
|
||||
* Indicates whether the encryption module needs a detailed list of users with access to the file.
|
||||
* For example, modules using per-user encryption keys may require this information.
|
||||
*
|
||||
* @return bool
|
||||
* @return bool True if a detailed access list is required, false otherwise.
|
||||
*/
|
||||
public function needDetailedAccessList() {
|
||||
public function needDetailedAccessList(): bool {
|
||||
return !$this->util->isMasterKeyEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,7 +618,7 @@ class KeyManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* add system keys such as the public share key and the recovery key
|
||||
* add system keys such as the public link share key and the recovery key
|
||||
*
|
||||
* @param array $accessList
|
||||
* @param array $publicKeys
|
||||
@@ -627,6 +627,7 @@ class KeyManager {
|
||||
* @throws PublicKeyMissingException
|
||||
*/
|
||||
public function addSystemKeys(array $accessList, array $publicKeys, $uid) {
|
||||
// Only add if already shared
|
||||
if (!empty($accessList['public'])) {
|
||||
$publicShareKey = $this->getPublicShareKey();
|
||||
if (empty($publicShareKey)) {
|
||||
@@ -635,6 +636,7 @@ class KeyManager {
|
||||
$publicKeys[$this->getPublicShareKeyId()] = $publicShareKey;
|
||||
}
|
||||
|
||||
// Only add if recovery key is applicable
|
||||
if ($this->recoveryKeyExists()
|
||||
&& $this->util->isRecoveryEnabledForUser($uid)) {
|
||||
$publicKeys[$this->getRecoveryKeyId()] = $this->getRecoveryKey();
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
describe('OCP.Comments tests', function() {
|
||||
function dataProvider() {
|
||||
return [
|
||||
{ input: 'nextcloud.com', expected: 'nextcloud.com' },
|
||||
{ input: 'http://nextcloud.com', expected: '<a class="external" target="_blank" rel="noopener noreferrer" href="http://nextcloud.com">http://nextcloud.com</a>' },
|
||||
{ input: 'https://nextcloud.com', expected: '<a class="external" target="_blank" rel="noopener noreferrer" href="https://nextcloud.com">nextcloud.com</a>' },
|
||||
{ input: 'hi nextcloud.com', expected: 'hi nextcloud.com' },
|
||||
{ input: 'hi http://nextcloud.com', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="http://nextcloud.com">http://nextcloud.com</a>' },
|
||||
{ input: 'hi https://nextcloud.com', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="https://nextcloud.com">nextcloud.com</a>' },
|
||||
{ input: 'nextcloud.com foobar', expected: 'nextcloud.com foobar' },
|
||||
{ input: 'http://nextcloud.com foobar', expected: '<a class="external" target="_blank" rel="noopener noreferrer" href="http://nextcloud.com">http://nextcloud.com</a> foobar' },
|
||||
{ input: 'https://nextcloud.com foobar', expected: '<a class="external" target="_blank" rel="noopener noreferrer" href="https://nextcloud.com">nextcloud.com</a> foobar' },
|
||||
{ input: 'hi nextcloud.com foobar', expected: 'hi nextcloud.com foobar' },
|
||||
{ input: 'hi http://nextcloud.com foobar', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="http://nextcloud.com">http://nextcloud.com</a> foobar' },
|
||||
{ input: 'hi https://nextcloud.com foobar', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="https://nextcloud.com">nextcloud.com</a> foobar' },
|
||||
{ input: 'hi help.nextcloud.com/category/topic foobar', expected: 'hi help.nextcloud.com/category/topic foobar' },
|
||||
{ input: 'hi http://help.nextcloud.com/category/topic foobar', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="http://help.nextcloud.com/category/topic">http://help.nextcloud.com/category/topic</a> foobar' },
|
||||
{ input: 'hi https://help.nextcloud.com/category/topic foobar', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="https://help.nextcloud.com/category/topic">help.nextcloud.com/category/topic</a> foobar' },
|
||||
{ input: 'noreply@nextcloud.com', expected: 'noreply@nextcloud.com' },
|
||||
{ input: 'hi noreply@nextcloud.com', expected: 'hi noreply@nextcloud.com' },
|
||||
{ input: 'hi <noreply@nextcloud.com>', expected: 'hi <noreply@nextcloud.com>' },
|
||||
{ input: 'FirebaseInstanceId.getInstance().deleteInstanceId()', expected: 'FirebaseInstanceId.getInstance().deleteInstanceId()' },
|
||||
{ input: 'I mean...it', expected: 'I mean...it' },
|
||||
]
|
||||
}
|
||||
|
||||
it('should parse URLs only', function() {
|
||||
dataProvider().forEach(function(data) {
|
||||
const result = OCP.Comments.plainToRich(data.input)
|
||||
expect(result).toEqual(data.expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import LoginForm from './LoginForm.vue'
|
||||
|
||||
describe('core: LoginForm', { testIsolation: true }, () => {
|
||||
beforeEach(() => {
|
||||
// Mock the required global state
|
||||
cy.window().then(($window) => {
|
||||
$window.OC = {
|
||||
theme: {
|
||||
name: 'J\'s cloud',
|
||||
},
|
||||
requestToken: 'request-token',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Ensure that characters like ' are not double HTML escaped.
|
||||
* This was a bug in https://github.com/nextcloud/server/issues/34990
|
||||
*/
|
||||
it('does not double escape special characters in product name', () => {
|
||||
cy.mount(LoginForm, {
|
||||
propsData: {
|
||||
username: 'test-user',
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('h2').contains('J\'s cloud')
|
||||
})
|
||||
|
||||
it('fills username from props into form', () => {
|
||||
cy.mount(LoginForm, {
|
||||
propsData: {
|
||||
username: 'test-user',
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('input[name="user"]')
|
||||
.should('exist')
|
||||
.and('have.attr', 'id', 'user')
|
||||
|
||||
cy.get('input[name="user"]')
|
||||
.should('have.value', 'test-user')
|
||||
})
|
||||
|
||||
it('clears password after timeout', () => {
|
||||
// mock timeout of 5 seconds
|
||||
cy.window().then(($window) => {
|
||||
const state = $window.document.createElement('input')
|
||||
state.type = 'hidden'
|
||||
state.id = 'initial-state-core-loginTimeout'
|
||||
state.value = btoa(JSON.stringify(5))
|
||||
$window.document.body.appendChild(state)
|
||||
})
|
||||
|
||||
// mount forms
|
||||
cy.mount(LoginForm)
|
||||
|
||||
cy.get('input[name="password"]')
|
||||
.should('exist')
|
||||
.type('MyPassword')
|
||||
|
||||
cy.get('input[name="password"]')
|
||||
.should('have.value', 'MyPassword')
|
||||
|
||||
// Wait for timeout
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
cy.wait(5100)
|
||||
|
||||
cy.get('input[name="password"]')
|
||||
.should('have.value', '')
|
||||
})
|
||||
})
|
||||
@@ -194,10 +194,10 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
data(props) {
|
||||
return {
|
||||
loading: false,
|
||||
user: '',
|
||||
user: props.username,
|
||||
password: '',
|
||||
}
|
||||
},
|
||||
@@ -262,7 +262,7 @@ export default {
|
||||
},
|
||||
|
||||
emailEnabled() {
|
||||
return this.emailStates ? this.emailStates.every((state) => state === '1') : 1
|
||||
return this.emailStates.every((state) => state === '1')
|
||||
},
|
||||
|
||||
loginText() {
|
||||
@@ -286,7 +286,6 @@ export default {
|
||||
if (this.username === '') {
|
||||
this.$refs.user.$refs.inputField.$refs.input.focus()
|
||||
} else {
|
||||
this.user = this.username
|
||||
this.$refs.password.$refs.inputField.$refs.input.focus()
|
||||
}
|
||||
},
|
||||
|
||||
33
core/src/tests/OCP/comments.spec.js
Normal file
33
core/src/tests/OCP/comments.spec.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect, it } from 'vitest'
|
||||
import * as Comments from '../../OCP/comments.js'
|
||||
|
||||
it.for([
|
||||
{ input: 'nextcloud.com', expected: 'nextcloud.com' },
|
||||
{ input: 'http://nextcloud.com', expected: '<a class="external" target="_blank" rel="noopener noreferrer" href="http://nextcloud.com">http://nextcloud.com</a>' },
|
||||
{ input: 'https://nextcloud.com', expected: '<a class="external" target="_blank" rel="noopener noreferrer" href="https://nextcloud.com">nextcloud.com</a>' },
|
||||
{ input: 'hi nextcloud.com', expected: 'hi nextcloud.com' },
|
||||
{ input: 'hi http://nextcloud.com', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="http://nextcloud.com">http://nextcloud.com</a>' },
|
||||
{ input: 'hi https://nextcloud.com', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="https://nextcloud.com">nextcloud.com</a>' },
|
||||
{ input: 'nextcloud.com foobar', expected: 'nextcloud.com foobar' },
|
||||
{ input: 'http://nextcloud.com foobar', expected: '<a class="external" target="_blank" rel="noopener noreferrer" href="http://nextcloud.com">http://nextcloud.com</a> foobar' },
|
||||
{ input: 'https://nextcloud.com foobar', expected: '<a class="external" target="_blank" rel="noopener noreferrer" href="https://nextcloud.com">nextcloud.com</a> foobar' },
|
||||
{ input: 'hi nextcloud.com foobar', expected: 'hi nextcloud.com foobar' },
|
||||
{ input: 'hi http://nextcloud.com foobar', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="http://nextcloud.com">http://nextcloud.com</a> foobar' },
|
||||
{ input: 'hi https://nextcloud.com foobar', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="https://nextcloud.com">nextcloud.com</a> foobar' },
|
||||
{ input: 'hi help.nextcloud.com/category/topic foobar', expected: 'hi help.nextcloud.com/category/topic foobar' },
|
||||
{ input: 'hi http://help.nextcloud.com/category/topic foobar', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="http://help.nextcloud.com/category/topic">http://help.nextcloud.com/category/topic</a> foobar' },
|
||||
{ input: 'hi https://help.nextcloud.com/category/topic foobar', expected: 'hi <a class="external" target="_blank" rel="noopener noreferrer" href="https://help.nextcloud.com/category/topic">help.nextcloud.com/category/topic</a> foobar' },
|
||||
{ input: 'noreply@nextcloud.com', expected: 'noreply@nextcloud.com' },
|
||||
{ input: 'hi noreply@nextcloud.com', expected: 'hi noreply@nextcloud.com' },
|
||||
{ input: 'hi <noreply@nextcloud.com>', expected: 'hi <noreply@nextcloud.com>' },
|
||||
{ input: 'FirebaseInstanceId.getInstance().deleteInstanceId()', expected: 'FirebaseInstanceId.getInstance().deleteInstanceId()' },
|
||||
{ input: 'I mean...it', expected: 'I mean...it' },
|
||||
])('OCP.Comments should parse URLs only', ({ input, expected }) => {
|
||||
const result = Comments.plainToRich(input)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
102
core/src/tests/components/Login/LoginForm.spec.ts
Normal file
102
core/src/tests/components/Login/LoginForm.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cleanup, render } from '@testing-library/vue'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LoginForm from '../../../components/login/LoginForm.vue'
|
||||
|
||||
describe('core: LoginForm', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock the required global state
|
||||
window.OC = {
|
||||
theme: {
|
||||
name: 'J\'s cloud',
|
||||
},
|
||||
requestToken: 'request-token',
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Ensure that characters like ' are not double HTML escaped.
|
||||
* This was a bug in https://github.com/nextcloud/server/issues/34990
|
||||
*/
|
||||
it('does not double escape special characters in product name', () => {
|
||||
const page = render(LoginForm, {
|
||||
props: {
|
||||
username: 'test-user',
|
||||
},
|
||||
})
|
||||
|
||||
const heading = page.getByRole('heading', { level: 2 })
|
||||
expect(heading.textContent).toContain('J\'s cloud')
|
||||
})
|
||||
|
||||
it('offers email as login name by default', async () => {
|
||||
const page = render(LoginForm)
|
||||
|
||||
const input = await page.findByRole('textbox', { name: /Account name or email/ })
|
||||
expect(input).toBeInstanceOf(HTMLInputElement)
|
||||
})
|
||||
|
||||
it('offers only account name if email is not enabled', async () => {
|
||||
const page = render(LoginForm, {
|
||||
propsData: {
|
||||
emailStates: ['0', '1'],
|
||||
},
|
||||
})
|
||||
|
||||
await expect(async () => page.findByRole('textbox', { name: /Account name or email/ })).rejects.toThrow()
|
||||
await expect(page.findByRole('textbox', { name: /Account name/ })).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('fills username from props into form', () => {
|
||||
const page = render(LoginForm, {
|
||||
props: {
|
||||
username: 'test-user',
|
||||
},
|
||||
})
|
||||
|
||||
page.debug()
|
||||
|
||||
const input: HTMLInputElement = page.getByRole('textbox', { name: /Account name or email/ })
|
||||
expect(input.id).toBe('user')
|
||||
expect(input.name).toBe('user')
|
||||
expect(input.value).toBe('test-user')
|
||||
})
|
||||
|
||||
describe('', () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
// mock timeout of 5 seconds
|
||||
const state = document.createElement('input')
|
||||
state.type = 'hidden'
|
||||
state.id = 'initial-state-core-loginTimeout'
|
||||
state.value = btoa(JSON.stringify(5))
|
||||
document.body.appendChild(state)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
document.querySelector('#initial-state-core-loginTimeout')?.remove()
|
||||
})
|
||||
|
||||
it('clears password after timeout', () => {
|
||||
// mount forms
|
||||
const page = render(LoginForm)
|
||||
const input: HTMLInputElement = page.getByLabelText('Password', { selector: 'input' })
|
||||
input.dispatchEvent(new InputEvent('input', { data: 'MyPassword' }))
|
||||
|
||||
vi.advanceTimersByTime(2500)
|
||||
// see its still the value
|
||||
expect(input.value).toBe('')
|
||||
|
||||
// Wait for timeout
|
||||
vi.advanceTimersByTime(2600)
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
4
dist/core-login.js
vendored
4
dist/core-login.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-login.js.map
vendored
2
dist/core-login.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -63,6 +63,8 @@ export default defineConfig([
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'jsdoc/require-param-type': 'off',
|
||||
'jsdoc/require-param-description': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
|
||||
@@ -11,165 +11,159 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Interface IEncryptionModule
|
||||
* Interface for encryption modules for use by Nextcloud's Server-side Encryption (SSE).
|
||||
*
|
||||
* This interface allows Nextcloud SSE to support multiple interchangeable encryption modules.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*/
|
||||
interface IEncryptionModule {
|
||||
/**
|
||||
* @return string defining the technical unique id
|
||||
* Returns the unique technical identifier for this encryption module.
|
||||
* This ID must be globally unique among all modules and is used for internal reference.
|
||||
*
|
||||
* @return string The technical module ID.
|
||||
* @since 8.1.0
|
||||
*/
|
||||
public function getId();
|
||||
public function getId(): string;
|
||||
|
||||
/**
|
||||
* In comparison to getKey() this function returns a human readable (maybe translated) name
|
||||
* Returns a human-readable (and possibly translated) name for the encryption module.
|
||||
* Intended for display in user interfaces and logs.
|
||||
*
|
||||
* @return string
|
||||
* @return string The display name of the module.
|
||||
* @since 8.1.0
|
||||
*/
|
||||
public function getDisplayName();
|
||||
public function getDisplayName(): string;
|
||||
|
||||
/**
|
||||
* start receiving chunks from a file. This is the place where you can
|
||||
* perform some initial step before starting encrypting/decrypting the
|
||||
* chunks
|
||||
* Initializes the encryption or decryption process for a file.
|
||||
* Called before chunked processing begins. Allows the module to prepare state,
|
||||
* analyze headers, and determine access control.
|
||||
*
|
||||
* @param string $path to the file
|
||||
* @param string $user who read/write the file (null for public access)
|
||||
* @param string $mode php stream open mode
|
||||
* @param array $header contains the header data read from the file
|
||||
* @param array $accessList who has access to the file contains the key 'users' and 'public'
|
||||
*
|
||||
* @return array $header contain data as key-value pairs which should be
|
||||
* written to the header, in case of a write operation
|
||||
* or if no additional data is needed return a empty array
|
||||
* @param string $path The path to the file.
|
||||
* @param string $user The user performing the operation (or null for public access).
|
||||
* @param string $mode The PHP stream open mode (e.g., 'r', 'w').
|
||||
* @param array $header Header data read from the file.
|
||||
* @param array $accessList Access control list; must include keys 'users' and 'public'.
|
||||
* @return array Key-value pairs for header data to write (if writing), or an empty array if not needed.
|
||||
* @since 8.1.0
|
||||
*/
|
||||
public function begin($path, $user, $mode, array $header, array $accessList);
|
||||
public function begin(string $path, string $user, string $mode, array $header, array $accessList): array;
|
||||
|
||||
/**
|
||||
* last chunk received. This is the place where you can perform some final
|
||||
* operation and return some remaining data if something is left in your
|
||||
* buffer.
|
||||
*
|
||||
* @param string $path to the file
|
||||
* @param string $position id of the last block (looks like "<Number>end")
|
||||
*
|
||||
* @return string remained data which should be written to the file in case
|
||||
* of a write operation
|
||||
* Finalizes the encryption or decryption process for a file.
|
||||
* Called after all chunks have been processed. Allows the module to flush buffers and perform cleanup.
|
||||
*
|
||||
* @param string $path The path to the file.
|
||||
* @param string $position The identifier of the last block (e.g., "<Number>end").
|
||||
* @return string Any remaining data to write at the end of a write operation (empty string if none).
|
||||
* @since 8.1.0
|
||||
* @since 9.0.0 parameter $position added
|
||||
* @since 9.0.0 Parameter $position added.
|
||||
*/
|
||||
public function end($path, $position);
|
||||
public function end(string $path, string $position): string;
|
||||
|
||||
/**
|
||||
* encrypt data
|
||||
*
|
||||
* @param string $data you want to encrypt
|
||||
* @param string $position position of the block we want to encrypt (starts with '0')
|
||||
*
|
||||
* @return mixed encrypted data
|
||||
* Encrypts a chunk of file data.
|
||||
*
|
||||
* @param string $data The plaintext data to encrypt.
|
||||
* @param string $position The position or identifier of the chunk/block (typically starts at '0').
|
||||
* @return string The encrypted data for this chunk.
|
||||
* @since 8.1.0
|
||||
* @since 9.0.0 parameter $position added
|
||||
* @since 9.0.0 Parameter $position added.
|
||||
*/
|
||||
public function encrypt($data, $position);
|
||||
public function encrypt(string $data, string $position): string;
|
||||
|
||||
/**
|
||||
* decrypt data
|
||||
*
|
||||
* @param string $data you want to decrypt
|
||||
* @param int|string $position position of the block we want to decrypt
|
||||
*
|
||||
* @return mixed decrypted data
|
||||
* Decrypts a chunk of file data.
|
||||
*
|
||||
* @param string $data The encrypted data to decrypt.
|
||||
* @param int|string $position The position or identifier of the chunk/block.
|
||||
* @return string The decrypted (plaintext) data for this chunk.
|
||||
* @since 8.1.0
|
||||
* @since 9.0.0 parameter $position added
|
||||
* @since 9.0.0 Parameter $position added.
|
||||
*/
|
||||
public function decrypt($data, $position);
|
||||
public function decrypt(string $data, int|string $position): string;
|
||||
|
||||
/**
|
||||
* update encrypted file, e.g. give additional users access to the file
|
||||
* Updates the encryption metadata for a file.
|
||||
* For example, grants additional users access to the file or updates access lists.
|
||||
*
|
||||
* @param string $path path to the file which should be updated
|
||||
* @param string $uid of the user who performs the operation
|
||||
* @param array $accessList who has access to the file contains the key 'users' and 'public'
|
||||
* @return boolean
|
||||
* @param string $path Path to the file to update.
|
||||
* @param string $uid The user performing the update.
|
||||
* @param array $accessList Updated access control list; must include keys 'users' and 'public'.
|
||||
* @return bool True on success, false otherwise.
|
||||
* @since 8.1.0
|
||||
*/
|
||||
public function update($path, $uid, array $accessList);
|
||||
public function update(string $path, string $uid, array $accessList): bool;
|
||||
|
||||
/**
|
||||
* should the file be encrypted or not
|
||||
* Checks whether the file at the given path should be encrypted by this module.
|
||||
*
|
||||
* @param string $path
|
||||
* @return boolean
|
||||
* @param string $path Path to the file.
|
||||
* @return bool True if the file should be encrypted, false otherwise.
|
||||
* @since 8.1.0
|
||||
*/
|
||||
public function shouldEncrypt($path);
|
||||
public function shouldEncrypt(string $path): bool;
|
||||
|
||||
/**
|
||||
* get size of the unencrypted payload per block.
|
||||
* ownCloud read/write files with a block size of 8192 byte
|
||||
* Returns the size (in bytes) of each unencrypted block payload.
|
||||
* Nextcloud reads/writes files using blocks of 8192 bytes.
|
||||
*
|
||||
* @param bool $signed
|
||||
* @return int
|
||||
* @since 8.1.0 optional parameter $signed was added in 9.0.0
|
||||
* @param bool $signed True if the block is signed; affects the available payload size.
|
||||
* @return int The size of the unencrypted block payload in bytes.
|
||||
* @since 8.1.0 (optional parameter $signed added in 9.0.0)
|
||||
*/
|
||||
public function getUnencryptedBlockSize($signed = false);
|
||||
public function getUnencryptedBlockSize(bool $signed = false): int;
|
||||
|
||||
/**
|
||||
* check if the encryption module is able to read the file,
|
||||
* e.g. if all encryption keys exists
|
||||
* Checks if this module can decrypt and read the given file for the specified user.
|
||||
* For example, verifies that all necessary encryption keys exist for this user.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $uid user for whom we want to check if they can read the file
|
||||
* @return boolean
|
||||
* @param string $path Path to the file.
|
||||
* @param string $uid User for whom to check readability.
|
||||
* @return bool True if the file can be read, false otherwise.
|
||||
* @since 8.1.0
|
||||
*/
|
||||
public function isReadable($path, $uid);
|
||||
public function isReadable(string $path, string $uid): bool;
|
||||
|
||||
/**
|
||||
* Initial encryption of all files
|
||||
* Performs initial encryption of all files (bulk operation).
|
||||
* Used for server-side bulk encryption, typically from command-line tools.
|
||||
*
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output write some status information to the terminal during encryption
|
||||
* @param InputInterface $input Input interface.
|
||||
* @param OutputInterface $output Output interface for status/progress information.
|
||||
* @since 8.2.0
|
||||
*/
|
||||
public function encryptAll(InputInterface $input, OutputInterface $output);
|
||||
public function encryptAll(InputInterface $input, OutputInterface $output): void;
|
||||
|
||||
/**
|
||||
* prepare encryption module to decrypt all files
|
||||
* Prepares the encryption module for a bulk decrypt-all operation.
|
||||
*
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output write some status information to the terminal during encryption
|
||||
* @param $user (optional) for which the files should be decrypted, default = all users
|
||||
* @return bool return false on failure or if it isn't supported by the module
|
||||
* @param InputInterface $input Input interface.
|
||||
* @param OutputInterface $output Output interface for status/progress information.
|
||||
* @param string $user (Optional) User for whom files should be decrypted. If omitted, decrypts for all users.
|
||||
* @return bool True on success, false if the operation is not supported or failed.
|
||||
* @since 8.2.0
|
||||
*/
|
||||
public function prepareDecryptAll(InputInterface $input, OutputInterface $output, $user = '');
|
||||
public function prepareDecryptAll(InputInterface $input, OutputInterface $output, string $user = ''): bool;
|
||||
|
||||
/**
|
||||
* Check if the module is ready to be used by that specific user.
|
||||
* In case a module is not ready - because e.g. key pairs have not been generated
|
||||
* upon login this method can return false before any operation starts and might
|
||||
* cause issues during operations.
|
||||
* Checks if the module is ready to be used by the specified user.
|
||||
* For example, returns false if key pairs have not yet been generated for the user.
|
||||
*
|
||||
* @param string $user
|
||||
* @return boolean
|
||||
* @param string $user User to check.
|
||||
* @return bool True if ready, false otherwise.
|
||||
* @since 9.1.0
|
||||
*/
|
||||
public function isReadyForUser($user);
|
||||
public function isReadyForUser(string $user): bool;
|
||||
|
||||
/**
|
||||
* Does the encryption module needs a detailed list of users with access to the file?
|
||||
* For example if the encryption module uses per-user encryption keys and needs to know
|
||||
* the users with access to the file to encrypt/decrypt it.
|
||||
* Indicates whether this module requires a detailed list of users with access to each file.
|
||||
* For example, modules using per-user encryption keys may require this information.
|
||||
*
|
||||
* @return bool True if a detailed access list is required, false otherwise.
|
||||
* @since 13.0.0
|
||||
* @return bool
|
||||
*/
|
||||
public function needDetailedAccessList();
|
||||
public function needDetailedAccessList(): bool;
|
||||
}
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -104,7 +104,7 @@
|
||||
"@testing-library/cypress": "^10.1.0",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/vue": "^5.8.3",
|
||||
"@testing-library/vue": "^5.9.0",
|
||||
"@types/dockerode": "^3.3.44",
|
||||
"@types/wait-on": "^5.3.4",
|
||||
"@vitejs/plugin-vue2": "^2.3.3",
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
"@testing-library/cypress": "^10.1.0",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/vue": "^5.8.3",
|
||||
"@testing-library/vue": "^5.9.0",
|
||||
"@types/dockerode": "^3.3.44",
|
||||
"@types/wait-on": "^5.3.4",
|
||||
"@vitejs/plugin-vue2": "^2.3.3",
|
||||
|
||||
Reference in New Issue
Block a user