From 911fa8867451415351adc1ff86b0d24d935482b7 Mon Sep 17 00:00:00 2001 From: binsky Date: Thu, 15 Oct 2026 19:07:38 +0200 Subject: [PATCH] use Utf8Base64Service for encoding/decoding in various services and directives; fixes crashes when using utf8 characters in labels Signed-off-by: binsky --- js/app/directives/icon.js | 4 +- js/app/directives/iconpicker.js | 4 +- js/app/services/credentialservice.js | 4 +- js/app/services/utf8base64service.js | 58 ++++++++++++++++++++++++ js/app/services/vaultservice.js | 6 +-- templates/bookmarklet.php | 1 + templates/main.php | 1 + templates/public_share.php | 1 + tests/Unit/Db/CredentialRevisionTest.php | 12 +++++ 9 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 js/app/services/utf8base64service.js diff --git a/js/app/directives/icon.js b/js/app/directives/icon.js index 7b63bbee5..c329d1117 100644 --- a/js/app/directives/icon.js +++ b/js/app/directives/icon.js @@ -30,7 +30,7 @@ * # passwordGen */ angular.module('passmanApp').directive('credentialIcon', [ - '$window', 'UrlService', function($window, UrlService) { + '$window', 'UrlService', 'Utf8Base64Service', function($window, UrlService, Utf8Base64Service) { return { template: '', restrict: 'E', @@ -46,7 +46,7 @@ scope.iconUrl = 'data:image/'+ scope.credential.icon.type +';base64,' + scope.credential.icon.content; } else { if(scope.credential.url) { - var url = window.btoa(angular.copy(scope.credential.url)).replace('/', '_'); + var url = Utf8Base64Service.encode(scope.credential.url).replace('/', '_'); scope.iconUrl = UrlService.generateUrl('/api/v2/icon/') + url + '/' + scope.credential.credential_id; } diff --git a/js/app/directives/iconpicker.js b/js/app/directives/iconpicker.js index 7ee2ddf98..9daac2cf7 100644 --- a/js/app/directives/iconpicker.js +++ b/js/app/directives/iconpicker.js @@ -30,7 +30,7 @@ * # passwordGen */ angular.module('passmanApp').directive('iconPicker', [ - '$window', 'IconService', '$http', 'NotificationService', 'UrlService', '$translate', function($window, IconService, $http, NotificationService, UrlService, $translate) { + '$window', 'IconService', '$http', 'NotificationService', 'UrlService', '$translate', 'Utf8Base64Service', function($window, IconService, $http, NotificationService, UrlService, $translate, Utf8Base64Service) { return { templateUrl: 'views/partials/icon-picker.html', restrict: 'A', @@ -117,7 +117,7 @@ scope.refreshUrlIcon = function(){ NotificationService.showNotification($translate.instant('use.icon.refresh.trying'), 5000); - var queryUrl = UrlService.generateUrl('/api/v2/geticon/'+btoa(scope.credential.url)); + var queryUrl = UrlService.generateUrl('/api/v2/geticon/' + Utf8Base64Service.encode(scope.credential.url)); $http.get(queryUrl).then(function (response) { if(typeof response.data.content !== 'undefined'){ scope.customIcon = {}; diff --git a/js/app/services/credentialservice.js b/js/app/services/credentialservice.js index 54abd90e3..7ca610d7d 100644 --- a/js/app/services/credentialservice.js +++ b/js/app/services/credentialservice.js @@ -30,7 +30,7 @@ * Service in the passmanApp. */ angular.module('passmanApp') - .service('CredentialService', ['$http', 'EncryptService', 'VaultService', 'FileService', 'UrlService', function ($http, EncryptService, VaultService, FileService, UrlService) { + .service('CredentialService', ['$http', 'EncryptService', 'VaultService', 'FileService', 'UrlService', 'Utf8Base64Service', function ($http, EncryptService, VaultService, FileService, UrlService, Utf8Base64Service) { var credential = { 'credential_id': null, 'guid': null, @@ -182,7 +182,7 @@ }, updateRevision: function (revision) { var _revision = angular.copy(revision); - _revision.credential_data = window.btoa(JSON.stringify(_revision.credential_data)); + _revision.credential_data = Utf8Base64Service.encode(JSON.stringify(_revision.credential_data)); var queryUrl = UrlService.generateUrl('/api/v2/credentials/' + revision.credential_data.guid + '/revision/' + revision.revision_id); return $http.patch(queryUrl, _revision).then(function (response) { if (response.data) { diff --git a/js/app/services/utf8base64service.js b/js/app/services/utf8base64service.js new file mode 100644 index 000000000..f58bf4824 --- /dev/null +++ b/js/app/services/utf8base64service.js @@ -0,0 +1,58 @@ +/** + * Nextcloud - passman + * + * @copyright Copyright (c) 2026, Timo Triebensky (timo@binsky.org) + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +/* global TextEncoder, TextDecoder, Uint8Array */ +(function () { + 'use strict'; + + /** + * @ngdoc service + * @name passmanApp.Utf8Base64Service + * @description + * UTF-8 base64 for text that may contain characters outside Latin-1. + * window.btoa throws on those characters. decode runs window.atob and then reads the bytes as UTF-8, + * which is the same recovery as PHP base64_decode + json_decode. ASCII text matches the old btoa/atob pair. + * Since the old frontend stack does not provide js-base64, let's simply use the existing TextEncoder and TextDecoder APIs. + */ + angular.module('passmanApp') + .service('Utf8Base64Service', [function () { + return { + encode: function (value) { + var bytes = new TextEncoder().encode(value); + var binary = ''; + var i; + for (i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + }, + decode: function (value) { + var binary = window.atob(value); + var bytes = new Uint8Array(binary.length); + var i; + for (i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder().decode(bytes); + } + }; + }]); +}()); diff --git a/js/app/services/vaultservice.js b/js/app/services/vaultservice.js index d65d4e6e4..138cd082c 100644 --- a/js/app/services/vaultservice.js +++ b/js/app/services/vaultservice.js @@ -32,7 +32,7 @@ * Service in the passmanApp. */ angular.module('passmanApp') - .service('VaultService', ['UrlService', '$http', function (UrlService, $http) { + .service('VaultService', ['UrlService', '$http', 'Utf8Base64Service', function (UrlService, $http, Utf8Base64Service) { // AngularJS will instantiate a singleton by calling "new" on this function var _this = this; var _activeVault; @@ -85,7 +85,7 @@ return $http.get(queryUrl).then(function (response) { if (response.data) { if (response.data.vault_settings) { - response.data.vault_settings = JSON.parse(window.atob(response.data.vault_settings)); + response.data.vault_settings = JSON.parse(Utf8Base64Service.decode(response.data.vault_settings)); } else { response.data.vault_settings = {}; } @@ -100,7 +100,7 @@ delete _vault.defaultVaultPass; delete _vault.defaultVault; delete _vault.vaultKey; - _vault.vault_settings = window.btoa(JSON.stringify(_vault.vault_settings)); + _vault.vault_settings = Utf8Base64Service.encode(JSON.stringify(_vault.vault_settings)); var queryUrl = UrlService.generateUrl('/api/v2/vaults/' + _vault.guid); return $http.patch(queryUrl, _vault).then(function (response) { if (response.data) { diff --git a/templates/bookmarklet.php b/templates/bookmarklet.php index cbd81805c..4a7b3f673 100644 --- a/templates/bookmarklet.php +++ b/templates/bookmarklet.php @@ -51,6 +51,7 @@ Util::addScript(MyAppTemplateConfig::APP_ID, 'app/filters/escapeHTML', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/filters/as', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/filters/toHHMMSS', 'core'); +Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/utf8base64service', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/vaultservice', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/credentialservice', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/settingsservice', 'core'); diff --git a/templates/main.php b/templates/main.php index 38583f1ec..8011bc66a 100644 --- a/templates/main.php +++ b/templates/main.php @@ -64,6 +64,7 @@ Util::addScript(MyAppTemplateConfig::APP_ID, 'app/filters/as', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/filters/credentialsearch', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/filters/toHHMMSS', 'core'); +Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/utf8base64service', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/cacheservice', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/vaultservice', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/credentialservice', 'core'); diff --git a/templates/public_share.php b/templates/public_share.php index 447bd00e5..09ac90442 100644 --- a/templates/public_share.php +++ b/templates/public_share.php @@ -45,6 +45,7 @@ Util::addScript(MyAppTemplateConfig::APP_ID, 'app/filters/propsfilter', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/filters/byte', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/filters/escapeHTML', 'core'); +Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/utf8base64service', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/vaultservice', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/credentialservice', 'core'); Util::addScript(MyAppTemplateConfig::APP_ID, 'app/services/settingsservice', 'core'); diff --git a/tests/Unit/Db/CredentialRevisionTest.php b/tests/Unit/Db/CredentialRevisionTest.php index 83f55479b..fe34d3d8d 100644 --- a/tests/Unit/Db/CredentialRevisionTest.php +++ b/tests/Unit/Db/CredentialRevisionTest.php @@ -75,4 +75,16 @@ public function testJsonSerialize(): void { $this->assertEquals($expected, $this->revision->jsonSerialize()); } + + public function testJsonSerializeDecodesUtf8EuroInCredentialLabel(): void { + $this->revision->setCredentialData( + 'eyJndWlkIjoiODMxRDM0RkMtRTdDMC00NTQxLUJENjgtNTA3NzQ3ODI0MTkyIiwibGFiZWwiOiJCYW5rIOKCrCJ9' + ); + + $serialized = $this->revision->jsonSerialize(); + + $this->assertIsObject($serialized['credential_data']); + $this->assertSame('Bank €', $serialized['credential_data']->label); + $this->assertSame('831D34FC-E7C0-4541-BD68-507747824192', $serialized['credential_data']->guid); + } }