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);
+ }
}