Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb4c8127e | |||
| 27f7bcd040 | |||
| 76dc856dbf | |||
| 227fdf10dd | |||
| 19c744b17d | |||
| 3ce74d04d0 | |||
| 87b8fc6a1b | |||
| 9012badfb8 | |||
| 3b6e5d8ed1 | |||
| 1148724613 | |||
| f526695aae | |||
| e8850eeac2 | |||
| 777834d790 | |||
| dca9246450 | |||
| 767f7ab40e | |||
| 1b810ec74f | |||
| f59b9e1b5f | |||
| 398dbe802e | |||
| 8b5fa0fe76 | |||
| 99042a47f3 | |||
| 46e600abe9 | |||
| 051dd8b58f | |||
| 067b02dba1 | |||
| 22a0874188 | |||
| 0e25809158 | |||
| 305d877896 | |||
| a932a5251a | |||
| 7b58fccb9f | |||
| 859fef62d4 | |||
| 0647a3a233 | |||
| aedf55dba0 | |||
| e9a422b657 | |||
| 23df6bdfbf | |||
| 1b5fee233e | |||
| 63457d2de4 | |||
| 732c944e98 | |||
| 86c4db8f22 | |||
| 8c0c9981de | |||
| e5dcf78ceb | |||
| 92bce26e22 | |||
| a72c038435 | |||
| 6742cdf373 | |||
| ea72cef7f9 | |||
| 565ad83399 | |||
| 43f795c9e4 | |||
| 1589cfb639 | |||
| a9b9931aa8 | |||
| 1cd577cc65 | |||
| 13d8db3daa | |||
| 40c4a01bc0 | |||
| 4301c70ba7 | |||
| d5e9e556ab | |||
| bdf9e04963 | |||
| b95285365d | |||
| abf445e969 | |||
| e988e3a303 | |||
| dca548b8a0 | |||
| 56ecfdb4eb | |||
| 7640851aa9 | |||
| d9301160e1 | |||
| 3656d7f631 | |||
| 9f89b07777 | |||
| 199dbff7b1 | |||
| 88b8cb48fc | |||
| e8b3232966 | |||
| 5de7537c71 | |||
| 4706313239 | |||
| d32819da4e | |||
| b6becae396 | |||
| d310c5746e |
@@ -0,0 +1,24 @@
|
||||
run_tests:
|
||||
stage: test
|
||||
image: cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4
|
||||
services:
|
||||
- name: mysql:8.0
|
||||
alias: mysql
|
||||
variables:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
MYSQL_DATABASE: box
|
||||
BOX_ENV: ci
|
||||
DATABASE_URL: mysql://root:password@mysql/box
|
||||
script:
|
||||
- echo "Running tests..."
|
||||
- mysql -hmysql -uroot -ppassword -e "ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'password';"
|
||||
- mysql -hmysql -uroot -ppassword -e "CREATE DATABASE IF NOT EXISTS box"
|
||||
- npm install
|
||||
- node_modules/.bin/db-migrate up
|
||||
- ln -s /usr/local/node-18.18.0/bin/node /usr/bin/node
|
||||
- node_modules/.bin/mocha --no-timeouts --bail src/test/tokens-test.js
|
||||
- echo "Done!"
|
||||
|
||||
stages:
|
||||
- test
|
||||
|
||||
@@ -2830,3 +2830,24 @@
|
||||
* sftp: restore mode and owner
|
||||
* dashboard: also render checklist items in apps.html
|
||||
|
||||
[8.0.5]
|
||||
* cpu quota: fix rounding error
|
||||
* frontend: fix translation resolver to actually fallback to english
|
||||
* i18n: fix crash if language file is missing
|
||||
* memory: fix slider UI where max was incorrectly set
|
||||
* digitalocean: add LON1 Spaces region
|
||||
* exoscale: add sos AT-VIE-2 region
|
||||
* i18n: remove use of "Cloudron"
|
||||
* tz: add note in backup and update UI
|
||||
* backups: do not overflow the schedule timings
|
||||
* checklist: new checklist items on update are acknowledged
|
||||
* backups: automatically trigger a remount if mount is not active
|
||||
* logs: rework the syslog parser
|
||||
* docker: use system dns for app containers
|
||||
* logs: show error message in UI when log rotated
|
||||
* unbound: prefer ip4 for dns queries (only on ubuntu 24 and above)
|
||||
* apps: allow operators to update apps
|
||||
|
||||
[8.0.6]
|
||||
* Fix AdGuard resolving dashboard to docker bridge IP
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ const REGIONS_WASABI = [
|
||||
const REGIONS_DIGITALOCEAN = [
|
||||
{ name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' },
|
||||
{ name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' },
|
||||
{ name: 'LON1', value: 'https://lon1.digitaloceanspaces.com' },
|
||||
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
|
||||
{ name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' },
|
||||
{ name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' },
|
||||
@@ -181,6 +182,7 @@ const REGIONS_DIGITALOCEAN = [
|
||||
// https://www.exoscale.com/datacenters/
|
||||
const REGIONS_EXOSCALE = [
|
||||
{ name: 'Vienna (AT-VIE-1)', value: 'https://sos-at-vie-1.exo.io' },
|
||||
{ name: 'Vienna (AT-VIE-2)', value: 'https://sos-at-vie-2.exo.io' },
|
||||
{ name: 'Sofia (BG-SOF-1)', value: 'https://sos-bg-sof-1.exo.io' },
|
||||
{ name: 'Zurich (CH-DK-2)', value: 'https://sos-ch-dk-2.exo.io' },
|
||||
{ name: 'Geneva (CH-GVA-2)', value: 'https://sos-ch-gva-2.exo.io' },
|
||||
@@ -1398,8 +1400,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
};
|
||||
|
||||
Client.prototype.getUpdateInfo = function (callback) {
|
||||
if (!this._userInfo.isAtLeastAdmin) return callback(new Error('Not allowed'));
|
||||
|
||||
get('/api/v1/updater/updates', null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 200) return callback(new ClientError(status, data));
|
||||
@@ -2642,9 +2642,10 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
this.config(function (error, result) {
|
||||
if (error) return callback(error);
|
||||
|
||||
that.getUpdateInfo(function (error, info) { // note: non-admin users may get access denied for this
|
||||
if (!error) result.update = info.update; // attach update information to config object
|
||||
that.getUpdateInfo(function (error, info) {
|
||||
if (error) return callback(error);
|
||||
|
||||
result.update = info.update;
|
||||
that.setConfig(result);
|
||||
callback(null);
|
||||
});
|
||||
|
||||
@@ -135,6 +135,10 @@ select.form-control {
|
||||
background-position-y: 5px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type="checkbox"], input[type="radio"] {
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -730,6 +734,7 @@ multiselect {
|
||||
.form-control {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1745,6 +1750,7 @@ div:hover > .picture-edit-indicator {
|
||||
.form-control {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1759,6 +1765,7 @@ div:hover > .picture-edit-indicator {
|
||||
|
||||
.notification-item {
|
||||
cursor: pointer;
|
||||
padding: 10px 15px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 27px rgba(0,0,0,.1);
|
||||
|
||||
@@ -22,13 +22,17 @@
|
||||
"auth": {
|
||||
"sso": "Log ind med Cloudron-oplysninger",
|
||||
"nosso": "Log ind med en dedikeret konto",
|
||||
"email": "Log ind med din e-mailadresse"
|
||||
"email": "Log ind med din e-mailadresse",
|
||||
"openid": "Log ind med Cloudron OpenID"
|
||||
},
|
||||
"addAppAction": "Tilføj app",
|
||||
"addAppproxyAction": "Tilføj app-proxy",
|
||||
"addApplinkAction": "Tilføj app-link",
|
||||
"filter": {
|
||||
"clearAll": "Ryd alt"
|
||||
},
|
||||
"apps": {
|
||||
"count": "Antal apps: {{ count }}"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
@@ -80,7 +84,8 @@
|
||||
"justNow": "lige nu",
|
||||
"yeserday": "I går",
|
||||
"minutesAgo": "{{ m }} minutter siden",
|
||||
"hoursAgo": "{{ h }} timer siden"
|
||||
"hoursAgo": "{{ h }} timer siden",
|
||||
"never": "Aldrig"
|
||||
},
|
||||
"navbar": {
|
||||
"users": "Brugere"
|
||||
@@ -165,7 +170,10 @@
|
||||
"loginAction": "Login",
|
||||
"createAccountAction": "Opret konto",
|
||||
"switchToSignUpAction": "Har du ikke en konto endnu? Tilmeld dig",
|
||||
"switchToLoginAction": "Har du allerede en konto? Log ind"
|
||||
"switchToLoginAction": "Har du allerede en konto? Log ind",
|
||||
"setupWithTokenAction": "Opsætning",
|
||||
"setupToken": "Opsætningstoken",
|
||||
"titleToken": "Tilmeld dig med installationstoken"
|
||||
},
|
||||
"title": "App Store",
|
||||
"searchPlaceholder": "Søg efter alternativer som Github, Dropbox, Slack, Trello, …",
|
||||
@@ -180,7 +188,7 @@
|
||||
"users": {
|
||||
"externalLdap": {
|
||||
"title": "Tilslut en ekstern mappe",
|
||||
"description": "Cloudron synkroniserer brugere og grupper fra en ekstern LDAP- eller ActiveDirectory-server. Adgangskodebekræftelse til autentificering af disse brugere foretages mod den eksterne server. Synkroniseringen køres ikke automatisk, men skal udløses manuelt.",
|
||||
"description": "Denne indstilling synkroniserer og godkender brugere og grupper fra en ekstern LDAP- eller Active Directory-server. Synkroniseringen køres med jævne mellemrum, men kan også udløses manuelt.",
|
||||
"bindUsername": "Bind DN/Benyttelsesnavn (valgfrit)",
|
||||
"subscriptionRequiredAction": "Oprettelse af abonnement nu",
|
||||
"noopInfo": "LDAP-godkendelse er ikke konfigureret.",
|
||||
@@ -195,14 +203,15 @@
|
||||
"groupFilter": "Gruppefilter",
|
||||
"groupnameField": "Groupname Felt",
|
||||
"auth": "Auth",
|
||||
"autocreateUsersOnLogin": "Opret automatisk brugere, når de logger ind på Cloudron",
|
||||
"autocreateUsersOnLogin": "Opret automatisk brugere ved login",
|
||||
"showLogsAction": "Vis logs",
|
||||
"syncAction": "Synkroniser",
|
||||
"configureAction": "Konfigurer",
|
||||
"bindPassword": "Bind adgangskode (valgfrit)",
|
||||
"errorSelfSignedCert": "Serveren bruger et ugyldigt eller selvsigneret certifikat.",
|
||||
"providerOther": "Andre",
|
||||
"providerDisabled": "Deaktiveret"
|
||||
"providerDisabled": "Deaktiveret",
|
||||
"disableWarning": "Godkendelseskilden for alle eksisterende brugere bliver nulstillet til at godkende mod den lokale adgangskodedatabase."
|
||||
},
|
||||
"addUserDialog": {
|
||||
"sendInviteCheckbox": "Send en e-mail med en invitation nu",
|
||||
@@ -227,7 +236,9 @@
|
||||
"primaryEmail": "Primær e-mail",
|
||||
"errorDisplayNameRequired": "Navn er påkrævet",
|
||||
"activeCheckbox": "Brugeren er aktiv",
|
||||
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen"
|
||||
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen",
|
||||
"external2FA": "2FA-opsætning styres af ekstern godkendelseskilde",
|
||||
"ldapGroups": "LDAP-grupper"
|
||||
},
|
||||
"invitationDialog": {
|
||||
"descriptionLink": "Kopier link til invitation",
|
||||
@@ -255,10 +266,11 @@
|
||||
"description": "Cloudron kan fungere som en central brugerkatalogserver for eksterne programmer.",
|
||||
"enabled": "Aktiveret",
|
||||
"ipRestriction": {
|
||||
"description": "Mappeserveren kan begrænses til bestemte IP'er eller områder.",
|
||||
"description": "Begræns adgang til Directory Server til specifikke IP'er eller områder. Linjer, der starter med <code>#</code>, behandles som kommentarer.",
|
||||
"placeholder": "Linjeadskilt IP-adresse eller undernet",
|
||||
"label": "Begræns adgang"
|
||||
}
|
||||
},
|
||||
"cloudflarePortWarning": "Cloudflare-proxying skal deaktiveres på dashboard-domænet for at få adgang til LDAP-serveren"
|
||||
},
|
||||
"userImportDialog": {
|
||||
"description": "Upload en JSON- eller CSV-fil med det skema, der er beskrevet i vores <a href=\"{{ docsLink }}\" target=\"_blank\">dokumentation</a>",
|
||||
@@ -482,7 +494,10 @@
|
||||
"changeEmail": {
|
||||
"title": "Ændre primær e-mailadresse",
|
||||
"errorEmailInvalid": "E-mail-adressen er ikke gyldig",
|
||||
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet"
|
||||
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet",
|
||||
"email": "Ny e-mailadresse",
|
||||
"password": "Adgangskode til bekræftelse",
|
||||
"errorWrongPassword": "Forkert adgangskode"
|
||||
},
|
||||
"changeDisplayName": {
|
||||
"title": "Ændre dit visningsnavn",
|
||||
@@ -609,7 +624,7 @@
|
||||
},
|
||||
"check": {
|
||||
"noop": "Cloudron-backups er deaktiveret. Sørg for, at der tages backup af denne server ved hjælp af alternative midler. Se https://docs.cloudron.io/backups/#storage-providers for flere oplysninger.",
|
||||
"sameDisk": "Cloudron-backups er i øjeblikket på den samme disk som Cloudron-serverinstansen. Dette er farligt og kan føre til fuldstændigt tab af data, hvis disken fejler. Se https://docs.cloudron.io/backups/#storage-providers for lagring af sikkerhedskopier på en ekstern placering."
|
||||
"sameDisk": "Sikkerhedskopierne ligger i øjeblikket på den samme disk som Cloudron selv. Hvis disken fyldes op med disse sikkerhedskopier, vil Cloudron ikke fungere. En diskfejl kan også føre til fuldstændigt datatab. Se https://docs.cloudron.io/backups/#storage-providers for at gemme sikkerhedskopier på et eksternt sted."
|
||||
},
|
||||
"title": "Sikkerhedskopiering",
|
||||
"logs": {
|
||||
@@ -642,7 +657,9 @@
|
||||
"logo": "Logo",
|
||||
"changeLogo": {
|
||||
"title": "Vælg Cloudron Avatar"
|
||||
}
|
||||
},
|
||||
"backgroundImage": "Baggrundsbillede af login-side",
|
||||
"clearBackgroundImage": "Klar"
|
||||
},
|
||||
"emails": {
|
||||
"domains": {
|
||||
@@ -771,7 +788,7 @@
|
||||
"ip": {
|
||||
"interfaceDescription": "Liste over tilgængelige enheder på serveren med:",
|
||||
"title": "IP-adresse",
|
||||
"description": "Cloudron bruger denne IP-adresse, når der oprettes DNS-poster.",
|
||||
"description": "Cloudron bruger denne IPv4-adresse til at oprette DNS A-poster.",
|
||||
"provider": "Udbyder",
|
||||
"interface": "Navn på netværksgrænseflade",
|
||||
"configure": "Konfigurer",
|
||||
@@ -795,7 +812,7 @@
|
||||
},
|
||||
"title": "Netværk",
|
||||
"configureIp": {
|
||||
"title": "Konfigurer IP-provider",
|
||||
"title": "Konfigurer IPv4-provider",
|
||||
"providerGenericDescription": "Serverens offentlige IP-adresse registreres automatisk."
|
||||
},
|
||||
"ipv4": {
|
||||
@@ -838,14 +855,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"timezone": {
|
||||
"description": "Den aktuelle tidszoneindstilling er <b>{{{{ timeZone }}}</b>.\nDenne indstilling bruges til planlægning af backup- og opdateringsopgaver.",
|
||||
"description": "Den aktuelle tidszoneindstilling er <b>{{ timeZone }}</b>. Denne indstilling bruges til at planlægge backup- og opdateringsopgaver. Tidsstempler i brugergrænsefladen vises altid i browserens tidszone.",
|
||||
"title": "Tidszone"
|
||||
},
|
||||
"updates": {
|
||||
"updateAvailableAction": "Opdatering tilgængelig",
|
||||
"title": "Opdateringer",
|
||||
"autoUpdateDisabled": "Automatisk opdatering af platformen og apps er<b>deaktiveret</b>.",
|
||||
"currentSchedule": "Den nuværende tidsplan for automatisk opdatering af platform og apps er",
|
||||
"version": "Platform version",
|
||||
"showLogsAction": "Vis logs",
|
||||
"changeScheduleAction": "Ændre tidsplan",
|
||||
@@ -940,7 +955,11 @@
|
||||
"disableAction": "Deaktivere SSH-støtteadgang",
|
||||
"enableAction": "Aktiver SSH-støtteadgang"
|
||||
},
|
||||
"title": "Støtte"
|
||||
"title": "Støtte",
|
||||
"help": {
|
||||
"title": "Hjælp",
|
||||
"description": "Brug venligst følgende ressourcer til hjælp og support:\n* [Cloudron Forum]({{ forumLink }}) - Brug venligst de support- og app-specifikke kategorier til spørgsmål.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"diskUsage": {
|
||||
@@ -964,7 +983,19 @@
|
||||
"title": "System Memory",
|
||||
"graphSubtext": "Kun apps, der bruger mere end {{ threshold }} af memory, vises"
|
||||
},
|
||||
"selectPeriodLabel": "Vælg periode"
|
||||
"selectPeriodLabel": "Vælg periode",
|
||||
"info": {
|
||||
"platformVersion": "Platformsversion",
|
||||
"title": "Info",
|
||||
"vendor": "Leverandør",
|
||||
"product": "Produtk",
|
||||
"memory": "Memory",
|
||||
"uptime": "Driftstid",
|
||||
"activationTime": "Cloudrons skabelsestidspunkt"
|
||||
},
|
||||
"graphs": {
|
||||
"title": "Diagrammer"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"renewCerts": {
|
||||
@@ -1025,7 +1056,13 @@
|
||||
"porkbunSecretapikey": "Hemmelig API-nøgle",
|
||||
"cloudflareDefaultProxyStatus": "Aktiver proxying for nye DNS-poster",
|
||||
"porkbunApikey": "API-nøgle",
|
||||
"bunnyAccessKey": "Bunny Access Key"
|
||||
"bunnyAccessKey": "Bunny Access Key",
|
||||
"deSecToken": "deSEC Token",
|
||||
"dnsimpleAccessToken": "Adgangstoken",
|
||||
"ovhEndpoint": "Endepunkt",
|
||||
"ovhConsumerKey": "Consumer Key",
|
||||
"ovhAppKey": "Application Key",
|
||||
"ovhAppSecret": "Application Secret"
|
||||
},
|
||||
"title": "Domæner og certs",
|
||||
"addDomain": "Tilføj domæne",
|
||||
@@ -1089,7 +1126,8 @@
|
||||
"copy": "Kopier",
|
||||
"clear": "Klar",
|
||||
"pasteInfo": "Brug Ctrl+v til at indsætte for at indsætte"
|
||||
}
|
||||
},
|
||||
"uploadTo": "Upload til {{ path }}"
|
||||
},
|
||||
"filemanager": {
|
||||
"newFileDialog": {
|
||||
@@ -1121,7 +1159,8 @@
|
||||
"renameDialog": {
|
||||
"title": "Omdøb {{ fileName }}",
|
||||
"newName": "Nyt navn",
|
||||
"rename": "Omdøb"
|
||||
"rename": "Omdøb",
|
||||
"reallyOverwrite": "Der findes allerede en fil med det navn. Overskrive eksisterende fil?"
|
||||
},
|
||||
"extractDialog": {
|
||||
"title": "Udpakning af {{ fileName }}",
|
||||
@@ -1278,7 +1317,7 @@
|
||||
},
|
||||
"enableEmailDialog": {
|
||||
"description": "Dette vil konfigurere Cloudron til at modtage e-mails for<b>{{ domain }}</b>Se dokumentationen for åbning af de <a href=\"{{{ requiredPortsDocsLink }}\" target=\"_blank\">forpligtede porte</a> for Cloudron Email.",
|
||||
"cloudflareInfo": "Domænet <code>{{{ adminDomain }}</code> administreres af Cloudflare. Kontroller venligst, at Cloudflare-proxying er deaktiveret for <code>{{{ mailFqdn }}</code> og indstillet til <code>Kun DNS</code>. Dette er påkrævet, fordi Cloudflare ikke giver proxy for e-mail.",
|
||||
"cloudflareInfo": "Mailserverens domæne <code>{{ adminDomain }}</code> administreres af Cloudflare. Kontrollér, at Cloudflare-proxy er deaktiveret for <code>{{ mailFqdn }}</code> og indstillet til <code>kun DNS</code>. Dette er nødvendigt, fordi Cloudflare ikke proxy'er e-mail.",
|
||||
"title": "Aktiver e-mail for {{ domain }}?",
|
||||
"noProviderInfo": "Der er ikke oprettet nogen DNS-udbyder. De DNS-poster, der er anført i fanen Status, skal oprettes manuelt.",
|
||||
"setupDnsCheckbox": "Opsæt Mail DNS-poster nu",
|
||||
@@ -1431,7 +1470,7 @@
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory graense",
|
||||
"description": "Cloudron tildeler 50 % af denne værdi som RAM og 50 % som swap.",
|
||||
"description": "Maksimal arbejdshastighed, som appen kan bruge",
|
||||
"error": "Kan ikke indstille memory limit, prøv mindre.",
|
||||
"resizeAction": "Ændre størrelse"
|
||||
}
|
||||
@@ -1492,11 +1531,12 @@
|
||||
"packageVersion": "Pakkeversion",
|
||||
"lastUpdated": "Sidst opdateret",
|
||||
"checkForUpdatesAction": "Tjek for opdateringer",
|
||||
"customAppUpdateInfo": "Opdateringer er ikke tilgængelige for brugerdefinerede apps",
|
||||
"updateAvailableAction": "Opdatering tilgængelig"
|
||||
"customAppUpdateInfo": "Automatisk opdatering er ikke tilgængelig for brugerdefinerede apps.",
|
||||
"updateAvailableAction": "Opdatering tilgængelig",
|
||||
"installedAt": "Installeret på"
|
||||
},
|
||||
"auto": {
|
||||
"description": "Cloudron kontrollerer jævnligt App Store for opdateringer. Hvis du deaktiverer automatiske opdateringer, skal du sørge for at anvende opdateringerne manuelt.",
|
||||
"description": "Cloudron tjekker med jævne mellemrum <a href=»{{ appStoreLink }}« target=»_blank«>App Store</a> for opdateringer.",
|
||||
"title": "Automatiske opdateringer",
|
||||
"enabled": "Automatiske opdateringer er i øjeblikket aktiveret.",
|
||||
"disabled": "Automatiske opdateringer er i øjeblikket deaktiveret.",
|
||||
@@ -1569,7 +1609,8 @@
|
||||
"openAction": "Åbn {{ app }}",
|
||||
"firstTimeTitle": "Første gang du bruger det",
|
||||
"firstTimeCollapseHeader": "Første gangs opsætningsvejledning",
|
||||
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app."
|
||||
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app.",
|
||||
"checklist": "Administrativ tjekliste"
|
||||
},
|
||||
"restoreDialog": {
|
||||
"warning": "Alle data, der er genereret mellem nu og den sidst kendte sikkerhedskopi, vil uigenkaldeligt gå tabt. Det anbefales at oprette en sikkerhedskopi af de aktuelle data, før du forsøger at gendanne dem.",
|
||||
@@ -1718,6 +1759,12 @@
|
||||
"title": "Redis-konfiguration",
|
||||
"enable": "Konfigurer appen til at bruge Redis",
|
||||
"disable": "Deaktiver Redis"
|
||||
},
|
||||
"infoTabTitle": "Info",
|
||||
"info": {
|
||||
"notes": {
|
||||
"title": "Administrative noter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passwordReset": {
|
||||
@@ -1821,7 +1868,11 @@
|
||||
"mountStatus": "Status for montering",
|
||||
"type": "Type",
|
||||
"localDirectory": "Lokal vejviser",
|
||||
"remountActionTooltip": "Genmonter"
|
||||
"remountActionTooltip": "Genmonter",
|
||||
"editVolumeDialog": {
|
||||
"title": "Rediger volumen {{ name }}"
|
||||
},
|
||||
"editActionTooltip": "Rediger volumen"
|
||||
},
|
||||
"newLoginEmail": {
|
||||
"topic": "Vi har bemærket et nyt login på din Cloudron-konto.",
|
||||
@@ -1859,7 +1910,8 @@
|
||||
"signInAction": "Log ind",
|
||||
"resetPasswordAction": "Nulstil adgangskode",
|
||||
"errorIncorrect2FAToken": "2FA-token er ugyldig",
|
||||
"errorInternal": "Intern fejl, prøv igen senere"
|
||||
"errorInternal": "Intern fejl, prøv igen senere",
|
||||
"loginWith": "Log ind med Cloudron"
|
||||
},
|
||||
"lang": {
|
||||
"en": "English",
|
||||
@@ -1874,7 +1926,8 @@
|
||||
"es": "Spansk",
|
||||
"ru": "Russisk",
|
||||
"pt": "Portugisisk",
|
||||
"da": "Dansk"
|
||||
"da": "Dansk",
|
||||
"id": "Indonesisk"
|
||||
},
|
||||
"supportConfig": {
|
||||
"emailNotVerified": "Du bedes først bekræfte e-mailen på cloudron.io-kontoen for at sikre, at vi kan kontakte dig."
|
||||
|
||||
@@ -167,13 +167,11 @@
|
||||
"updates": {
|
||||
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
|
||||
"title": "Aktualisierungen",
|
||||
"currentSchedule": "Die Einstellungen für die automatische Aktualisierung für System und Anwendungen lautet:",
|
||||
"version": "Systemversion",
|
||||
"changeScheduleAction": "Zeitplan ändern",
|
||||
"stopUpdateAction": "Aktualisierung abbrechen",
|
||||
"updateAvailableAction": "Aktualisierung verfügbar",
|
||||
"showLogsAction": "Logfiles anzeigen",
|
||||
"autoUpdateDisabled": "Die automatische Aktualisierung des Systems und der Anwendungen ist <b>deaktiviert</b>."
|
||||
"showLogsAction": "Logfiles anzeigen"
|
||||
},
|
||||
"appstoreAccount": {
|
||||
"title": "Cloudron.io-Konto",
|
||||
|
||||
@@ -521,7 +521,7 @@
|
||||
"title": "Backups",
|
||||
"location": {
|
||||
"title": "Location",
|
||||
"description": "Cloudron makes a complete backup of your system at the configured location.",
|
||||
"description": "A complete backup of your system is saved to the storage location with the configured format.",
|
||||
"disabledList": "The following apps have automatic backups disabled:",
|
||||
"provider": "Provider",
|
||||
"location": "Location",
|
||||
@@ -532,7 +532,7 @@
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule and Retention",
|
||||
"description": "Cloudron makes a complete backup of your system based on this scheduled interval and keeps backups with the specified retention policy.",
|
||||
"description": "A complete backup of the system is created based on the specified Schedule in the <a href=\"/#/settings\">System Time Zone</a>. Old backups are removed based on the Retention Policy.",
|
||||
"schedule": "Schedule",
|
||||
"retentionPolicy": "Retention Policy",
|
||||
"configure": "Configure"
|
||||
@@ -788,7 +788,7 @@
|
||||
"title": "Network",
|
||||
"ip": {
|
||||
"title": "IPv4",
|
||||
"description": "Cloudron uses this IPv4 address to setup DNS A records.",
|
||||
"description": "This IPv4 address is used to set up DNS A records.",
|
||||
"provider": "Provider",
|
||||
"interface": "Network Interface Name",
|
||||
"configure": "Configure",
|
||||
@@ -821,7 +821,7 @@
|
||||
"ipv6": {
|
||||
"address": "IPv6 Address",
|
||||
"title": "IPv6",
|
||||
"description": "Cloudron uses this IPv6 address to setup DNS AAAA records.\n"
|
||||
"description": "This IPv6 address is used to set up DNS AAAA records."
|
||||
},
|
||||
"configureIpv6": {
|
||||
"title": "Configure IPv6 Provider"
|
||||
@@ -835,7 +835,7 @@
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
"description": "Cloudron services implement functionality such as databases, email and authentication.",
|
||||
"description": "Services implement functionality such as databases, email and authentication.",
|
||||
"service": "Service",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"memoryLimit": "Memory Limit",
|
||||
@@ -869,19 +869,20 @@
|
||||
"emailNotVerified": "Email not yet verified"
|
||||
},
|
||||
"timezone": {
|
||||
"title": "Time Zone",
|
||||
"title": "System Time Zone",
|
||||
"description": "The current timezone setting is <b>{{ timeZone }}</b>. This setting is used for scheduling backup and update tasks. Timestamps in the UI are always displayed using the browser's timezone."
|
||||
},
|
||||
"updates": {
|
||||
"title": "Updates",
|
||||
"autoUpdateDisabled": "Automatic update for the platform and apps is <b>disabled</b>.",
|
||||
"currentSchedule": "The current automatic update schedule for platform and apps is",
|
||||
"version": "Platform version",
|
||||
"showLogsAction": "Show Logs",
|
||||
"changeScheduleAction": "Change Schedule",
|
||||
"checkForUpdatesAction": "Check for Updates",
|
||||
"updateAvailableAction": "Update Available",
|
||||
"stopUpdateAction": "Stop Update"
|
||||
"stopUpdateAction": "Stop Update",
|
||||
"disabled": "Disabled",
|
||||
"schedule": "Schedule",
|
||||
"description": "Platform and App Updates are automatically applied based on the Schedule in the <a href=\"/#/settings\">System Time Zone</a>."
|
||||
},
|
||||
"privateDockerRegistry": {
|
||||
"title": "Private Docker Registry",
|
||||
@@ -1016,7 +1017,7 @@
|
||||
"tooltipRemove": "Remove Domain",
|
||||
"renewCerts": {
|
||||
"title": "Renew certificates",
|
||||
"description": "Cloudron renews Let's Encrypt certificates automatically. Use this option to trigger a renewal immediately.",
|
||||
"description": "Let's Encrypt certificates are renewed automatically. Use this option to trigger a renewal immediately.",
|
||||
"renewAllAction": "Renew All Certs",
|
||||
"showLogsAction": "Show Logs"
|
||||
},
|
||||
@@ -1301,7 +1302,7 @@
|
||||
"outbound": {
|
||||
"tabTitle": "Outbound",
|
||||
"title": "Email Relay",
|
||||
"description": "Cloudron will use this mail server (Smart host) to send the outbound mails of apps installed under this domain.",
|
||||
"description": "This mail server (Smart host) will be used to send the outbound mails of apps installed under this domain.",
|
||||
"noopAdminDomainWarning": "Cloudron cannot send user invites, password reset and other notifications when email is disabled on the primary domain",
|
||||
"noopNonAdminDomainWarning": "Cloudron cannot provide email sending for apps hosted under this domain when email is disabled.",
|
||||
"mailRelay": {
|
||||
@@ -1627,7 +1628,7 @@
|
||||
},
|
||||
"auto": {
|
||||
"title": "Automatic Backups",
|
||||
"description": "Cloudron periodically creates a backup based on the <a href=\"{{ backupLink }}\">backup</a> settings.",
|
||||
"description": "Backups are periodically created based on the <a href=\"{{ backupLink }}\">Backup Schedule</a>.",
|
||||
"enabled": "Automatic Backups is currently enabled.",
|
||||
"disabled": "Automatic Backups is currently disabled.",
|
||||
"disableAction": "Disable Automatic Backups",
|
||||
@@ -1672,7 +1673,8 @@
|
||||
"openAction": "Open {{ app }}",
|
||||
"firstTimeTitle": "First Time Usage",
|
||||
"firstTimeCollapseHeader": "First time setup instructions",
|
||||
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app."
|
||||
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app.",
|
||||
"checklist": "Admin Checklist"
|
||||
},
|
||||
"uninstallDialog": {
|
||||
"title": "Uninstall {{ app }}",
|
||||
|
||||
@@ -871,8 +871,6 @@
|
||||
"changeScheduleAction": "Cambiar Programación",
|
||||
"showLogsAction": "Mostrar Registros",
|
||||
"version": "Versión de la Plataforma",
|
||||
"currentSchedule": "El programa actual de actualización automática para la plataforma y las aplicaciones es",
|
||||
"autoUpdateDisabled": "La actualización automática de la plataforma y las aplicaciones está <b> desactivada </b>.",
|
||||
"title": "Actualizaciones"
|
||||
},
|
||||
"language": {
|
||||
|
||||
@@ -791,8 +791,6 @@
|
||||
"changeScheduleAction": "Modifier la fréquence",
|
||||
"showLogsAction": "Afficher les journaux",
|
||||
"version": "Version de la plateforme",
|
||||
"currentSchedule": "La mise à jour automatique de la plateforme et des application a lieu",
|
||||
"autoUpdateDisabled": "La mise à jour automatique de la plateforme et des applications est <b>désactivée</b>.",
|
||||
"title": "Mises à jour"
|
||||
},
|
||||
"timezone": {
|
||||
|
||||
@@ -1151,8 +1151,6 @@
|
||||
"changeScheduleAction": "Cambia Pianificazione",
|
||||
"showLogsAction": "Visualizza Logs",
|
||||
"version": "Versione piattaforma",
|
||||
"currentSchedule": "L'attuale programma di aggiornamento automatico per piattaforma e app è",
|
||||
"autoUpdateDisabled": "L'aggiornamento automatico per la piattaforma e le app è <b>disabilitato</b>.",
|
||||
"title": "Aggiornamenti"
|
||||
},
|
||||
"timezone": {
|
||||
|
||||
@@ -1117,7 +1117,8 @@
|
||||
"customAppUpdateWarning": "Dit is een aangepaste app en niet geïnstalleerd vanuit de App Store, het krijgt hierdoor geen updates. Lees de <a target=\"_blank\" href=\"{{ docsLink }}\">documentatie</a> over hoe je een aangepaste app kunt updaten.",
|
||||
"appDocsUrl": "Bekijk de <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} documentatie</a> voor informatie en tips over deze app. Indien je meer hulp nodig hebt ga dan naar Cloudron's <a target=\"_blank\" href=\"{{ forumUrl }}\">{{ title }} forum</a>.",
|
||||
"sso": "Deze app is ingesteld voor authenticatie via het Cloudron gebuikersadresboek. Cloudron gebruikers kunnen inloggen en het direct gebruiken.",
|
||||
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox."
|
||||
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox.",
|
||||
"checklist": "Admin Controlelijst"
|
||||
},
|
||||
"uninstallDialog": {
|
||||
"uninstallAction": "De-installeer",
|
||||
@@ -1315,12 +1316,10 @@
|
||||
},
|
||||
"timezone": {
|
||||
"title": "Tijdzone",
|
||||
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>.\nDeze instelling wordt gebruikt voor backup planning en update taken."
|
||||
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>. Deze instelling wordt gebruikt voor backup planning en update taken. Tijdseenheden in de gebruikersschermen zijn op basis van de brower’s tijdzone."
|
||||
},
|
||||
"updates": {
|
||||
"title": "Updates",
|
||||
"autoUpdateDisabled": "Automatische update voor het platform en apps is <b>uitgeschakeld</b>.",
|
||||
"currentSchedule": "De huidige automatische update planning voor het platform en de apps is",
|
||||
"showLogsAction": "Toon logbestanden",
|
||||
"changeScheduleAction": "Planning aanpassen",
|
||||
"checkForUpdatesAction": "Controleer op updates",
|
||||
|
||||
@@ -1228,9 +1228,7 @@
|
||||
"checkForUpdatesAction": "Проверить обновления",
|
||||
"updateAvailableAction": "Обновление доступно",
|
||||
"version": "Версия платформы",
|
||||
"stopUpdateAction": "Остановить обновление",
|
||||
"autoUpdateDisabled": "Автоматические обновления для платформы и приложений <b>выключены</b>.",
|
||||
"currentSchedule": "Текущее расписание автоматических обновлений для платформы и приложений"
|
||||
"stopUpdateAction": "Остановить обновление"
|
||||
},
|
||||
"privateDockerRegistry": {
|
||||
"title": "Частный реестр Docker",
|
||||
|
||||
@@ -22,14 +22,18 @@
|
||||
"auth": {
|
||||
"email": "Đăng nhập bằng email",
|
||||
"sso": "Đăng nhập với tên & mật khẩu trên Cloudron",
|
||||
"nosso": "Đăng nhập vào tài khoản riêng"
|
||||
"nosso": "Đăng nhập bằng tài khoản riêng",
|
||||
"openid": "Đăng nhập bằng Cloudron OpenID"
|
||||
},
|
||||
"addAppAction": "Thêm App",
|
||||
"addApplinkAction": "Thêm đường link App",
|
||||
"addApplinkAction": "Thêm link App",
|
||||
"filter": {
|
||||
"clearAll": "Xoá tất cả"
|
||||
},
|
||||
"addAppproxyAction": "Thêm proxy cho app"
|
||||
"addAppproxyAction": "Thêm proxy cho app",
|
||||
"apps": {
|
||||
"count": "Tổng số app: {{ count }}"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
"logout": "Thoát",
|
||||
@@ -80,7 +84,8 @@
|
||||
"justNow": "mới đây",
|
||||
"yeserday": "Hôm qua",
|
||||
"minutesAgo": "{{ m }} phút trước",
|
||||
"hoursAgo": "{{ h }} tiếng trước"
|
||||
"hoursAgo": "{{ h }} tiếng trước",
|
||||
"never": "Chưa lần nào"
|
||||
},
|
||||
"statusEnabled": "Đã bật",
|
||||
"statusDisabled": "Đã tắt",
|
||||
@@ -107,7 +112,7 @@
|
||||
"finance": "Tài chính",
|
||||
"git": "Chạy code",
|
||||
"email": "Email",
|
||||
"game": "Game",
|
||||
"game": "Trò chơi",
|
||||
"hosting": "Chạy web",
|
||||
"media": "Hình ảnh",
|
||||
"learning": "Học tập",
|
||||
@@ -130,7 +135,7 @@
|
||||
"manualWarning": "Thêm A record cho <b>{{ nơi cài đặt }}</b> vào địa chỉ IP công cộng của Cloudron này",
|
||||
"userManagement": "Quản lý người dùng",
|
||||
"userManagementMailbox": "Tất cả người dùng với hộp thư trên Cloudron này có quyền truy cập app.",
|
||||
"userManagementLeaveToApp": "Để phần quản lý người dùng cho app",
|
||||
"userManagementLeaveToApp": "Để app quản lý người dùng",
|
||||
"userManagementAllUsers": "Cho phép tất cả người dùng trên Cloudron truy cập",
|
||||
"errorUserManagementSelectAtLeastOne": "Chọn ít nhất một người dùng hay nhóm",
|
||||
"users": "Người dùng",
|
||||
@@ -138,10 +143,10 @@
|
||||
"lowOnResources": "Cloudron này đang chạy gần hết bộ nhớ.",
|
||||
"pleaseUpgradeServer": "Hãy nâng cấp server có bộ nhớ nhiều hơn. Hoặc, xoá những app không dùng đến để có thêm chỗ trống.",
|
||||
"setupSubscriptionAction": "Cài đặt gói đăng ký",
|
||||
"installAnywayAction": "Vẫn tải về luôn",
|
||||
"installAnywayAction": "Vẫn tải về",
|
||||
"installAction": "Tải về",
|
||||
"subscriptionRequired": "Để cài đặt thêm app, hãy đăng ký gói trả phí.",
|
||||
"userManagementNone": "App này có phần quản lý người dùng riêng. Phần cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
|
||||
"userManagementNone": "App này có phần quản lý người dùng riêng. Cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
|
||||
"userManagementSelectUsers": "Chỉ cho phép người dùng và nhóm sau",
|
||||
"configuredForCloudronEmail": "App này đã được cấu hình sẵn để sử dụng với <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron Email</a>.",
|
||||
"doInstallAction": "Tải về {{ dnsOverwrite ? 'and overwrite DNS' : '' }}",
|
||||
@@ -167,7 +172,10 @@
|
||||
"switchToLoginAction": "Đã có tài khoản rồi? Đăng nhập",
|
||||
"switchToSignUpAction": "Chưa có tài khoản? Hãy đăng ký nhé",
|
||||
"description": "Tài khoản này được dùng để truy cập Cửa hàng App và quản lý gói đăng ký của bạn",
|
||||
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron"
|
||||
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron",
|
||||
"setupWithTokenAction": "Cài đặt",
|
||||
"titleToken": "Đăng ký với Mã cài đặt",
|
||||
"setupToken": "Cài đặt Mã"
|
||||
},
|
||||
"searchPlaceholder": "Tìm kiếm app thay thế cho Github, Dropbox, Slack, Trello, …",
|
||||
"appMissing": "Thiếu app nào đó? Hãy nhắn cho chúng tôi.",
|
||||
@@ -205,7 +213,9 @@
|
||||
"username": "Tên đăng nhập",
|
||||
"fullName": "Họ tên",
|
||||
"fallbackEmailPlaceholder": "Không bắt buộc. Nếu không được xác định, email chính sẽ được sử dụng",
|
||||
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký"
|
||||
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký",
|
||||
"external2FA": "Nguồn xác thực ngoài đang quản lý cài đặt Mã xác minh 2 Bước",
|
||||
"ldapGroups": "Nhóm LDAP"
|
||||
},
|
||||
"addUserDialog": {
|
||||
"addUserAction": "Thêm người dùng",
|
||||
@@ -223,7 +233,7 @@
|
||||
"configureAction": "Cấu hình",
|
||||
"syncAction": "Đồng bộ",
|
||||
"showLogsAction": "Hiển thị log",
|
||||
"autocreateUsersOnLogin": "Tự động tạo tài khoản người dùng khi họ đăng nhập vào Cloudron",
|
||||
"autocreateUsersOnLogin": "Tự động tạo người dùng khi họ đăng ký",
|
||||
"auth": "Xác minh",
|
||||
"groupnameField": "Vùng tên nhóm",
|
||||
"groupFilter": "Lọc nhóm",
|
||||
@@ -237,10 +247,11 @@
|
||||
"provider": "Nhà cung cấp",
|
||||
"noopInfo": "Xác thực LDAP chưa được thiết lập.",
|
||||
"subscriptionRequiredAction": "Cài đặt gói đăng ký ngay",
|
||||
"description": "Cloudron sẽ đồng bộ người dùng và nhóm từ server LDAP hay ActiveDirectory bên ngoài. Xác minh mật khẩu cho người dùng được dựa trên server ngoài. Việc đồng bộ hoá không được chạy tự động mà cần được khởi động bằng tay.",
|
||||
"description": "Cài đặt này đồng bộ và xác thực người dùng và nhóm từ một server LDAP hay ActiveDirectory bên ngoài. Sự đồng bộ hóa này được chạy theo chu kỳ nhưng cũng có thể được khởi động bằng tay.",
|
||||
"title": "Kết nối thư mục ngoài",
|
||||
"providerOther": "Khác",
|
||||
"providerDisabled": "Đã tắt"
|
||||
"providerDisabled": "Đã tắt",
|
||||
"disableWarning": "Nguồn mã xác minh cho tất cả người dùng hiện hữu sẽ được cài đặt lại dựa trên cơ sở dữ liệu mật khẩu nội bộ trên server."
|
||||
},
|
||||
"users": {
|
||||
"inactiveTooltip": "Người dùng không hoạt động",
|
||||
@@ -250,12 +261,12 @@
|
||||
"notActivatedYetTooltip": "Người dùng chưa được kích hoạt",
|
||||
"externalLdapTooltip": "Từ thư mục LDAP ngoài",
|
||||
"usermanagerTooltip": "Người dùng này có thể quản lý nhóm và những người dùng khác",
|
||||
"adminTooltip": "Người dùng này có vai trò admin",
|
||||
"superadminTooltip": "Người dùng này có vai trò superadmin",
|
||||
"adminTooltip": "Người dùng này là admin",
|
||||
"superadminTooltip": "Người dùng này là superadmin",
|
||||
"empty": "Không tìm thấy người dùng",
|
||||
"groups": "Nhóm",
|
||||
"user": "Người dùng",
|
||||
"transferOwnershipTooltip": "Chuyển đổi quyền sở hữu",
|
||||
"transferOwnershipTooltip": "Chuyển nhượng quyền sở hữu",
|
||||
"invitationTooltip": "Mời Người dùng",
|
||||
"setGhostTooltip": "Nhập vai",
|
||||
"count": "Tổng ng dùng: {{ count }}",
|
||||
@@ -334,7 +345,7 @@
|
||||
"enabled": "Đã bật",
|
||||
"title": "Máy chủ chỉ mục",
|
||||
"ipRestriction": {
|
||||
"description": "Máy chủ chỉ mục có thể được giới hạn cho những địa chỉ IP hoặc khoảng vùng cụ thể.",
|
||||
"description": "Giới hạn quyền truy cập máy chủ chỉ mục cho những địa chỉ IP hoặc khoảng vùng cụ thể. Những dòng bắt đầu bằng dấu <code>#</code> được xem như ghi chú thêm.",
|
||||
"placeholder": "Viết xuống dòng những địa chỉ IP hoặc Subnet",
|
||||
"label": "Giới hạn quyền truy cập"
|
||||
},
|
||||
@@ -342,7 +353,8 @@
|
||||
"label": "Mật khẩu bind",
|
||||
"description": "Tất cả những yêu cầu LDAP cần phải được xác minh với mã bí mật này và tên người dùng user DN <i>{{ userDN }}</i>",
|
||||
"url": "URL máy chủ"
|
||||
}
|
||||
},
|
||||
"cloudflarePortWarning": "Cần tắt proxy Cloudflare cho tên miền dashboard để truy cập LDAP server"
|
||||
},
|
||||
"userImportDialog": {
|
||||
"success": "{{ count }} người dùng đã được nhập vào.",
|
||||
@@ -478,7 +490,10 @@
|
||||
"changeEmail": {
|
||||
"title": "Thay đổi email chính",
|
||||
"errorEmailInvalid": "Email không hợp lệ",
|
||||
"errorEmailRequired": "Bạn cần nhập một email hợp lệ"
|
||||
"errorEmailRequired": "Bạn cần nhập một email hợp lệ",
|
||||
"email": "Thêm địa chỉ mail mới",
|
||||
"password": "Mật khẩu để xác nhận",
|
||||
"errorWrongPassword": "Sai mật khẩu"
|
||||
},
|
||||
"disable2FAAction": "Tắt xác minh hai bước",
|
||||
"changeFallbackEmail": {
|
||||
@@ -499,7 +514,8 @@
|
||||
"passwordResetNotification": {
|
||||
"title": "Đã đặt lại mật khẩu thành công",
|
||||
"body": "Email đã được gửi đến {{ email }}"
|
||||
}
|
||||
},
|
||||
"enable2FANotAvailable": "Không cài được cho người dùng từ nguồn xác minh ngoài"
|
||||
},
|
||||
"backups": {
|
||||
"location": {
|
||||
@@ -617,7 +633,7 @@
|
||||
},
|
||||
"check": {
|
||||
"noop": "Tính năng sao lưu Cloudron đã tắt. Hãy chắc rằng server được sao lưu bằng một biện pháp khác. Xem thông tin thêm tại https://docs.cloudron.io/backups/#storage-providers.",
|
||||
"sameDisk": "Các bản sao lưu Cloudron đang ở trên cùng ổ đĩa với server chạy Cloudron. Việc này sẽ nguy hiểm và có thể dẫn đến mất dữ liệu nếu ổ đĩa bị trục trặc. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
|
||||
"sameDisk": "Các bản sao lưu Cloudron hiện đang ở trên cùng ổ đĩa với server chạy Cloudron. Nếu ổ đĩa chứa đầy các bản sao lưu, Cloudron sẽ không hoạt động được. Sự cố trục trặc ổ đĩa cũng có thể làm mất dữ liệu hoàn toàn. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
|
||||
},
|
||||
"backupEdit": {
|
||||
"preserved": {
|
||||
@@ -625,7 +641,8 @@
|
||||
"description": "Vẫn giữ bản sao lưu mặc kệ chính sách lưu giữ được định thế nào"
|
||||
},
|
||||
"title": "Chỉnh sửa Bản sao lưu",
|
||||
"label": "Nhãn"
|
||||
"label": "Nhãn",
|
||||
"remotePath": "Đường dẫn"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -637,7 +654,8 @@
|
||||
"errorIncorrectCredentials": "Không đúng tên đăng nhập hoặc mật khẩu",
|
||||
"loginTo": "Đăng nhập vào",
|
||||
"errorIncorrect2FAToken": "Mã bảo mật 2 Bước không đúng",
|
||||
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau"
|
||||
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau",
|
||||
"loginWith": "Đăng nhập bằng Cloudron"
|
||||
},
|
||||
"setupAccount": {
|
||||
"username": "Tên đăng nhập",
|
||||
@@ -670,7 +688,7 @@
|
||||
"enableAction": "Bật",
|
||||
"setupDnsInfo": "Sử dụng lựa chọn này để cài đặt những bản ghi có liên quan đến email. Để trống lựa chọn này sẽ hữu ích cho việc tạo ra các hộp thư và <a href=\"{{ importEmailDocsLink }}\">nhập dữ liệu các mail đã có sẵn</a> trước khi đưa vào sử dụng.",
|
||||
"setupDnsCheckbox": "Cài đặt các bản ghi DNS ngay",
|
||||
"cloudflareInfo": "Tên miền <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Xin chắc rằng proxy qua Cloudflare đã được tắt cho <code>{{ mailFqdn }}</code> và được chỉnh về chế độ<code>DNS only</code>. Việc này là cần thiết vì Cloudflare không proxy được email.",
|
||||
"cloudflareInfo": "Tên miền cho mail server <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Hãy nhớ tắt proxy qua Cloudflare cho <code>{{ mailFqdn }}</code> và chỉnh về chế độ <code>DNS only</code>. Cần làm vậy vì Cloudflare không proxy được email.",
|
||||
"noProviderInfo": "Chưa cài đặt nhà cung cấp DNS. Những bản ghi DNS trong phần Trạng thái cần được cài đặt thủ công.",
|
||||
"description": "Lựa chọn này sẽ cấu hình Cloudron để nhận mail cho <b>{{ domain }}</b>. Xem hướng dẫn để mở <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">những cổng cần thiết</a> cho Email Cloudron.",
|
||||
"title": "Bật chế độ email cho {{ domain }}?"
|
||||
@@ -856,7 +874,7 @@
|
||||
"network": {
|
||||
"configureIp": {
|
||||
"providerGenericDescription": "Địa chỉ IP công cộng của server này sẽ được tự động dò tìm ra.",
|
||||
"title": "Cấu hình nhà cung cấp IP"
|
||||
"title": "Cấu hình nhà cung cấp IPv4"
|
||||
},
|
||||
"dyndns": {
|
||||
"description": "Bật lựa chọn này để đồng bộ các bản ghi DNS với một địa chỉ IP thường xuyên thay đổi. Việc này hữu ích khi Cloudron chạy trên hệ thống mạng với địa chỉ IP hay thay đổi như kết nối mạng ở nhà.",
|
||||
@@ -879,8 +897,8 @@
|
||||
"configure": "Cấu hình",
|
||||
"interface": "Tên giao diện mạng",
|
||||
"provider": "Nhà cung cấp",
|
||||
"description": "Cloudron dùng địa chỉ IP này để cài đặt các bản ghi DNS.",
|
||||
"title": "Địa chỉ IP",
|
||||
"description": "Cloudron dùng địa chỉ IPv4 này để cài đặt các bản ghi A của DNS.",
|
||||
"title": "IPv4",
|
||||
"address": "Địa chỉ IP"
|
||||
},
|
||||
"title": "Mạng",
|
||||
@@ -981,7 +999,8 @@
|
||||
"info": "Các cài đặt này áp dụng cho tất cả các tên miền.",
|
||||
"title": "Cài đặt",
|
||||
"acl": "Danh sách quản lý truy cập mail",
|
||||
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL"
|
||||
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL",
|
||||
"virtualAllMail": "Thư mực \"Tất cả Thư\""
|
||||
},
|
||||
"domains": {
|
||||
"testEmailTooltip": "Gửi mail thử",
|
||||
@@ -1018,6 +1037,10 @@
|
||||
},
|
||||
"action": {
|
||||
"queue": "Cho vào hàng chờ gửi sau"
|
||||
},
|
||||
"changeVirtualAllMailDialog": {
|
||||
"description": "Thư mục \"Tất cả Thư\" là một thư mục chứa tất cả thư trong hộp thư của bạn. Thư mục này hữu dụng cho những mail client mà không hỗ trợ chức năng tìm kiếm thư mục xoay vòng.",
|
||||
"title": "Thư mực \"Tất cả Thư\""
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
@@ -1032,7 +1055,9 @@
|
||||
},
|
||||
"logo": "Logo",
|
||||
"cloudronName": "Tên cho Cloudron",
|
||||
"title": "Thương hiệu"
|
||||
"title": "Thương hiệu",
|
||||
"backgroundImage": "Hình nền trang đăng nhập",
|
||||
"clearBackgroundImage": "Xoá"
|
||||
},
|
||||
"eventlog": {
|
||||
"time": "Thời gian",
|
||||
@@ -1064,7 +1089,19 @@
|
||||
"uninstalledApp": "App đã xoá",
|
||||
"diskSpeed": "Tốc độ: {{ speed }} MB/s"
|
||||
},
|
||||
"title": "Hệ thống"
|
||||
"title": "Hệ thống",
|
||||
"info": {
|
||||
"activationTime": "Ngày tạo Cloudron",
|
||||
"platformVersion": "Phiên bản hệ thống",
|
||||
"title": "Thông tin",
|
||||
"vendor": "Nhà cung cấp",
|
||||
"product": "Sản phẩm",
|
||||
"memory": "Bộ nhớ",
|
||||
"uptime": "Thời gian online"
|
||||
},
|
||||
"graphs": {
|
||||
"title": "Biểu đồ"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"remoteSupport": {
|
||||
@@ -1093,9 +1130,14 @@
|
||||
"subscriptionRequired": "Phiếu hỗ trợ chỉ có trong những gói trả phí.",
|
||||
"title": "Phiếu hỗ trợ",
|
||||
"emailNotVerified": "Email tài khoản cloudron.io của bạn {{ email }} chưa được xác minh. Xin hãy xác minh mail trước để tạo phiếu hỗ trợ.",
|
||||
"emailVerifyAction": "Xác minh ngay"
|
||||
"emailVerifyAction": "Xác minh ngay",
|
||||
"typeBilling": "Vấn đề Hóa đơn"
|
||||
},
|
||||
"title": "Hỗ trợ"
|
||||
"title": "Hỗ trợ",
|
||||
"help": {
|
||||
"description": "Xin dùng những nguồn lực sau để được trợ giúp và hỗ trợ\n* [Diễn dàn Cloudron]({{ forumLink }}) - Vui lòng vào Mục Hỗ trợ & App cụ thể để đặt câu hỏi.\n* [HDSD & Kho kiến thức Cloudron]({{ docsLink }})\n* [Đóng gói App tùy chỉnh & API]({{ packagingLink }})\n",
|
||||
"title": "Hỗ trợ"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"registryConfig": {
|
||||
@@ -1148,17 +1190,15 @@
|
||||
"changeScheduleAction": "Thay đổi lịch cập nhật",
|
||||
"showLogsAction": "Hiển thị log",
|
||||
"version": "Phiên bản hệ thống",
|
||||
"currentSchedule": "Lịch cập nhật tự động hiện tại cho hệ thống và các app là",
|
||||
"autoUpdateDisabled": "Cập nhật tự động cho hệ thống và các app <b>đã tắt</b>.",
|
||||
"title": "Cập nhật"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>.\nMúi giờ này được dùng cho việc lên lịch sao lưu và cập nhật hệ thống.",
|
||||
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>. Cài đặt này được dùng cho tác vụ sao lưu và cập nhật. Dấu thời gian hiện ở giao diện được hiển thị theo múi giờ của trình duyệt hiện dùng.",
|
||||
"title": "Múi giờ"
|
||||
},
|
||||
"appstoreAccount": {
|
||||
"subscriptionReactivateAction": "Kích hoạt lại gói đăng ký",
|
||||
"subscriptionChangeAction": "Thay đổi gói đăng ký",
|
||||
"subscriptionChangeAction": "Quản lý gói đăng ký",
|
||||
"subscriptionSetupAction": "Nâng cấp Gói Cao cấp",
|
||||
"subscriptionEndsAt": "Đã huỷ đăng ký và kết thúc vào",
|
||||
"cloudronId": "Mã Cloudron ID",
|
||||
@@ -1264,7 +1304,8 @@
|
||||
"renameDialog": {
|
||||
"rename": "Đổi tên",
|
||||
"newName": "Tên mới",
|
||||
"title": "Đổi tên {{ fileName }}"
|
||||
"title": "Đổi tên {{ fileName }}",
|
||||
"reallyOverwrite": "Trùng tên tập tin hiện có. Ghi đè lên tập tin cũ?"
|
||||
},
|
||||
"newFileDialog": {
|
||||
"create": "Tạo",
|
||||
@@ -1315,7 +1356,8 @@
|
||||
"filePath": "Đường chỉ đến tập tin hay thư mục",
|
||||
"title": "Tải xuống từ {{ name }}"
|
||||
},
|
||||
"title": "Màn hình terminal"
|
||||
"title": "Màn hình terminal",
|
||||
"uploadTo": "Tải lên {{ path }}"
|
||||
},
|
||||
"logs": {
|
||||
"download": "Tải xuống tất cả log",
|
||||
@@ -1384,7 +1426,13 @@
|
||||
"cloudflareDefaultProxyStatus": "Bật tính năng proxy cho những bản ghi DNS mới",
|
||||
"porkbunSecretapikey": "Mã bí mật API",
|
||||
"bunnyAccessKey": "Mã truy cập Bunny",
|
||||
"porkbunApikey": "Key API"
|
||||
"porkbunApikey": "Key API",
|
||||
"deSecToken": "Mã deSEC",
|
||||
"dnsimpleAccessToken": "Mã truy cập",
|
||||
"ovhAppSecret": "Mã bí mật App",
|
||||
"ovhEndpoint": "Điểm Endpoint",
|
||||
"ovhConsumerKey": "Mã Khách hàng",
|
||||
"ovhAppKey": "Mã App"
|
||||
},
|
||||
"subscriptionRequired": {
|
||||
"description": "Để thêm tên miền, hãy đăng ký gói trả phí.",
|
||||
@@ -1432,13 +1480,14 @@
|
||||
"firstTimeCollapseHeader": "Hướng dẫn cho lần cài đặt đầu tiên",
|
||||
"openAction": "Mở {{ app }}",
|
||||
"postInstallConfirmCheckbox": "Đã xem hướng dẫn",
|
||||
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>."
|
||||
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>.",
|
||||
"checklist": "Danh sách kiểm tra cho Admin"
|
||||
},
|
||||
"uninstall": {
|
||||
"uninstall": {
|
||||
"uninstallAction": "Xoá",
|
||||
"backupWarning": "Các bản sao lưu app sẽ không được xoá ngay mà sẽ dựa vào lịch trình sao lưu được định sẵn. Bạn có thể hồi sinh app từ một bản sao lưu hiện có bằng những <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">hướng dẫn sau đây</a>.",
|
||||
"description": "Lựa chọn này sẽ gỡ cài đặt app ngay lập tức và xoá hết tất cả những dữ liệu liên quan. Trang web sẽ không còn truy cập được sau đó.",
|
||||
"description": "Việc này sẽ xóa app ngay lập tức và tất cả dữ liệu. Trang sẽ không còn truy cập được sau khi xóa.",
|
||||
"title": "Xoá"
|
||||
},
|
||||
"startStop": {
|
||||
@@ -1491,23 +1540,24 @@
|
||||
},
|
||||
"updates": {
|
||||
"auto": {
|
||||
"enableAction": "Bật chế độ cập nhật tự động",
|
||||
"disableAction": "Tắt chế độ cập nhật tự động",
|
||||
"enableAction": "Bật cập nhật tự động",
|
||||
"disableAction": "Tắt cập nhật tự động",
|
||||
"disabled": "Cập nhật tự động hiện đang tắt.",
|
||||
"enabled": "Cập nhật tự dộng đang được mở.",
|
||||
"description": "Cloudron định kỳ kiểm tra Cửa hàng app cho các phiên bản cập nhật mới. Nếu bạn tắt chế độ cập nhật tự động, xin chắc rằng bạn cài đặt thủ công các cập nhật phiên bản mới.",
|
||||
"description": "Cloudron định kỳ kiểm tra <a href=\"{{ appStoreLink }}\" target=\"_blank\">Cửa hàng App </a> cho phiên bản app mới.",
|
||||
"title": "Cập nhật tự động"
|
||||
},
|
||||
"info": {
|
||||
"updateAvailableAction": "Có phiên bản cập nhật mới",
|
||||
"customAppUpdateInfo": "Phiên bản mới không có sẵn cho các app tuỳ chỉnh",
|
||||
"customAppUpdateInfo": "Tự động cập nhật không có sẵn cho các app tùy chỉnh.",
|
||||
"checkForUpdatesAction": "Kiểm tra cập nhật",
|
||||
"lastUpdated": "Lần cuối cập nhật",
|
||||
"packageVersion": "Phiên bản đóng gói",
|
||||
"appId": "ID của app",
|
||||
"description": "Tên app và phiên bản",
|
||||
"title": "Thông tin app",
|
||||
"repository": "Repo của bản đống gói"
|
||||
"repository": "Repo của bản đống gói",
|
||||
"installedAt": "Được cài lúc"
|
||||
},
|
||||
"noUpdates": "Không có phiên bản mới"
|
||||
},
|
||||
@@ -1586,14 +1636,14 @@
|
||||
},
|
||||
"resources": {
|
||||
"cpu": {
|
||||
"description": "Phần trăm thời gian CPU dành cho app khi hệ thống đang chịu tải nặng.",
|
||||
"title": "Chia phần trong CPU",
|
||||
"setAction": "Cài đặt"
|
||||
"description": "Phần trăm CPU tối đa app có thể dùng",
|
||||
"title": "Giới hạn CPU",
|
||||
"setAction": "Nâng lên"
|
||||
},
|
||||
"memory": {
|
||||
"resizeAction": "Chỉnh lại",
|
||||
"error": "Hệ thống không chỉnh được giới hạn bộ nhớ này, hãy thử một giá trị thấp hơn.",
|
||||
"description": "Cloudron dành 50% giá trị này cho RAM và 50% còn lại cho swap.",
|
||||
"description": "Bộ nhớ tối đa app có thể dùng",
|
||||
"title": "Giới hạn bộ nhớ"
|
||||
}
|
||||
},
|
||||
@@ -1747,7 +1797,8 @@
|
||||
},
|
||||
"redis": {
|
||||
"title": "Thiết lập Redis",
|
||||
"enable": "Thiết lập app sử dụng Redis"
|
||||
"enable": "Thiết lập app sử dụng Redis",
|
||||
"disable": "Tắt Redis"
|
||||
},
|
||||
"addApplinkDialog": {
|
||||
"title": "Thêm link app bên ngoài"
|
||||
@@ -1761,6 +1812,12 @@
|
||||
"upstreamUri": "Đường dẫn bên ngoài",
|
||||
"label": "Nhãn",
|
||||
"clearIconAction": "Xoá biểu tượng"
|
||||
},
|
||||
"infoTabTitle": "Thông tin",
|
||||
"info": {
|
||||
"notes": {
|
||||
"title": "Ghi chú của Admin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"volumes": {
|
||||
@@ -1795,10 +1852,14 @@
|
||||
},
|
||||
"mountStatus": "Trạng thái mount",
|
||||
"type": "Dạng",
|
||||
"tooltipEdit": "Chỉnh sửa Volume",
|
||||
"tooltipEdit": "Chỉnh Volume",
|
||||
"localDirectory": "Thư mục trên máy",
|
||||
"remountActionTooltip": "Mount Volume lại",
|
||||
"mountType": "Dạng mount"
|
||||
"mountType": "Dạng mount",
|
||||
"editVolumeDialog": {
|
||||
"title": "Chỉnh volume {{ name }}"
|
||||
},
|
||||
"editActionTooltip": "Chỉnh Volume"
|
||||
},
|
||||
"welcomeEmail": {
|
||||
"inviteLinkAction": "Bắt đầu tạo tải khoản",
|
||||
@@ -1822,7 +1883,8 @@
|
||||
"es": "Tiếng Tây Ban Nha",
|
||||
"ru": "Tiếng Nga",
|
||||
"da": "Tiếng Đan Mạch",
|
||||
"pt": "Tiếng Bồ Đào Nha"
|
||||
"pt": "Tiếng Bồ Đào Nha",
|
||||
"id": "Tiếng Indonesia"
|
||||
},
|
||||
"passwordResetEmail": {
|
||||
"subject": "[<%= cloudron %>] Đặt lại mật khẩu",
|
||||
@@ -1859,7 +1921,7 @@
|
||||
"topic": "Chúng tôi nhận thấy có một đăng nhập mới vào tài khoản Cloudron của bạn.",
|
||||
"salutation": "Xin chào <%= user %>,",
|
||||
"subject": "[<%= cloudron %>] Có đăng nhập mới vào tài khoản của bạn",
|
||||
"notice": "Chhungs tôi nhận thấy một đăng nhập trên tài khoản Cloudron của bạn từ một thiết bị mới.",
|
||||
"notice": "Có một đăng nhập vào tài khoản Cloudron của bạn từ một thiết bị mới.",
|
||||
"action": "Nếu người đó là bạn, bạn có thể thoải mái bỏ qua email này. Nếu đó không phải là bạn, bạn nên đổi mật khẩu của bạn ngay bây giờ."
|
||||
},
|
||||
"supportConfig": {
|
||||
|
||||
@@ -798,8 +798,6 @@
|
||||
},
|
||||
"updates": {
|
||||
"title": "更新",
|
||||
"autoUpdateDisabled": "平台和应用的自动更新已 <b>停用</b>。",
|
||||
"currentSchedule": "当前平台和应用的自动更新计划是",
|
||||
"version": "平台版本",
|
||||
"showLogsAction": "显示日志",
|
||||
"changeScheduleAction": "修改计划",
|
||||
|
||||
@@ -775,38 +775,38 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span>{{ app.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
|
||||
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'app.updates.info.installedAt' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span>{{ app.creationTime | prettyDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span>{{ app.updateTime | prettyDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -629,9 +629,12 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
|
||||
var nearest256m = Math.ceil(Math.max(result.memory, $scope.resources.currentMemoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
|
||||
var startTick = app.manifest.memoryLimit || (256 * 1024 * 1024);
|
||||
|
||||
for (var i = startTick; i <= nearest256m; i *= 2) {
|
||||
// code below ensure we atleast have 2 ticks to keep the slider usable
|
||||
$scope.resources.memoryTicks.push(startTick); // start tick
|
||||
for (var i = startTick * 2; i < nearest256m; i *= 2) {
|
||||
$scope.resources.memoryTicks.push(i);
|
||||
}
|
||||
$scope.resources.memoryTicks.push(nearest256m); // end tick
|
||||
});
|
||||
|
||||
// for firefox widget update
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
-->
|
||||
<div ng-bind-html="appPostInstallConfirm.message | markdown2html"></div>
|
||||
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
|
||||
|
||||
<div style="margin-top: 10px; margin-bottom: 5px;" ng-show="pendingChecklistItems(appPostInstallConfirm.app)">
|
||||
<label class="control-label">{{ 'app.appInfo.checklist' | tr }}</label>
|
||||
</div>
|
||||
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
|
||||
<div class="checklist-item" ng-hide="item.acknowledged">
|
||||
<span ng-bind-html="item.message | markdown2html"></span>
|
||||
|
||||
@@ -542,20 +542,20 @@
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom: 15px;">
|
||||
<p>{{ 'backups.schedule.description' | tr }}</p>
|
||||
<p ng-bind-html=" 'backups.schedule.description' | tr "></p>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'backups.schedule.schedule' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
|
||||
<span>{{ prettyBackupSchedule(backupPolicy.currentPolicy.schedule) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'backups.schedule.retentionPolicy' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
<span>{{ prettyBackupRetention(backupPolicy.currentPolicy.retention) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
/* global $, angular, TASK_TYPES, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, APP_TYPES */
|
||||
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR , REGIONS_CONTABO */
|
||||
/* global document, window, FileReader */
|
||||
|
||||
angular.module('Application').controller('BackupsController', ['$scope', '$location', '$rootScope', '$timeout', 'Client', function ($scope, $location, $rootScope, $timeout, Client) {
|
||||
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
|
||||
@@ -15,6 +16,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
|
||||
$scope.memory = null; // { memory, swap }
|
||||
$scope.mountStatus = null; // { state, message }
|
||||
$scope.manualBackupApps = [];
|
||||
$scope.currentTimeZone = '';
|
||||
|
||||
$scope.backupConfig = {};
|
||||
$scope.backups = [];
|
||||
@@ -785,7 +787,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
|
||||
$scope.backups.forEach(function (backup) {
|
||||
backup.contents = []; // { id, label, fqdn }
|
||||
backup.dependsOn.forEach(function (appBackupId) {
|
||||
let match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
|
||||
const match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
|
||||
if (!match) return; // for example, 'mail'
|
||||
const app = appsById[match[1]];
|
||||
if (app) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="content">
|
||||
<div class="content content-large">
|
||||
|
||||
<div class="text-left">
|
||||
<h1>{{ 'notifications.title' | tr }}
|
||||
@@ -15,7 +15,7 @@
|
||||
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
|
||||
</div>
|
||||
|
||||
<div class="card" ng-hide="busy || notifications.length">
|
||||
<div class="card card-large" ng-hide="busy || notifications.length">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h3 class="text-center" style="margin: 20px;">{{ 'notifications.nonePending' | tr }}</h3>
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
|
||||
<div class="card card-large notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
|
||||
<div class="row">
|
||||
<div class="col-xs-12" ng-class="{ 'notification-details': notification.detailsShown }">
|
||||
<span class="notification-title">{{ notification.title }}</span> <small class="text-muted" uib-tooltip="{{ notification.creationTime | prettyLongDate }}">{{ notification.creationTime | prettyDate }}</small>
|
||||
|
||||
@@ -272,22 +272,31 @@
|
||||
<div class="card" style="margin-bottom: 15px;">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ 'settings.updates.currentSchedule' | tr }} <b>{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</b></span>
|
||||
<span ng-show="updateSchedule.currentPattern === 'never'" ng-bind-html=" 'settings.updates.autoUpdateDisabled' | tr "></span>
|
||||
<span ng-bind-html=" 'settings.updates.description' | tr "></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'settings.updates.version' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<div class="col-xs-8 text-right">
|
||||
v{{ config.version }} ({{ config.ubuntuVersion }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-4">
|
||||
<span class="text-muted">{{ 'settings.updates.schedule' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
|
||||
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</span>
|
||||
<span ng-show="updateSchedule.currentPattern === 'never'">{{ 'settings.updates.disabled' | tr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<br/>
|
||||
<div ng-if="update.busy" class="col-md-12" style="margin-bottom: 10px;">
|
||||
|
||||
Generated
+270
-561
File diff suppressed because it is too large
Load Diff
+13
-14
@@ -9,27 +9,26 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/noto-sans": "^5.0.22",
|
||||
"@fontsource/noto-sans": "^5.1.0",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.1.1",
|
||||
"combokeys": "^3.0.1",
|
||||
"filesize": "^10.1.4",
|
||||
"marked": "^13.0.3",
|
||||
"filesize": "^10.1.6",
|
||||
"marked": "^14.1.2",
|
||||
"moment": "^2.30.1",
|
||||
"pankow": "^1.7.3",
|
||||
"pankow-viewers": "^1.0.4",
|
||||
"superagent": "^9.0.2",
|
||||
"vue": "^3.4.38",
|
||||
"vue-i18n": "^9.14.0",
|
||||
"vue-router": "^4.4.3"
|
||||
"pankow": "^2.2.1",
|
||||
"pankow-viewers": "^1.0.7",
|
||||
"vue": "^3.5.6",
|
||||
"vue-i18n": "^10.0.1",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@vitejs/plugin-vue": "^5.1.2",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"vite": "^5.4.1"
|
||||
"@eslint/js": "^9.10.0",
|
||||
"@vitejs/plugin-vue": "^5.1.3",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"vite": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<Button icon="fa-solid fa-eraser" @click="onClear()" style="margin-right: 5px">{{ $t('logs.clear') }}</Button>
|
||||
<Button :href="downloadUrl" target="_blank" icon="fa-solid fa-download">{{ $t('logs.download') }}</Button>
|
||||
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
|
||||
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
|
||||
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
|
||||
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
|
||||
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
|
||||
</template>
|
||||
</TopBar>
|
||||
</template>
|
||||
@@ -31,7 +31,7 @@ import { Button, TopBar, MainLayout } from 'pankow';
|
||||
import LogsModel from '../models/LogsModel.js';
|
||||
import AppModel from '../models/AppModel.js';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin ;
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
|
||||
export default {
|
||||
name: 'LogsViewer',
|
||||
@@ -43,7 +43,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
accessToken: localStorage.token,
|
||||
apiOrigin: API_ORIGIN || '',
|
||||
logsModel: null,
|
||||
appModel: null,
|
||||
busyRestart: false,
|
||||
@@ -113,10 +112,10 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logsModel = LogsModel.create(this.apiOrigin, this.accessToken, this.type, this.id);
|
||||
this.logsModel = LogsModel.create(API_ORIGIN, this.accessToken, this.type, this.id);
|
||||
|
||||
if (this.type === 'app') {
|
||||
this.appModel = AppModel.create(this.apiOrigin, this.accessToken, this.id);
|
||||
this.appModel = AppModel.create(API_ORIGIN, this.accessToken, this.id);
|
||||
|
||||
try {
|
||||
const app = await this.appModel.get();
|
||||
@@ -160,7 +159,7 @@ export default {
|
||||
this.logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
console.error('Failed to start log stream:', error);
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
<Button style="margin-left: 20px;" :disabled="!connected" @click="onUpload" icon="fa-solid fa-upload">{{ $t('terminal.uploadTo', { path: '/app/data/' }) }}</Button>
|
||||
<Button :disabled="!connected" @click="onDownload" icon="fa-solid fa-download">{{ $t('terminal.downloadAction') }}</Button>
|
||||
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
|
||||
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
|
||||
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
|
||||
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
|
||||
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
|
||||
</template>
|
||||
</TopBar>
|
||||
</template>
|
||||
@@ -48,9 +48,7 @@
|
||||
|
||||
<script>
|
||||
|
||||
import superagent from 'superagent';
|
||||
|
||||
import { Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
|
||||
import { fetcher, Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
@@ -75,7 +73,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
accessToken: localStorage.token,
|
||||
apiOrigin: API_ORIGIN || '',
|
||||
appModel: null,
|
||||
directoryModel: null,
|
||||
fatalError: false,
|
||||
@@ -117,7 +114,7 @@ export default {
|
||||
if (!downloadFileName) return;
|
||||
|
||||
try {
|
||||
const result = await superagent.head(`${this.apiOrigin}/api/v1/apps/${this.id}/download`).query({
|
||||
await fetcher.head(`${API_ORIGIN}/api/v1/apps/${this.id}/download`, {
|
||||
file: downloadFileName,
|
||||
access_token: this.accessToken
|
||||
});
|
||||
@@ -128,7 +125,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
this.downloadFileDownloadUrl = `${this.apiOrigin}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
|
||||
this.downloadFileDownloadUrl = `${API_ORIGIN}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
|
||||
|
||||
// we have to click the link to make the browser do the download
|
||||
// don't know how to prevent the browsers
|
||||
@@ -199,7 +196,7 @@ export default {
|
||||
|
||||
let execId;
|
||||
try {
|
||||
const result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.id}/exec`).query({ access_token: this.accessToken }).send({ cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' });
|
||||
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.id}/exec`, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, { access_token: this.accessToken });
|
||||
execId = result.body.id;
|
||||
} catch (error) {
|
||||
console.error('Cannot create socket.', error);
|
||||
@@ -222,7 +219,7 @@ export default {
|
||||
});
|
||||
|
||||
// websocket cannot use relative urls
|
||||
const url = `${this.apiOrigin.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
|
||||
const url = `${API_ORIGIN.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
|
||||
this.socket = new WebSocket(url);
|
||||
|
||||
this.terminal.loadAddon(new AttachAddon(this.socket));
|
||||
@@ -263,8 +260,8 @@ export default {
|
||||
this.id = id;
|
||||
this.name = id;
|
||||
|
||||
this.appModel = create(this.apiOrigin, this.accessToken, this.id);
|
||||
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, `apps/${id}`);
|
||||
this.appModel = create(API_ORIGIN, this.accessToken, this.id);
|
||||
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, `apps/${id}`);
|
||||
|
||||
try {
|
||||
const app = await this.appModel.get();
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import './style.css';
|
||||
|
||||
import '@fontsource/noto-sans';
|
||||
|
||||
import superagent from 'superagent';
|
||||
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
|
||||
import i18n from './i18n.js';
|
||||
import FileManager from './FileManager.vue';
|
||||
import Home from './views/Home.vue';
|
||||
import Viewer from './views/Viewer.vue';
|
||||
@@ -25,53 +23,10 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
const translations = {};
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
messages: translations,
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
messageResolver: (keys, key) => {
|
||||
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
|
||||
|
||||
// fallback tr key
|
||||
if (message === null) message = key;
|
||||
|
||||
return message.replaceAll('{{', '{').replaceAll('}}', '}');
|
||||
}
|
||||
});
|
||||
|
||||
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
|
||||
(async function loadLanguages() {
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
|
||||
async function loadLanguage(lang) {
|
||||
try {
|
||||
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
|
||||
|
||||
// we do not deliver as application/json :/
|
||||
translations[lang] = JSON.parse(result.text);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
await loadLanguage('en');
|
||||
|
||||
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
const app = createApp(FileManager);
|
||||
|
||||
app.use(i18n);
|
||||
app.use(await i18n());
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
|
||||
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { fetcher } from 'pankow';
|
||||
|
||||
const translations = {};
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
messages: translations,
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
messageResolver: function (keys, key) {
|
||||
const message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
|
||||
|
||||
// if not found return null to fallback to resolving for english
|
||||
if (message === null) return null;
|
||||
|
||||
return message.replaceAll('{{', '{').replaceAll('}}', '}');
|
||||
}
|
||||
});
|
||||
|
||||
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
|
||||
async function loadLanguage(lang) {
|
||||
try {
|
||||
const result = await fetcher.get(`${API_ORIGIN}/translation/${lang}.json`);
|
||||
translations[lang] = result.body;
|
||||
} catch (e) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// load at least fallback english
|
||||
await loadLanguage('en');
|
||||
|
||||
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
}
|
||||
|
||||
return i18n;
|
||||
}
|
||||
|
||||
export default main;
|
||||
+3
-48
@@ -1,61 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import './style.css';
|
||||
|
||||
import '@fontsource/noto-sans';
|
||||
|
||||
import superagent from 'superagent';
|
||||
|
||||
import i18n from './i18n.js';
|
||||
import LogsViewer from './components/LogsViewer.vue';
|
||||
|
||||
const translations = {};
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
messages: translations,
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
messageResolver: (keys, key) => {
|
||||
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
|
||||
|
||||
// fallback tr key
|
||||
if (message === null) message = key;
|
||||
|
||||
return message.replaceAll('{{', '{').replaceAll('}}', '}');
|
||||
}
|
||||
});
|
||||
|
||||
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
|
||||
(async function loadLanguages() {
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
|
||||
async function loadLanguage(lang) {
|
||||
try {
|
||||
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
|
||||
|
||||
// we do not deliver as application/json :/
|
||||
translations[lang] = JSON.parse(result.text);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
await loadLanguage('en');
|
||||
|
||||
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
const app = createApp(LogsViewer);
|
||||
|
||||
app.use(i18n);
|
||||
app.use(await i18n());
|
||||
|
||||
app.mount('#app');
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import superagent from 'superagent';
|
||||
import { ISTATES } from '../constants.js';
|
||||
import { fetcher } from 'pankow';
|
||||
import { sleep } from 'pankow/utils';
|
||||
|
||||
export function create(origin, accessToken, id) {
|
||||
@@ -9,13 +9,13 @@ export function create(origin, accessToken, id) {
|
||||
async get() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
|
||||
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid app ${id}`, error || result.statusCode);
|
||||
if (error || result.status !== 200) {
|
||||
console.error(`Invalid app ${id}`, error || result.status);
|
||||
this.fatalError = `Invalid app ${id}`;
|
||||
return;
|
||||
}
|
||||
@@ -25,25 +25,25 @@ export function create(origin, accessToken, id) {
|
||||
async restart() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
|
||||
result = await fetcher.post(`${origin}/api/v1/apps/${id}/restart`, null, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 202) {
|
||||
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
|
||||
if (error || result.status !== 202) {
|
||||
console.error(`Failed to restart app ${this.id}`, error || result.status);
|
||||
return;
|
||||
}
|
||||
|
||||
while(true) {
|
||||
let result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
|
||||
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import superagent from 'superagent';
|
||||
import { fetcher } from 'pankow';
|
||||
import { sanitize } from 'pankow/utils';
|
||||
|
||||
const BASE_URL = import.meta.env.BASE_URL || '/';
|
||||
@@ -35,15 +35,15 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
async listFiles(path) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/${api}/files/${path}`).query({ access_token: accessToken });
|
||||
result = await fetcher.get(`${origin}/api/v1/${api}/files/${path}`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
if (error || result.status !== 200) {
|
||||
if (error.status === 404) return [];
|
||||
|
||||
console.error('Failed to list files', error || result.statusCode);
|
||||
console.error('Failed to list files', error || result.status);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
// if we have an image, attach previewUrl
|
||||
if (item.mimeType.indexOf('image/') === 0) {
|
||||
item.previewUrl = `${origin}/api/v1/${api}/files/${encodeURIComponent(path + '/' + item.fileName)}?access_token=${accessToken}`;
|
||||
} else {
|
||||
item.previewUrl = '';
|
||||
}
|
||||
|
||||
item.owner = item.uid;
|
||||
@@ -109,31 +111,22 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
await this.save(filePath, '');
|
||||
},
|
||||
async newFolder(folderPath) {
|
||||
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}?access_token=${accessToken}&directory=true`);
|
||||
await fetcher.post(`${origin}/api/v1/${api}/files/${folderPath}`, { access_token: accessToken, directory: true });
|
||||
},
|
||||
async remove(filePath) {
|
||||
await superagent.del(`${origin}/api/v1/${api}/files/${filePath}`)
|
||||
.query({ access_token: accessToken });
|
||||
await fetcher.del(`${origin}/api/v1/${api}/files/${filePath}`, { access_token: accessToken });
|
||||
},
|
||||
async rename(fromFilePath, toFilePath, overwrite = false) {
|
||||
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
|
||||
.send({ action: 'rename', newFilePath: sanitize(toFilePath), overwrite })
|
||||
.query({ access_token: accessToken });
|
||||
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'rename', newFilePath: sanitize(toFilePath), overwrite }, { access_token: accessToken });
|
||||
},
|
||||
async copy(fromFilePath, toFilePath) {
|
||||
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
|
||||
.send({ action: 'copy', newFilePath: sanitize(toFilePath) })
|
||||
.query({ access_token: accessToken });
|
||||
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'copy', newFilePath: sanitize(toFilePath) }, { access_token: accessToken });
|
||||
},
|
||||
async chown(filePath, uid) {
|
||||
await superagent.put(`${origin}/api/v1/${api}/files/${filePath}`)
|
||||
.send({ action: 'chown', uid: uid, recursive: true })
|
||||
.query({ access_token: accessToken });
|
||||
await fetcher.put(`${origin}/api/v1/${api}/files/${filePath}`, { action: 'chown', uid: uid, recursive: true }, { access_token: accessToken });
|
||||
},
|
||||
async extract(path) {
|
||||
await superagent.put(`${origin}/api/v1/${api}/files/${path}`)
|
||||
.send({ action: 'extract' })
|
||||
.query({ access_token: accessToken });
|
||||
await fetcher.put(`${origin}/api/v1/${api}/files/${path}`, { action: 'extract' }, { access_token: accessToken });
|
||||
},
|
||||
async download(path) {
|
||||
window.open(`${origin}/api/v1/${api}/files/${path}?download=true&access_token=${accessToken}`);
|
||||
@@ -169,7 +162,7 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
const res = await req;
|
||||
await req;
|
||||
},
|
||||
async getFile(path) {
|
||||
let result;
|
||||
@@ -185,7 +178,7 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
},
|
||||
async paste(targetDir, action, files) {
|
||||
// this will not overwrite but tries to find a new unique name to past to
|
||||
for (let f in files) {
|
||||
for (const f in files) {
|
||||
let done = false;
|
||||
let targetPath = targetDir + '/' + files[f].name;
|
||||
while (!done) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
|
||||
import moment from 'moment';
|
||||
import superagent from 'superagent';
|
||||
import { ansiToHtml } from 'anser';
|
||||
import { ISTATES } from '../constants.js';
|
||||
import { sleep } from 'pankow/utils';
|
||||
|
||||
// https://github.com/janl/mustache.js/blob/master/mustache.js#L60
|
||||
const entityMap = {
|
||||
@@ -58,7 +55,17 @@ export function create(origin, accessToken, type, id) {
|
||||
name: 'LogsModel',
|
||||
stream(lineHandler, errorHandler) {
|
||||
eventSource = new EventSource(`${origin}${streamApi}?lines=${INITIAL_STREAM_LINES}&access_token=${accessToken}`);
|
||||
eventSource.onerror = errorHandler;
|
||||
eventSource._lastMessage = null;
|
||||
eventSource.onerror = function ( /* uselessError */) {
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
// eventSource does not give us the HTTP error code. We have to resort to message count check and guess the reason
|
||||
const msg = eventSource._lastMessage === null ? `Logs unavailable. Maybe the logs were logrotated.` : `Connection closed.`;
|
||||
const e = new Error(msg);
|
||||
e.time = moment().format('MMM DD HH:mm:ss');
|
||||
e.html = ansiToHtml(e.message);
|
||||
errorHandler(e);
|
||||
}
|
||||
};
|
||||
// eventSource.onopen = function () { console.log('stream is open'); };
|
||||
eventSource.onmessage = function (message) {
|
||||
var data;
|
||||
@@ -72,56 +79,12 @@ export function create(origin, accessToken, type, id) {
|
||||
const time = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
|
||||
const html = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
|
||||
|
||||
eventSource._lastMessage = { time, html };
|
||||
lineHandler(time, html);
|
||||
};
|
||||
},
|
||||
getDownloadUrl() {
|
||||
return `${origin}${downloadApi}?access_token=${accessToken}&format=short&lines=-1`;
|
||||
},
|
||||
// TODO maybe move this into AppsModel.js
|
||||
async getApp() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid app ${id}`, error || result.statusCode);
|
||||
this.fatalError = `Invalid app ${id}`;
|
||||
return;
|
||||
}
|
||||
|
||||
return result.body;
|
||||
},
|
||||
async restartApp() {
|
||||
if (type !== 'app') return;
|
||||
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 202) {
|
||||
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
while(true) {
|
||||
let result;
|
||||
try {
|
||||
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,19 +9,6 @@ html, body {
|
||||
color: var(--pankow-text-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 300 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2196f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover, a:focus {
|
||||
color: #0a6ebd;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,.1);
|
||||
}
|
||||
@@ -29,9 +16,3 @@ a:hover, a:focus {
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import './style.css';
|
||||
|
||||
import '@fontsource/noto-sans';
|
||||
|
||||
import superagent from 'superagent';
|
||||
|
||||
import i18n from './i18n.js';
|
||||
import Terminal from './components/Terminal.vue';
|
||||
|
||||
const translations = {};
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
messages: translations,
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
messageResolver: (keys, key) => {
|
||||
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
|
||||
|
||||
// fallback tr key
|
||||
if (message === null) message = key;
|
||||
|
||||
return message.replaceAll('{{', '{').replaceAll('}}', '}');
|
||||
}
|
||||
});
|
||||
|
||||
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
|
||||
(async function loadLanguages() {
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
|
||||
async function loadLanguage(lang) {
|
||||
try {
|
||||
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
|
||||
|
||||
// we do not deliver as application/json :/
|
||||
translations[lang] = JSON.parse(result.text);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
await loadLanguage('en');
|
||||
|
||||
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
const app = createApp(Terminal);
|
||||
|
||||
app.use(i18n);
|
||||
app.use(await i18n());
|
||||
|
||||
app.mount('#app');
|
||||
})();
|
||||
|
||||
+23
-48
@@ -28,7 +28,7 @@
|
||||
<template #header>
|
||||
<TopBar class="navbar">
|
||||
<template #left>
|
||||
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain/>
|
||||
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool/>
|
||||
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
|
||||
</template>
|
||||
<template #right>
|
||||
@@ -37,9 +37,9 @@
|
||||
<Button icon="fa-solid fa-upload" @click="onUploadMenu">{{ $t('filemanager.toolbar.upload') }}</Button>
|
||||
<Menu ref="uploadMenu" :model="uploadMenuModel"/>
|
||||
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
|
||||
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
|
||||
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
|
||||
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
|
||||
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
|
||||
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
|
||||
</template>
|
||||
</TopBar>
|
||||
</template>
|
||||
@@ -57,8 +57,6 @@
|
||||
:delete-handler="deleteHandler"
|
||||
:rename-handler="renameHandler"
|
||||
:change-owner-handler="changeOwnerHandler"
|
||||
:copy-handler="copyHandler"
|
||||
:cut-handler="cutHandler"
|
||||
:paste-handler="pasteHandler"
|
||||
:download-handler="downloadHandler"
|
||||
:extract-handler="extractHandler"
|
||||
@@ -68,7 +66,6 @@
|
||||
:upload-folder-handler="onUploadFolder"
|
||||
:drop-handler="onDrop"
|
||||
:items="items"
|
||||
:clipboard="clipboard"
|
||||
:owners-model="ownersModel"
|
||||
:fallback-icon="fallbackIcon"
|
||||
:tr="$t"
|
||||
@@ -100,10 +97,9 @@
|
||||
|
||||
<script>
|
||||
|
||||
import superagent from 'superagent';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
|
||||
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
|
||||
import Icon from 'pankow/components/Icon.vue';
|
||||
import { sanitize, sleep } from 'pankow/utils';
|
||||
|
||||
@@ -112,7 +108,7 @@ import { ISTATES } from '../constants.js';
|
||||
import PreviewPanel from '../components/PreviewPanel.vue';
|
||||
import { createDirectoryModel } from '../models/DirectoryModel.js';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
|
||||
const BASE_URL = import.meta.env.BASE_URL || '/';
|
||||
|
||||
const beforeUnloadListener = (event) => {
|
||||
@@ -150,12 +146,7 @@ export default {
|
||||
activeDirectoryItem: {},
|
||||
items: [],
|
||||
selectedItems: [],
|
||||
clipboard: {
|
||||
action: '', // copy or cut
|
||||
files: []
|
||||
},
|
||||
accessToken: localStorage.token,
|
||||
apiOrigin: API_ORIGIN || '',
|
||||
title: 'Cloudron',
|
||||
appLink: '',
|
||||
resourceType: '',
|
||||
@@ -222,13 +213,13 @@ export default {
|
||||
if (type === 'app') {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${resourceId}`).query({ access_token: this.accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${resourceId}`, { access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
|
||||
if (error || result.status !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
|
||||
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
|
||||
}
|
||||
|
||||
@@ -237,13 +228,13 @@ export default {
|
||||
} else if (type === 'volume') {
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes/${resourceId}`).query({ access_token: this.accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/volumes/${resourceId}`, { access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
|
||||
if (error || result.status !== 200) {
|
||||
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
|
||||
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
|
||||
}
|
||||
|
||||
@@ -253,7 +244,7 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await superagent.get(`${this.apiOrigin}/api/v1/dashboard/config`).query({ access_token: this.accessToken });
|
||||
const result = await fetcher.get(`${API_ORIGIN}/api/v1/dashboard/config`, { access_token: this.accessToken });
|
||||
this.footerContent = marked.parse(result.body.footer);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Cloudron config.', e);
|
||||
@@ -265,7 +256,7 @@ export default {
|
||||
this.resourceType = type;
|
||||
this.resourceId = resourceId;
|
||||
|
||||
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
|
||||
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
|
||||
this.ownersModel = this.directoryModel.ownersModel;
|
||||
|
||||
this.loadCwd();
|
||||
@@ -277,7 +268,7 @@ export default {
|
||||
this.resourceType = toParams.type;
|
||||
this.resourceId = toParams.resourceId;
|
||||
|
||||
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
|
||||
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
|
||||
}
|
||||
|
||||
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
|
||||
@@ -488,31 +479,15 @@ export default {
|
||||
|
||||
await this.loadCwd();
|
||||
},
|
||||
async copyHandler(files) {
|
||||
if (!files) return;
|
||||
|
||||
this.clipboard = {
|
||||
action: 'copy',
|
||||
files
|
||||
};
|
||||
},
|
||||
async cutHandler(files) {
|
||||
if (!files) return;
|
||||
|
||||
this.clipboard = {
|
||||
action: 'cut',
|
||||
files
|
||||
};
|
||||
},
|
||||
async pasteHandler(target) {
|
||||
if (!this.clipboard.files || !this.clipboard.files.length) return;
|
||||
async pasteHandler(action, files, target) {
|
||||
if (!files || !files.length) return;
|
||||
|
||||
const targetPath = (target && target.isDirectory) ? sanitize(this.cwd + '/' + target.fileName) : this.cwd;
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
|
||||
this.$refs.pasteInProgressDialog.open();
|
||||
|
||||
await this.directoryModel.paste(targetPath, this.clipboard.action, this.clipboard.files);
|
||||
await this.directoryModel.paste(targetPath, action, files);
|
||||
this.clipboard = {};
|
||||
await this.loadCwd();
|
||||
|
||||
@@ -565,25 +540,25 @@ export default {
|
||||
|
||||
let error, result;
|
||||
try {
|
||||
result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.resourceId}/restart`).query({ access_token: this.accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.resourceId}/restart`, null, { access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.statusCode !== 202) {
|
||||
console.error(`Failed to restart app ${this.resourceId}`, error || result.statusCode);
|
||||
if (error || result.status !== 202) {
|
||||
console.error(`Failed to restart app ${this.resourceId}`, error || result.status);
|
||||
return;
|
||||
}
|
||||
|
||||
while(true) {
|
||||
let result;
|
||||
try {
|
||||
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${this.resourceId}`, { access_token: this.accessToken });
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch app status.', e);
|
||||
}
|
||||
|
||||
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
|
||||
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TextViewer, ImageViewer } from 'pankow-viewers';
|
||||
import { createDirectoryModel } from '../models/DirectoryModel.js';
|
||||
import { sanitize } from 'pankow/utils';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
|
||||
|
||||
export default {
|
||||
name: 'Viewer',
|
||||
|
||||
Generated
-6
@@ -37,7 +37,6 @@
|
||||
"multiparty": "^4.2.3",
|
||||
"mysql": "^2.18.1",
|
||||
"nodemailer": "^6.9.13",
|
||||
"nsyslog-parser-2": "^0.9.11",
|
||||
"oidc-provider": "^8.4.6",
|
||||
"ovh": "^2.0.3",
|
||||
"qrcode": "^1.5.3",
|
||||
@@ -4215,11 +4214,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nsyslog-parser-2": {
|
||||
"version": "0.9.11",
|
||||
"resolved": "https://registry.npmjs.org/nsyslog-parser-2/-/nsyslog-parser-2-0.9.11.tgz",
|
||||
"integrity": "sha512-QF13YP12BAA38NOWescMjiEoyJtnRV5k++fYOP8kNqKFtCubv1w73W9UhjCeER4l87M+4CWlm3MJcD5ZbgDJAg=="
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.10",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"multiparty": "^4.2.3",
|
||||
"mysql": "^2.18.1",
|
||||
"nodemailer": "^6.9.13",
|
||||
"nsyslog-parser-2": "^0.9.11",
|
||||
"oidc-provider": "^8.4.6",
|
||||
"ovh": "^2.0.3",
|
||||
"qrcode": "^1.5.3",
|
||||
|
||||
@@ -2,40 +2,11 @@
|
||||
|
||||
set -eu
|
||||
|
||||
readonly source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly DATA_DIR="${HOME}/.cloudron_test"
|
||||
readonly DEFAULT_TESTS="./src/test/*-test.js ./src/routes/test/*-test.js"
|
||||
|
||||
! "${source_dir}/src/test/check-install" && exit 1
|
||||
|
||||
# cleanup old data dirs some of those docker container data requires sudo to be removed
|
||||
echo "=> Provide root password to purge any leftover data in ${DATA_DIR} and load apparmor profile:"
|
||||
sudo rm -rf ${DATA_DIR}
|
||||
|
||||
# archlinux does not have apparmor
|
||||
if hash apparmor_parser 2>/dev/null; then
|
||||
echo "=> Loading app armor profile"
|
||||
sudo apparmor_parser --replace --write-cache ./setup/start/docker-cloudron-app.apparmor
|
||||
fi
|
||||
|
||||
# create dir structure
|
||||
mkdir -p ${DATA_DIR}
|
||||
cd ${DATA_DIR}
|
||||
mkdir -p appsdata
|
||||
mkdir -p boxdata/box boxdata/mail boxdata/certs boxdata/mail/dkim/localhost boxdata/mail/dkim/foobar.com
|
||||
mkdir -p platformdata/addons/mail/banner platformdata/nginx/cert platformdata/nginx/applications/dashboard platformdata/collectd/collectd.conf.d platformdata/addons platformdata/logrotate.d platformdata/backup platformdata/logs/tasks platformdata/sftp/ssh platformdata/firewall platformdata/update
|
||||
sudo mkdir -p /mnt/cloudron-test-music /media/cloudron-test-music # volume test
|
||||
|
||||
# put cert
|
||||
echo "=> Generating a localhost selfsigned cert"
|
||||
openssl req -x509 -newkey rsa:2048 -keyout platformdata/nginx/cert/host.key -out platformdata/nginx/cert/host.cert -days 3650 -subj '/CN=localhost' -nodes -config <(cat /etc/ssl/openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:*.localhost"))
|
||||
|
||||
cd "${source_dir}"
|
||||
|
||||
# clear out any containers if FAST is unset
|
||||
if [[ -z ${FAST+x} ]]; then
|
||||
echo "=> Delete all docker containers first"
|
||||
docker ps -qa --filter "label=isCloudronManaged" | xargs --no-run-if-empty docker rm -f
|
||||
echo "=> Delete mysql server"
|
||||
docker rm -f mysql-server
|
||||
echo "==> To skip this run with: FAST=1 ./run-tests"
|
||||
else
|
||||
@@ -62,11 +33,8 @@ while ! mysqladmin ping -h"${MYSQL_IP}" --silent; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "=> Ensure local base image"
|
||||
docker pull cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4
|
||||
|
||||
echo "=> Create iptables blocklist"
|
||||
sudo ipset create cloudron_blocklist hash:net || true
|
||||
# echo "=> Create iptables blocklist"
|
||||
# sudo ipset create cloudron_blocklist hash:net || true
|
||||
|
||||
echo "=> Ensure database"
|
||||
mysql -h"${MYSQL_IP}" -uroot -ppassword -e "ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'password';"
|
||||
@@ -80,5 +48,6 @@ if [[ $# -gt 0 ]]; then
|
||||
TESTS="$*"
|
||||
fi
|
||||
|
||||
echo "=> Run tests with mocha"
|
||||
BOX_ENV=test ./node_modules/.bin/mocha --bail --no-timeouts --exit -R spec ${TESTS}
|
||||
echo "=> Run tests"
|
||||
docker run -e BOX_ENV=test -e DATABASE_HOSTNAME=${MYSQL_IP} -v `pwd`:/home/yellowtent/box:ro -v `which node`:/usr/bin/node:ro -v /var/run/docker.sock:/var/run/docker.sock -t cloudron/boxtest node_modules/.bin/mocha --bail --no-timeouts --colors --exit -R spec src/test
|
||||
|
||||
|
||||
@@ -247,8 +247,7 @@ function check_dns() {
|
||||
|
||||
function check_unbound() {
|
||||
if ! systemctl is-active -q unbound; then
|
||||
info "unbound is down. updating root anchor to see if it fixes it"
|
||||
unbound-anchor -a /var/lib/unbound/root.key
|
||||
info "unbound is down. restarting to see if it fixes it" # unbound-anchor is part of ExecStartPre
|
||||
systemctl restart unbound
|
||||
|
||||
if ! systemctl is-active -q unbound; then
|
||||
@@ -361,6 +360,48 @@ function check_node() {
|
||||
success "node version is correct"
|
||||
}
|
||||
|
||||
function print_ipv6_disable_howto() {
|
||||
echo "Instead of disabling IPv6 globally, you can disable it at an interface level."
|
||||
for iface in $(ls /sys/class/net | grep -vE '^(lo|veth|docker|virbr|br|vmnet|tun|tap|wl|we)'); do
|
||||
echo -e "\tsysctl -w net.ipv6.conf.${iface}.disable_ipv6=1"
|
||||
done
|
||||
|
||||
echo "For above configuration to persist reboots, you have to add below to /etc/sysctl.conf"
|
||||
for iface in $(ls /sys/class/net | grep -vE '^(lo|veth|docker|virbr|br|vmnet|tun|tap|wl|we)'); do
|
||||
echo -e "\tnet.ipv6.conf.${iface}.disable_ipv6=1"
|
||||
done
|
||||
}
|
||||
|
||||
function check_ipv6() {
|
||||
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
|
||||
if [[ "${ipv6_disable}" == "1" ]]; then
|
||||
fail "IPv6 is disabled in kernel. Cloudron requires IPv6 in kernel"
|
||||
print_ipv6_disable_howto
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# check if server has IPv6 address
|
||||
has_ipv6_address=0
|
||||
for iface in $(ls /sys/class/net | grep -vE '^(lo|veth|docker|virbr|br|vmnet|tun|tap|wl|we)'); do
|
||||
if ipv6=$(ip -6 addr show dev ${iface} | grep -o 'inet6 [^ ]*' | awk '{print $2}' | grep -v '^fe80'); then
|
||||
[[ -n "${ipv6}" ]] && has_ipv6_address=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${has_ipv6_address}" == "0" ]]; then
|
||||
success "IPv6 is enabled. No public IPv6 address"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! ping6 -q -c 1 api.cloudron.io; then
|
||||
fail "Server has an IPv6 address but api.cloudron.io is unreachable via IPv6"
|
||||
print_ipv6_disable_howto
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "IPv6 is enabled and public IPv6 address is working"
|
||||
}
|
||||
|
||||
function check_docker() {
|
||||
if ! systemctl is-active -q docker; then
|
||||
info "Docker is down. Trying to restart docker ..."
|
||||
@@ -503,6 +544,7 @@ function troubleshoot() {
|
||||
# note: disk space test has already been run globally
|
||||
print_system
|
||||
check_node
|
||||
check_ipv6
|
||||
check_docker
|
||||
check_host_mysql
|
||||
check_nginx # requires mysql to be checked
|
||||
@@ -591,6 +633,7 @@ function ask_reboot() {
|
||||
function recreate_docker() {
|
||||
readonly logfile="/home/yellowtent/platformdata/logs/box.log"
|
||||
readonly stagefile="/home/yellowtent/platformdata/recreate-docker-stage"
|
||||
readonly containerd_root="/var/lib/containerd"
|
||||
|
||||
if ! docker_root=$(docker info -f '{{ .DockerRootDir }}' 2>/dev/null); then
|
||||
warning "Unable to detect docker root. Assuming /var/lib/docker"
|
||||
@@ -616,7 +659,7 @@ function recreate_docker() {
|
||||
|
||||
if grep -q "clearing_storage" "${stagefile}"; then
|
||||
info "Clearing docker storage at ${docker_root}"
|
||||
if ! rm -rf "${docker_root}/"*; then
|
||||
if ! rm -rf "${docker_root}/"* "${containerd_root}/"*; then
|
||||
echo -e "\nThe server has to be rebooted to clear the docker storage. After reboot,"
|
||||
echo -e "run 'cloudron-support --recreate-docker' again.\n"
|
||||
ask_reboot
|
||||
|
||||
+10
-4
@@ -22,6 +22,11 @@ readonly MAIL_DATA_DIR="${HOME_DIR}/boxdata/mail"
|
||||
readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly ubuntu_version=$(lsb_release -rs)
|
||||
|
||||
vergte() {
|
||||
greater_version=$(echo -e "$1\n$2" | sort -rV | head -n1)
|
||||
[[ "$1" == "${greater_version}" ]] && return 0 || return 1
|
||||
}
|
||||
|
||||
cp -f "${script_dir}/../scripts/cloudron-support" /usr/bin/cloudron-support
|
||||
cp -f "${script_dir}/../scripts/cloudron-translation-update" /usr/bin/cloudron-translation-update
|
||||
rm -f /usr/bin/cloudron-logs # legacy script
|
||||
@@ -109,11 +114,12 @@ systemctl restart systemd-journald
|
||||
usermod -a -G adm ${USER}
|
||||
|
||||
log "Setting up unbound"
|
||||
cp -f "${script_dir}/start/unbound.conf" /etc/unbound/unbound.conf.d/cloudron-network.conf
|
||||
cp -f "${script_dir}/start/unbound/unbound.conf" /etc/unbound/unbound.conf.d/cloudron-network.conf
|
||||
unbound_version=$(unbound -V | sed -n 's/^Version \([0-9.]*\)/\1/p')
|
||||
if vergte "${unbound_version}" "1.19.2"; then
|
||||
cp "${script_dir}/start/unbound/prefer-ip4.conf" /etc/unbound/unbound.conf.d/cloudron-prefer-ip4.conf
|
||||
fi
|
||||
rm -f /etc/unbound/unbound.conf.d/remote-control.conf # on ubuntu 24
|
||||
# update the root anchor after a out-of-disk-space situation (see #269)
|
||||
# it returns 1 even on fail, it's not clear - https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound-anchor.html#exit-code
|
||||
unbound-anchor -v -a /var/lib/unbound/root.key || log "unbound-anchor failed, but it probably worked"
|
||||
|
||||
log "Adding systemd services"
|
||||
cp -r "${script_dir}/start/systemd/." /etc/systemd/system/
|
||||
|
||||
@@ -8,10 +8,13 @@ Wants=network-online.target nss-lookup.target
|
||||
|
||||
[Service]
|
||||
PIDFile=/run/unbound.pid
|
||||
# https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html ("Special Excecutable Prefixes")
|
||||
# update the root anchor after a out-of-disk-space situation (see #269)
|
||||
# it returns 1 even on fail, it's not clear - https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound-anchor.html#exit-code
|
||||
ExecStartPre=-/usr/sbin/unbound-anchor -a /var/lib/unbound/root.key
|
||||
ExecStart=/usr/sbin/unbound -d
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
# On ubuntu 16, this doesn't work for some reason
|
||||
Type=notify
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Prefer IPv4 outbound queries. Spamhaus often rejects queries from IPv6 addresses
|
||||
# This setting is in a separate file since it only works from Ubuntu 24 , unbound 1.19.2
|
||||
|
||||
server:
|
||||
prefer-ip4: yes
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ async function detectMetaInfo(applink) {
|
||||
});
|
||||
|
||||
const [jsdomError, dom] = await safe(jsdom.JSDOM.fromURL(applink.upstreamUri, { virtualConsole }));
|
||||
if (jsdomError) console.error('detectMetaInfo: jsdomError', jsdomError);
|
||||
if (jsdomError) debug('detectMetaInfo: jsdomError', jsdomError);
|
||||
|
||||
if (!applink.icon && dom) {
|
||||
let favicon = '';
|
||||
|
||||
+11
-11
@@ -103,7 +103,7 @@ async function login(email, password, totpToken) {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS, response.body.message);
|
||||
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Login error. status code: ${response.status}`);
|
||||
if (!response.body.accessToken) throw new BoxError(BoxError.EXTERNAL_ERROR, `Login error. invalid response: ${response.text}`);
|
||||
@@ -120,7 +120,7 @@ async function registerUser(email, password) {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 409) throw new BoxError(BoxError.ALREADY_EXISTS, 'Registration error: account already exists');
|
||||
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Registration error. invalid response: ${response.status}`);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ async function getSubscription() {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
||||
if (response.status === 502) throw new BoxError(BoxError.EXTERNAL_ERROR, `Stripe error: ${response.status} ${JSON.stringify(response.body)}`);
|
||||
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${response.status} ${JSON.stringify(response.body)}`);
|
||||
@@ -165,7 +165,7 @@ async function purchaseApp(data) {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND); // appstoreId does not exist
|
||||
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
||||
if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message);
|
||||
@@ -188,7 +188,7 @@ async function unpurchaseApp(appId, data) {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 404) return; // was never purchased
|
||||
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
||||
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `App unpurchase failed to get app. status:${response.status}`);
|
||||
@@ -199,7 +199,7 @@ async function unpurchaseApp(appId, data) {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
||||
if (response.status !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, `App unpurchase failed. status:${response.status}`);
|
||||
}
|
||||
@@ -221,7 +221,7 @@ async function getBoxUpdate(options) {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
||||
if (response.status === 204) return; // no update
|
||||
if (response.status !== 200 || !response.body) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${response.status} ${response.text}`);
|
||||
@@ -296,7 +296,7 @@ async function registerCloudron(data) {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 401) throw new BoxError(BoxError.LICENSE_ERROR, 'Setup token invalid');
|
||||
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to register cloudron: ${response.statusCode} ${error.message}`);
|
||||
|
||||
@@ -402,7 +402,7 @@ async function createTicket(info, auditSource) {
|
||||
}
|
||||
|
||||
const [error, response] = await safe(request);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
||||
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${response.status} ${response.text}`);
|
||||
|
||||
@@ -444,7 +444,7 @@ async function getApps() {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 403 || response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
||||
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `App listing failed. ${response.status} ${JSON.stringify(response.body)}`);
|
||||
if (!response.body.apps) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${response.status} ${response.text}`);
|
||||
@@ -467,7 +467,7 @@ async function getAppVersion(appId, version) {
|
||||
.timeout(60 * 1000)
|
||||
.ok(() => true));
|
||||
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
||||
if (response.status === 403 || response.statusCode === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
||||
if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND);
|
||||
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `App fetch failed. ${response.status} ${JSON.stringify(response.body)}`);
|
||||
|
||||
+12
-11
@@ -243,9 +243,10 @@ async function downloadImage(manifest) {
|
||||
await docker.downloadImage(manifest);
|
||||
}
|
||||
|
||||
async function updateChecklist(app, newChecks) {
|
||||
async function updateChecklist(app, newChecks, acknowledged = false) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof newChecks, 'object');
|
||||
assert.strictEqual(typeof acknowledged, 'boolean');
|
||||
|
||||
// add new checklist items depending on sso state
|
||||
const checklist = app.checklist || {};
|
||||
@@ -253,7 +254,7 @@ async function updateChecklist(app, newChecks) {
|
||||
if (app.checklist[k]) continue;
|
||||
|
||||
const item = {
|
||||
acknowledged: false,
|
||||
acknowledged: acknowledged,
|
||||
sso: newChecks[k].sso,
|
||||
appVersion: app.version,
|
||||
message: newChecks[k].message,
|
||||
@@ -296,7 +297,7 @@ async function install(app, args, progressCallback) {
|
||||
await verifyManifest(app.manifest);
|
||||
|
||||
// teardown for re-installs
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old containers' });
|
||||
await reverseProxy.unconfigureApp(app);
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
@@ -324,7 +325,7 @@ async function install(app, args, progressCallback) {
|
||||
await downloadIcon(app);
|
||||
|
||||
await progressCallback({ percent: 25, message: 'Updating checklist' });
|
||||
await updateChecklist(app, app.manifest.checklist || {});
|
||||
await updateChecklist(app, app.manifest.checklist || {}, restoreConfig ? true : false);
|
||||
|
||||
if (!skipDnsSetup) {
|
||||
await progressCallback({ percent: 30, message: 'Registering subdomains' });
|
||||
@@ -406,7 +407,7 @@ async function create(app, args, progressCallback) {
|
||||
assert.strictEqual(typeof args, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old container' });
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
// FIXME: re-setup addons only because sendmail addon to re-inject env vars on mailboxName change
|
||||
@@ -432,7 +433,7 @@ async function changeLocation(app, args, progressCallback) {
|
||||
const skipDnsSetup = args.skipDnsSetup;
|
||||
const overwriteDns = args.overwriteDns;
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Unregistering old domains' });
|
||||
await reverseProxy.unconfigureApp(app);
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
@@ -493,7 +494,7 @@ async function changeServices(app, args, progressCallback) {
|
||||
assert.strictEqual(typeof args, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old containers' });
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
const unusedAddons = {};
|
||||
@@ -526,7 +527,7 @@ async function migrateDataDir(app, args, progressCallback) {
|
||||
assert(newStorageVolumeId === null || typeof newStorageVolumeId === 'string');
|
||||
assert(newStorageVolumePrefix === null || typeof newStorageVolumePrefix === 'string');
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old containers' });
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
await progressCallback({ percent: 45, message: 'Ensuring app data directory' });
|
||||
@@ -556,7 +557,7 @@ async function configure(app, args, progressCallback) {
|
||||
assert.strictEqual(typeof args, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 10, message: 'Deleting old containers' });
|
||||
await reverseProxy.unconfigureApp(app);
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
|
||||
@@ -616,7 +617,7 @@ async function update(app, args, progressCallback) {
|
||||
}
|
||||
|
||||
await progressCallback({ percent: 20, message: 'Updating checklist' });
|
||||
await updateChecklist(app, app.manifest.checklist || {});
|
||||
await updateChecklist(app, app.manifest.checklist || {}, true /* new state acked */);
|
||||
|
||||
// download new image before app is stopped. this is so we can reduce downtime
|
||||
// and also not remove the 'common' layers when the old image is deleted
|
||||
@@ -625,7 +626,7 @@ async function update(app, args, progressCallback) {
|
||||
|
||||
// note: we cleanup first and then backup. this is done so that the app is not running should backup fail
|
||||
// we cannot easily 'recover' from backup failures because we have to revert manfest and portBindings
|
||||
await progressCallback({ percent: 35, message: 'Cleaning up old install' });
|
||||
await progressCallback({ percent: 35, message: 'Deleting old containers' });
|
||||
await deleteContainers(app, { managedOnly: true });
|
||||
if (app.manifest.dockerImage !== updateConfig.manifest.dockerImage) await docker.deleteImage(app.manifest);
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ async function run(progressCallback) {
|
||||
const { retention } = await backups.getPolicy();
|
||||
debug(`run: retention is ${JSON.stringify(retention)}`);
|
||||
|
||||
const status = await storage.api(backupConfig.provider).getProviderStatus(backupConfig);
|
||||
const status = await backups.ensureMounted();
|
||||
debug(`run: mount point status is ${JSON.stringify(status)}`);
|
||||
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not mounted: ${status.message}`);
|
||||
|
||||
|
||||
+11
-4
@@ -39,6 +39,7 @@ exports = module.exports = {
|
||||
|
||||
remount,
|
||||
getMountStatus,
|
||||
ensureMounted,
|
||||
|
||||
BACKUP_IDENTIFIER_BOX: 'box',
|
||||
BACKUP_IDENTIFIER_MAIL: 'mail',
|
||||
@@ -359,9 +360,7 @@ function managedBackupMountObject(backupConfig) {
|
||||
};
|
||||
}
|
||||
|
||||
async function remount(auditSource) {
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
async function remount() {
|
||||
const backupConfig = await getConfig();
|
||||
|
||||
if (mounts.isManagedProvider(backupConfig.provider)) {
|
||||
@@ -380,12 +379,20 @@ async function getMountStatus() {
|
||||
} else if (backupConfig.provider === 'filesystem') {
|
||||
hostPath = backupConfig.backupFolder;
|
||||
} else {
|
||||
throw new BoxError(BoxError.BAD_STATE, 'Backup location is not a mount');
|
||||
return { state: 'active' };
|
||||
}
|
||||
|
||||
return await mounts.getStatus(backupConfig.provider, hostPath); // { state, message }
|
||||
}
|
||||
|
||||
async function ensureMounted() {
|
||||
const status = await getMountStatus();
|
||||
if (status.state === 'active') return status;
|
||||
|
||||
await remount();
|
||||
return await getMountStatus();
|
||||
}
|
||||
|
||||
async function getPolicy() {
|
||||
const result = await settings.getJson(settings.BACKUP_POLICY_KEY);
|
||||
return result || {
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
|
||||
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
||||
|
||||
// check mount status before uploading
|
||||
const status = await storage.api(backupConfig.provider).getProviderStatus(backupConfig);
|
||||
const status = await backups.ensureMounted();
|
||||
debug(`upload: mount point status is ${JSON.stringify(status)}`);
|
||||
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not active: ${status.message}`);
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ function BoxError(reason, errorOrMessage, override) {
|
||||
this.message = reason;
|
||||
} else if (typeof errorOrMessage === 'string') {
|
||||
this.message = errorOrMessage;
|
||||
} else if (errorOrMessage instanceof AggregateError) { //
|
||||
const messages = errorOrMessage.errors.map(e => e.message);
|
||||
this.message = `${errorOrMessage.message} messages: ${messages.join(',')}`;
|
||||
this.nestedError = errorOrMessage;
|
||||
Object.assign(this, override); // copy enumerable properies
|
||||
} else { // error object
|
||||
this.message = errorOrMessage.message;
|
||||
this.nestedError = errorOrMessage;
|
||||
|
||||
+1
-6
@@ -25,7 +25,7 @@ const assert = require('assert'),
|
||||
let gConnectionPool = null;
|
||||
|
||||
const gDatabase = {
|
||||
hostname: '127.0.0.1',
|
||||
hostname: constants.TEST ? process.env.DATABASE_HOSTNAME : '127.0.0.1',
|
||||
username: 'root',
|
||||
password: 'password',
|
||||
port: 3306,
|
||||
@@ -35,11 +35,6 @@ const gDatabase = {
|
||||
async function initialize() {
|
||||
if (gConnectionPool !== null) return;
|
||||
|
||||
if (constants.TEST) {
|
||||
// see setupTest script how the mysql-server is run
|
||||
gDatabase.hostname = require('child_process').execSync('docker inspect -f "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" mysql-server').toString().trim();
|
||||
}
|
||||
|
||||
// https://github.com/mysqljs/mysql#pool-options
|
||||
gConnectionPool = mysql.createPool({
|
||||
connectionLimit: 5,
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ async function resolve(hostname, rrtype, options) {
|
||||
assert.strictEqual(typeof rrtype, 'string');
|
||||
assert(options && typeof options === 'object'); // { server, timeout }
|
||||
|
||||
const resolver = new dns.promises.Resolver({ timeout: options.timeout || 10000 });
|
||||
const resolver = new dns.promises.Resolver({ timeout: options.timeout || 10000, tries: options.tries || 2 });
|
||||
|
||||
if (constants.CLOUDRON) resolver.setServers([ options.server || '127.0.0.150' ]); // unbound runs here
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ async function isChangeSynced(hostname, type, value, nameserver) {
|
||||
const status = [];
|
||||
for (let i = 0; i < nsIPs.length; i++) {
|
||||
const nsIp = nsIPs[i];
|
||||
const resolveOptions = { server: nsIp, timeout: 5000 };
|
||||
const resolveOptions = { server: nsIp, timeout: 5000, tries: 1 };
|
||||
const resolver = type === 'A' || type === 'AAAA' ? resolveIp(hostname, type, resolveOptions) : dig.resolve(hostname, 'TXT', resolveOptions);
|
||||
|
||||
const [error, answer] = await safe(resolver);
|
||||
|
||||
+7
-4
@@ -288,7 +288,9 @@ async function createSubcontainer(app, name, cmd, options) {
|
||||
const { hostPort, type:portType, count:portCount } = app.portBindings[portName];
|
||||
const portSpec = portType == 'tcp' ? manifest.tcpPorts : manifest.udpPorts;
|
||||
const containerPort = portSpec[portName].containerPort || hostPort;
|
||||
const hostIps = hostPort === 53 ? await getAddressesForPort53() : [ '0.0.0.0', '::0' ]; // port 53 is special because it is possibly taken by systemd-resolved
|
||||
// port 53 is special. systemd-resolved is listening on 127.0.0.x port 53 and another process cannot listen to 0.0.0.0 port 53
|
||||
// for port 53 alone, we listen explicitly on the server's interface IP
|
||||
const hostIps = hostPort === 53 ? await getAddressesForPort53() : [ '0.0.0.0', '::0' ];
|
||||
|
||||
portEnv.push(`${portName}=${hostPort}`);
|
||||
if (portCount > 1) portEnv.push(`${portName}_COUNT=${portCount}`);
|
||||
@@ -355,7 +357,7 @@ async function createSubcontainer(app, name, cmd, options) {
|
||||
},
|
||||
// CpuPeriod (100000 microseconds) and CpuQuota(app.cpuQuota% of CpuPeriod)
|
||||
// 1000000000 is one core https://github.com/moby/moby/issues/24713#issuecomment-233167619 and https://stackoverflow.com/questions/52391877/set-the-number-of-cpu-cores-of-a-container-using-docker-engine-api
|
||||
NanoCPUs: app.cpuQuota === 100 ? 0 : (os.cpus().length * app.cpuQuota/100).toFixed(2) * 1000000000,
|
||||
NanoCPUs: app.cpuQuota === 100 ? 0 : Math.round(os.cpus().length * app.cpuQuota/100 * 1000000000),
|
||||
VolumesFrom: isAppContainer ? null : [ app.containerId + ':rw' ],
|
||||
SecurityOpt: [ 'apparmor=docker-cloudron-app' ],
|
||||
CapAdd: [],
|
||||
@@ -373,8 +375,9 @@ async function createSubcontainer(app, name, cmd, options) {
|
||||
if (isAppContainer) {
|
||||
containerOptions.Hostname = app.id;
|
||||
containerOptions.HostConfig.NetworkMode = 'cloudron'; // user defined bridge network
|
||||
containerOptions.HostConfig.Dns = ['172.18.0.1']; // use internal dns
|
||||
containerOptions.HostConfig.DnsSearch = ['.']; // use internal dns
|
||||
|
||||
// Do not inject for AdGuard. It ends up resolving the dashboard domain as the docker bridge IP
|
||||
if (manifest.id !== 'com.adguard.home.cloudronapp') containerOptions.HostConfig.ExtraHosts = [ `${dashboardFqdn}:172.18.0.1` ];
|
||||
|
||||
containerOptions.NetworkingConfig = {
|
||||
EndpointsConfig: {
|
||||
|
||||
@@ -118,6 +118,8 @@ function process(req, res, next) {
|
||||
async function start() {
|
||||
assert(gHttpServer === null, 'Already started');
|
||||
|
||||
if (constants.TEST) return;
|
||||
|
||||
const json = express.json({ strict: true });
|
||||
|
||||
// we protect container create as the app/admin can otherwise mount random paths (like the ghost file)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
exports = module.exports = {
|
||||
// a version change recreates all containers with latest docker config
|
||||
'version': '49.7.0',
|
||||
'version': '49.8.0',
|
||||
|
||||
// a major version bump in the db containers will trigger the restore logic that uses the db dumps
|
||||
// docker inspect --format='{{index .RepoDigests 0}}' $IMAGE to get the sha256
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ async function getStatus(mountType, hostPath) {
|
||||
|
||||
if (mountType === 'filesystem') return { state: 'active', message: 'Mounted' };
|
||||
|
||||
const [error] = await safe(shell.execArgs('getVolumeStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
|
||||
const [error] = await safe(shell.execArgs('getStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
|
||||
const state = error ? 'inactive' : 'active';
|
||||
|
||||
if (mountType === 'mountpoint') return { state, message: state === 'active' ? 'Mounted' : 'Not mounted' };
|
||||
|
||||
@@ -288,6 +288,10 @@ server {
|
||||
location ~ ^/translation/ {
|
||||
root <%= sourceDir %>/dashboard/dist;
|
||||
add_header "Access-Control-Allow-Origin" "*";
|
||||
|
||||
types {
|
||||
application/json json;
|
||||
}
|
||||
}
|
||||
|
||||
# Cross domain webfont access for proxy auth login page https://github.com/h5bp/server-configs/issues/85
|
||||
|
||||
+13
-3
@@ -44,6 +44,7 @@ const assert = require('assert'),
|
||||
translations = require('./translations.js'),
|
||||
url = require('url'),
|
||||
users = require('./users.js'),
|
||||
groups = require('./groups.js'),
|
||||
util = require('util');
|
||||
|
||||
const OIDC_CLIENTS_TABLE_NAME = 'oidcClients';
|
||||
@@ -719,6 +720,9 @@ async function claims(userId/*, use, scope*/) {
|
||||
const [error, user] = await safe(users.get(userId));
|
||||
if (error) return { error: 'user not found' };
|
||||
|
||||
const [groupsError, allGroups] = await safe(groups.listWithMembers());
|
||||
if (groupsError) return { error: groupsError.message }
|
||||
|
||||
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
|
||||
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
|
||||
|
||||
@@ -735,7 +739,8 @@ async function claims(userId/*, use, scope*/) {
|
||||
locale: 'en-US',
|
||||
name: user.displayName,
|
||||
picture: `https://${dashboardFqdn}/api/v1/profile/avatar/${user.id}`,
|
||||
preferred_username: user.username
|
||||
preferred_username: user.username,
|
||||
groups: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `${g.name}`; })
|
||||
};
|
||||
|
||||
return claims;
|
||||
@@ -817,12 +822,17 @@ async function start() {
|
||||
},
|
||||
claims: {
|
||||
email: ['email', 'email_verified'],
|
||||
profile: [ 'family_name', 'given_name', 'locale', 'name', 'preferred_username', 'picture' ]
|
||||
profile: [ 'family_name', 'given_name', 'locale', 'name', 'preferred_username', 'picture' ],
|
||||
groups: [ 'groups' ]
|
||||
},
|
||||
features: {
|
||||
rpInitiatedLogout: { enabled: false },
|
||||
devInteractions: { enabled: false }
|
||||
},
|
||||
clientDefaults: {
|
||||
response_types: ['code', 'id_token'],
|
||||
grant_types: ['authorization_code', 'implicit']
|
||||
},
|
||||
responseTypes: [
|
||||
'code',
|
||||
'id_token', 'id_token token',
|
||||
@@ -860,7 +870,7 @@ async function start() {
|
||||
accountId: ctx.oidc.session.accountId,
|
||||
});
|
||||
|
||||
grant.addOIDCScope('openid email profile');
|
||||
grant.addOIDCScope('openid email profile groups');
|
||||
// grant.addOIDCClaims(['first_name']);
|
||||
await grant.save();
|
||||
|
||||
|
||||
+2
-4
@@ -4,10 +4,7 @@ const constants = require('./constants.js'),
|
||||
path = require('path');
|
||||
|
||||
function baseDir() {
|
||||
const homeDir = process.env.HOME;
|
||||
if (constants.CLOUDRON) return homeDir;
|
||||
if (constants.TEST) return path.join(homeDir, '.cloudron_test');
|
||||
// cannot reach
|
||||
return process.env.HOME;
|
||||
}
|
||||
|
||||
// keep these values in sync with start.sh
|
||||
@@ -18,6 +15,7 @@ exports = module.exports = {
|
||||
INFRA_VERSION_FILE: path.join(baseDir(), 'platformdata/INFRA_VERSION'),
|
||||
CRON_SEED_FILE: path.join(baseDir(), 'platformdata/CRON_SEED'),
|
||||
DASHBOARD_DIR: constants.TEST ? path.join(__dirname, '../dashboard/src') : path.join(baseDir(), 'box/dashboard/dist'),
|
||||
TRANSLATIONS_DIR: constants.TEST ? path.join(__dirname, '../dashboard/src/translation') : path.join(baseDir(), 'box/dashboard/dist/translation'),
|
||||
|
||||
PROVIDER_FILE: '/etc/cloudron/PROVIDER',
|
||||
SETUP_TOKEN_FILE: '/etc/cloudron/SETUP_TOKEN',
|
||||
|
||||
+2
-2
@@ -266,9 +266,9 @@ async function onDashboardLocationSet(subdomain, domain) {
|
||||
async function onDashboardLocationChanged(auditSource) {
|
||||
assert.strictEqual(typeof auditSource, 'object');
|
||||
|
||||
// mark apps using oidc addon to be reconfigured
|
||||
// mark all apps to be reconfigured, all have ExtraHosts injected
|
||||
const [, installedApps] = await safe(apps.list());
|
||||
await safe(apps.configureApps(installedApps.filter((a) => !!a.manifest.addons?.oidc), { scheduleNow: true }, auditSource), { debug });
|
||||
await safe(apps.configureApps(installedApps, { scheduleNow: true }, auditSource), { debug });
|
||||
|
||||
await safe(services.rebuildService('turn', auditSource), { debug }); // to update the realm variable
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ async function cleanup(req, res, next) {
|
||||
}
|
||||
|
||||
async function remount(req, res, next) {
|
||||
const [error] = await safe(backups.remount(AuditSource.fromRequest(req)));
|
||||
const [error] = await safe(backups.remount());
|
||||
if (error) return next(BoxError.toHttpError(error));
|
||||
|
||||
next(new HttpSuccess(202, {}));
|
||||
|
||||
@@ -41,7 +41,7 @@ async function enableRemoteSupport(req, res, next) {
|
||||
if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enabled is required'));
|
||||
|
||||
const [error] = await safe(support.enableRemoteSupport(req.body.enable, AuditSource.fromRequest(req)));
|
||||
if (error) return next(new HttpError(503, 'Error enabling remote support. Try running "cloudron-support --enable-ssh" on the server'));
|
||||
if (error) return next(new HttpError(503, 'Error enabling remote support. Try running "cloudron-support --enable-remote-support" on the server'));
|
||||
|
||||
next(new HttpSuccess(202, {}));
|
||||
}
|
||||
|
||||
@@ -118,12 +118,13 @@ describe('System', function () {
|
||||
expect(response.body.swap).to.be.a('number');
|
||||
});
|
||||
|
||||
it('fails (non-admin)', async function () {
|
||||
it('succeeds (admin)', async function () {
|
||||
const response = await superagent.get(`${serverUrl}/api/v1/system/memory`)
|
||||
.query({ access_token: user.token })
|
||||
.ok(() => true);
|
||||
.query({ access_token: user.token });
|
||||
|
||||
expect(response.statusCode).to.equal(403);
|
||||
expect(response.statusCode).to.equal(200);
|
||||
expect(response.body.memory).to.eql(os.totalmem());
|
||||
expect(response.body.swap).to.be.a('number');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ host_path="$1"
|
||||
mount_filename=$(systemd-escape -p --suffix=mount "$host_path")
|
||||
mount_file="/etc/systemd/system/${mount_filename}"
|
||||
|
||||
# stop and start will do the reumount
|
||||
# stop and start will do the remount
|
||||
systemctl stop "${mount_filename}"
|
||||
systemctl start "${mount_filename}"
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ fi
|
||||
service="$1"
|
||||
|
||||
if [[ "${service}" == "unbound" ]]; then
|
||||
unbound-anchor -a /var/lib/unbound/root.key
|
||||
systemctl restart --no-block unbound
|
||||
elif [[ "${service}" == "nginx" ]]; then
|
||||
if systemctl -q is-active nginx; then
|
||||
|
||||
@@ -46,11 +46,18 @@ fi
|
||||
# DEBUG has to be hardcoded because it is not set in the tests. --setenv is required for ubuntu 16 (-E does not work)
|
||||
# NODE_OPTIONS is used because env -S does not work in ubuntu 16/18.
|
||||
# it seems systemd-run does not return the exit status of the process despite --wait
|
||||
if ! systemd-run --unit "${service_name}" --nice "${nice}" --uid=${id} --gid=${id} ${options} --setenv HOME=${HOME} --setenv USER=${SUDO_USER} --setenv DEBUG=box:* --setenv BOX_ENV=${BOX_ENV} --setenv NODE_ENV=production --setenv NODE_OPTIONS=--unhandled-rejections=strict --setenv AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE=1 "${task_worker}" "${task_id}" "${logfile}"; then
|
||||
echo "Service ${service_name} failed to run" # this only happens if the path to task worker itself is wrong
|
||||
fi
|
||||
if [[ "$CI" == "1" ]]; then
|
||||
if ! DEBUG=box:* NODE_ENV=production NODE_OPTIONS=--unhandled-rejections=strict gosu $id:$id "${task_worker}" "${task_id}" "${logfile}"; then
|
||||
echo "Service ${service_name} failed to run" # this only happens if the path to task worker itself is wrong
|
||||
fi
|
||||
exit_code=$?
|
||||
else
|
||||
if ! systemd-run --unit "${service_name}" --nice "${nice}" --uid=${id} --gid=${id} ${options} --setenv HOME=${HOME} --setenv USER=${SUDO_USER} --setenv DEBUG=box:* --setenv BOX_ENV=${BOX_ENV} --setenv NODE_ENV=production --setenv NODE_OPTIONS=--unhandled-rejections=strict --setenv AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE=1 "${task_worker}" "${task_id}" "${logfile}"; then
|
||||
echo "Service ${service_name} failed to run" # this only happens if the path to task worker itself is wrong
|
||||
fi
|
||||
|
||||
exit_code=$(systemctl show "${service_name}" -p ExecMainCode | sed 's/ExecMainCode=//g')
|
||||
exit_code=$(systemctl show "${service_name}" -p ExecMainCode | sed 's/ExecMainCode=//g')
|
||||
fi
|
||||
|
||||
echo "Service ${service_name} finished with exit code ${exit_code}"
|
||||
exit "${exit_code}"
|
||||
|
||||
+5
-4
@@ -113,23 +113,24 @@ async function initializeExpressSync() {
|
||||
router.post('/api/v1/dashboard/location', json, token, authorizeAdmin, routes.dashboard.changeLocation);
|
||||
|
||||
// system (vm/server)
|
||||
router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo);
|
||||
router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo); // vendor, product name etc
|
||||
router.post('/api/v1/system/reboot', json, token, authorizeAdmin, routes.system.reboot);
|
||||
router.get ('/api/v1/system/graphs', token, authorizeAdmin, routes.system.getSystemGraphs);
|
||||
router.get ('/api/v1/system/cpus', token, authorizeAdmin, routes.system.getCpus);
|
||||
router.get ('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.getDiskUsage);
|
||||
router.post('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.updateDiskUsage);
|
||||
router.get ('/api/v1/system/block_devices', token, authorizeAdmin, routes.system.getBlockDevices);
|
||||
router.get ('/api/v1/system/memory', token, authorizeAdmin, routes.system.getMemory);
|
||||
router.get ('/api/v1/system/logs/:unit', token, authorizeAdmin, routes.system.getLogs);
|
||||
router.get ('/api/v1/system/logstream/:unit', token, authorizeAdmin, routes.system.getLogStream);
|
||||
// app operators require cpu and memory info for the Resources UI
|
||||
router.get ('/api/v1/system/cpus', token, authorizeUser, routes.system.getCpus);
|
||||
router.get ('/api/v1/system/memory', token, authorizeUser, routes.system.getMemory);
|
||||
|
||||
// eventlog
|
||||
router.get ('/api/v1/eventlog', token, authorizeAdmin, routes.eventlog.list);
|
||||
router.get ('/api/v1/eventlog/:eventId', token, authorizeAdmin, routes.eventlog.get);
|
||||
|
||||
// updater
|
||||
router.get ('/api/v1/updater/updates', token, authorizeAdmin, routes.updater.getUpdateInfo);
|
||||
router.get ('/api/v1/updater/updates', token, authorizeUser, routes.updater.getUpdateInfo);
|
||||
router.post('/api/v1/updater/update', json, token, authorizeAdmin, routes.updater.update);
|
||||
router.post('/api/v1/updater/check_for_updates', json, token, authorizeAdmin, routes.updater.checkForUpdates);
|
||||
router.get ('/api/v1/updater/autoupdate_pattern', token, authorizeAdmin, routes.updater.getAutoupdatePattern);
|
||||
|
||||
+2
-2
@@ -111,10 +111,10 @@ function sudo(tag, args, options, callback) {
|
||||
|
||||
cp.stdout.on('data', (data) => {
|
||||
if (options.captureStdout) stdoutResult += data.toString('utf8');
|
||||
if (!options.quiet) process.stdout.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
|
||||
if (!options.quiet) process.stdout.write(data + '\r'); // do not use debug to avoid double timestamps when calling backupupload.js
|
||||
});
|
||||
cp.stderr.on('data', (data) => {
|
||||
process.stderr.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
|
||||
process.stderr.write(data + '\r'); // do not use debug to avoid double timestamps when calling backupupload.js
|
||||
});
|
||||
|
||||
cp.on('exit', function (code, signal) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -35,23 +34,12 @@ const assert = require('assert'),
|
||||
debug = require('debug')('box:storage/filesystem'),
|
||||
df = require('../df.js'),
|
||||
fs = require('fs'),
|
||||
mounts = require('../mounts.js'),
|
||||
path = require('path'),
|
||||
paths = require('../paths.js'),
|
||||
readdirp = require('readdirp'),
|
||||
safe = require('safetydance'),
|
||||
shell = require('../shell.js');
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
// Check filesystem is mounted so we don't write into the actual folder on disk
|
||||
if (!mounts.isManagedProvider(apiConfig.provider) && apiConfig.provider !== 'mountpoint') return await mounts.getStatus(apiConfig.provider, apiConfig.backupFolder);
|
||||
|
||||
const hostPath = mounts.isManagedProvider(apiConfig.provider) ? paths.MANAGED_BACKUP_MOUNT_DIR : apiConfig.mountPoint;
|
||||
return await mounts.getStatus(apiConfig.provider, hostPath); // { state, message }
|
||||
}
|
||||
|
||||
// the du call in the function below requires root
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
@@ -88,11 +76,12 @@ async function upload(apiConfig, backupFilePath) {
|
||||
const [mkdirError] = await safe(fs.promises.mkdir(path.dirname(backupFilePath), { recursive: true }));
|
||||
if (mkdirError) throw new BoxError(BoxError.FS_ERROR, `Error creating directory ${backupFilePath}: ${mkdirError.message}`);
|
||||
|
||||
await safe(fs.promises.unlink(backupFilePath), { debug }); // remove any hardlink
|
||||
await safe(fs.promises.unlink(backupFilePath)); // remove any hardlink
|
||||
|
||||
return {
|
||||
stream: fs.createWriteStream(backupFilePath, { autoClose: true }),
|
||||
async finish() {
|
||||
console.log('OK CHOWNNIG!!!!!', process.env.SUDO_UID, process.getuid());
|
||||
const backupUid = parseInt(process.env.SUDO_UID, 10) || process.getuid(); // in test, upload() may or may not be called via sudo script
|
||||
if (hasChownSupportSync(apiConfig)) {
|
||||
if (!safe.fs.chownSync(backupFilePath, backupUid, backupUid)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to chown ${backupFilePath}: ${safe.error.message}`);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -61,12 +60,6 @@ function getBucket(apiConfig) {
|
||||
return new GCS(gcsConfig).bucket(apiConfig.bucket);
|
||||
}
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
return { state: 'active' };
|
||||
}
|
||||
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
// for the other API calls we leave it to the backend to retry. this allows
|
||||
// them to tune the concurrency based on failures/rate limits accordingly
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -44,12 +43,6 @@ function injectPrivateFields(newConfig, currentConfig) {
|
||||
// in-place injection of tokens and api keys which came in with constants.SECRET_PLACEHOLDER
|
||||
}
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
return { state: 'active' };
|
||||
}
|
||||
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -24,12 +23,6 @@ const assert = require('assert'),
|
||||
debug = require('debug')('box:storage/noop'),
|
||||
fs = require('fs');
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
return { state: 'active' };
|
||||
}
|
||||
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
getProviderStatus,
|
||||
getAvailableSize,
|
||||
|
||||
upload,
|
||||
@@ -92,12 +91,6 @@ function getS3Config(apiConfig) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async function getProviderStatus(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
return { state: 'active' };
|
||||
}
|
||||
|
||||
async function getAvailableSize(apiConfig) {
|
||||
assert.strictEqual(typeof apiConfig, 'object');
|
||||
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ async function getDisks() {
|
||||
const DISK_TYPES = [ 'ext4', 'xfs', 'cifs', 'nfs', 'fuse.sshfs' ]; // we don't show size of contents in untracked disk types
|
||||
|
||||
for (const disk of dfEntries) {
|
||||
if (!DISK_TYPES.includes(disk.type)) continue;
|
||||
if (!DISK_TYPES.includes(disk.type) && disk.mountpoint !== '/') continue;
|
||||
if (disk.mountpoint === '/') rootDisk = disk;
|
||||
disks[disk.filesystem] = {
|
||||
filesystem: disk.filesystem,
|
||||
|
||||
+1
-1
@@ -169,7 +169,7 @@ function startTask(id, options, onTaskFinished) {
|
||||
let killTimerId = null, timedOut = false;
|
||||
|
||||
const sudoOptions = { preserveEnv: true, logStream: null };
|
||||
if (constants.TEST) sudoOptions.logStream = fs.createWriteStream('/dev/null'); // without this output is messed up, not sure why
|
||||
//if (constants.TEST) sudoOptions.logStream = fs.createWriteStream('/dev/null'); // without this output is messed up, not sure why
|
||||
gTasks[id] = shell.sudo('startTask', [ START_TASK_CMD, id, logFile, options.nice || 0, options.memoryLimit || 400, options.oomScoreAdjust || 0 ], sudoOptions, async function (sudoError) {
|
||||
if (!gTasks[id]) return; // ignore task exit since we are shutting down. see stopAllTasks
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ const applinks = require('../applinks.js'),
|
||||
describe('Applinks', function () {
|
||||
const { setup, cleanup } = common;
|
||||
|
||||
this.timeout(10000);
|
||||
|
||||
before(setup);
|
||||
after(cleanup);
|
||||
|
||||
@@ -30,7 +32,7 @@ describe('Applinks', function () {
|
||||
};
|
||||
|
||||
const APPLINK_2 = {
|
||||
upstreamUri: 'https://google.com'
|
||||
upstreamUri: 'https://google.de'
|
||||
};
|
||||
|
||||
const APPLINK_3 = {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eu
|
||||
|
||||
readonly source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
readonly sudo_scripts_dir="${source_dir}/src/scripts"
|
||||
|
||||
if [[ ! -f /usr/bin/node ]]; then
|
||||
echo "node is not in root user's environment. '/usr/bin/env node' will not work"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# checks if all scripts are sudo access
|
||||
readarray -d '' scripts < <(find ${sudo_scripts_dir} -type f -print0)
|
||||
declare -a missing_scripts=()
|
||||
for script in "${scripts[@]}"; do
|
||||
# sudo -k ignores a cached sudo session for the command
|
||||
if [[ $(sudo -k -n "${script}" --check 2>/dev/null) != "OK" ]]; then
|
||||
missing_scripts+=("${script}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing_scripts[@]} -gt 0 ]]; then
|
||||
echo "The following script(s) have no sudo access: ${missing_scripts[*]} . Try 'sudo -n ${missing_scripts[0]} --check'"
|
||||
echo -e "\nYou have to add the lines below to /etc/sudoers.d/yellowtent\n\n"
|
||||
|
||||
for missing_script in "${missing_scripts[@]}"; do
|
||||
echo "Defaults!${missing_script} env_keep=\"HOME BOX_ENV\""
|
||||
echo "${USER} ALL=(ALL) NOPASSWD: ${missing_script}"
|
||||
echo ""
|
||||
done
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
setenv_scripts=(starttask.sh backupupload.js)
|
||||
for script in "${setenv_scripts[@]}"; do
|
||||
if ! grep -q ":SETENV:.*${script}" "/etc/sudoers.d/yellowtent"; then
|
||||
echo "SETENV missing for ${script} in /etc/sudoers.d/yellowtent"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! grep -q "backupupload.js closefrom_override" "/etc/sudoers.d/yellowtent"; then
|
||||
echo "backupupload.js needs closefrom_override in /etc/sudoers.d/yellowtent"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
images=$(node -e "const i = require('${source_dir}/src/infra_version.js'); console.log(Object.keys(i.images).map(x => i.images[x]).join(' '));")
|
||||
|
||||
for image in ${images}; do
|
||||
if ! docker inspect "${image}" >/dev/null 2>/dev/null; then
|
||||
docker pull ${image%@sha256:*}
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -11,7 +11,7 @@ const BoxError = require('../boxerror.js'),
|
||||
expect = require('expect.js'),
|
||||
safe = require('safetydance');
|
||||
|
||||
describe('Settings', function () {
|
||||
describe('Cloudron', function () {
|
||||
const { setup, cleanup } = common;
|
||||
|
||||
before(setup);
|
||||
|
||||
+1
-7
@@ -17,20 +17,14 @@ describe('System', function () {
|
||||
after(cleanup);
|
||||
|
||||
it('can get disks', async function () {
|
||||
// does not work on archlinux 8!
|
||||
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
|
||||
|
||||
const disks = await df.disks();
|
||||
expect(disks).to.be.ok();
|
||||
expect(disks.some(d => d.mountpoint === '/')).to.be.ok();
|
||||
});
|
||||
|
||||
it('can get file', async function () {
|
||||
// does not work on archlinux 8!
|
||||
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
|
||||
|
||||
const disks = await df.file(__dirname);
|
||||
expect(disks).to.be.ok();
|
||||
expect(disks.mountpoint).to.be('/home');
|
||||
expect(disks.mountpoint).to.be('/home/yellowtent/box');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,11 +160,8 @@ describe('Storage', function () {
|
||||
format: 'tgz'
|
||||
};
|
||||
|
||||
it('upload works', function (done) {
|
||||
noop.upload(gBackupConfig, 'file', { }, function (error) {
|
||||
expect(error).to.be(null);
|
||||
done();
|
||||
});
|
||||
it('upload works', async function () {
|
||||
await noop.upload(gBackupConfig, 'file', {});
|
||||
});
|
||||
|
||||
it('can download file', async function () {
|
||||
|
||||
@@ -17,27 +17,18 @@ describe('System', function () {
|
||||
after(cleanup);
|
||||
|
||||
it('can get disks', async function () {
|
||||
// does not work on archlinux 8!
|
||||
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
|
||||
|
||||
const disks = await system.getDisks();
|
||||
expect(disks).to.be.ok();
|
||||
expect(Object.keys(disks).some(fs => disks[fs].mountpoint === '/')).to.be.ok();
|
||||
});
|
||||
|
||||
it('can get swaps', async function () {
|
||||
// does not work on archlinux 8!
|
||||
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
|
||||
|
||||
const swaps = await system.getSwaps();
|
||||
expect(swaps).to.be.ok();
|
||||
expect(Object.keys(swaps).some(n => swaps[n].type === 'partition' || swaps[n].type === 'file')).to.be.ok();
|
||||
});
|
||||
|
||||
it('can check for disk space', async function () {
|
||||
// does not work on archlinux 8!
|
||||
if (require('child_process').execSync('uname -a').toString().indexOf('-arch') !== -1) return;
|
||||
|
||||
await system.checkDiskSpace();
|
||||
});
|
||||
|
||||
|
||||
+9
-9
@@ -14,8 +14,6 @@ const assert = require('assert'),
|
||||
paths = require('./paths.js'),
|
||||
safe = require('safetydance');
|
||||
|
||||
const TRANSLATION_FOLDER = path.join(paths.DASHBOARD_DIR, 'translation');
|
||||
|
||||
// to be used together with getTranslations() => { translations, fallback }
|
||||
function translate(input, assets) {
|
||||
assert.strictEqual(typeof input, 'string');
|
||||
@@ -51,21 +49,23 @@ function translate(input, assets) {
|
||||
}
|
||||
|
||||
async function getTranslations() {
|
||||
const fallback = safe.JSON.parse(fs.readFileSync(path.join(TRANSLATION_FOLDER, 'en.json'), 'utf8'));
|
||||
if (!fallback) debug(`getTranslations: Fallback language en not found. ${safe.error.message}`);
|
||||
const fallbackData = fs.readFileSync(path.join(paths.TRANSLATIONS_DIR, 'en.json'), 'utf8');
|
||||
if (!fallbackData) debug(`getTranslations: Fallback language en not found. ${safe.error.message}`);
|
||||
const fallback = safe.JSON.parse(fallbackData) || {};
|
||||
|
||||
const lang = await cloudron.getLanguage();
|
||||
|
||||
const translations = safe.JSON.parse(fs.readFileSync(path.join(TRANSLATION_FOLDER, lang + '.json'), 'utf8'));
|
||||
if (!translations) debug(`getTranslations: Requested language ${lang} not found. ${safe.error.message}`);
|
||||
const translationData = safe.fs.readFileSync(path.join(paths.TRANSLATIONS_DIR, `${lang}.json`), 'utf8');
|
||||
if (!translationData) debug(`getTranslations: Requested language ${lang} not found. ${safe.error.message}`);
|
||||
const translations = safe.JSON.parse(translationData) || {};
|
||||
|
||||
return { translations: translations || {}, fallback: fallback || {} };
|
||||
return { translations, fallback };
|
||||
}
|
||||
|
||||
async function listLanguages() {
|
||||
const [error, result] = await safe(fs.promises.readdir(TRANSLATION_FOLDER));
|
||||
const [error, result] = await safe(fs.promises.readdir(paths.TRANSLATIONS_DIR));
|
||||
if (error) {
|
||||
debug('listLanguages: Failed to list translations. %o', error);
|
||||
debug(`listLanguages: Failed to list translations. %${error.message}`);
|
||||
return [ 'en' ]; // we always return english to avoid dashboard breakage
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ async function checkBoxUpdates(options) {
|
||||
debug(`checkBoxUpdates: ${updateInfo.version} is available. renotification: ${!!state.box}`);
|
||||
|
||||
const changelog = updateInfo.changelog.map((m) => `* ${m}\n`).join('');
|
||||
const message = `Changelog:\n${changelog}\n\nGo to the settings view to update.\n\n`;
|
||||
const message = `Changelog:\n${changelog}\n\nGo to the Settings view to update.\n\n`;
|
||||
|
||||
await notifications.alert(notifications.ALERT_BOX_UPDATE, `Cloudron v${updateInfo.version} is available`, message, { persist: false });
|
||||
|
||||
|
||||
@@ -12,11 +12,33 @@ const debug = require('debug')('syslog:server'),
|
||||
net = require('net'),
|
||||
path = require('path'),
|
||||
paths = require('./src/paths.js'),
|
||||
parser = require('nsyslog-parser-2'),
|
||||
util = require('util');
|
||||
|
||||
let gServer = null;
|
||||
|
||||
// https://docs.docker.com/engine/logging/drivers/syslog/
|
||||
// example: <34>1 2023-09-07T14:33:22Z myhost myapp 1234 5678 [exampleSDID@32473 iut="3" eventSource="Application"] An example message
|
||||
function parseRFC5424Message(rawMessage) {
|
||||
const syslogRegex = /^<(\d+)>(\d+) (\S+) (\S+) (\S+) (\S+) (\S+) (?:\[(.*?)\])?(.*)$/s; // /s means .* will match newline
|
||||
|
||||
const match = rawMessage.match(syslogRegex);
|
||||
if (!match) return null;
|
||||
|
||||
const [, pri, version, timestamp, hostname, appName, procId, msgId, structuredData, message] = match;
|
||||
|
||||
return {
|
||||
pri: parseInt(pri, 10), // priority
|
||||
version: parseInt(version, 10), // version
|
||||
timestamp, // timestamp
|
||||
hostname, // hostname
|
||||
appName, // app name
|
||||
procId, // process ID
|
||||
msgId, // message ID
|
||||
structuredData: structuredData ? structuredData : null, // structured data (if present)
|
||||
message // message
|
||||
};
|
||||
}
|
||||
|
||||
async function start() {
|
||||
debug('==========================================');
|
||||
debug(' Cloudron Syslog Daemon ');
|
||||
@@ -29,33 +51,24 @@ async function start() {
|
||||
});
|
||||
|
||||
gServer.on('connection', function (socket) {
|
||||
socket.on('data', function (msg) {
|
||||
const lines = msg.toString().split('\n'); // may be multiline data
|
||||
socket.on('data', function (data) {
|
||||
const msg = data.toString('utf8');
|
||||
const info = parseRFC5424Message(msg);
|
||||
if (!info) return debug('Unable to parse:', msg);
|
||||
if (!info.appName) return debug('Ignore unknown app:', msg);
|
||||
|
||||
lines.forEach(function (msg) {
|
||||
if (!msg) return;
|
||||
const appLogDir = path.join(paths.LOG_DIR, info.appName);
|
||||
|
||||
const info = parser(msg);
|
||||
|
||||
if (!info || !info.appName) return debug('Ignore unknown app log:', msg);
|
||||
|
||||
// remove line breaks to avoid holes in the log file
|
||||
// we do not ignore empty log lines, to allow gaps for potential ease of readability
|
||||
const message = info.message.replace(/[\n\r]+/g, '');
|
||||
|
||||
const appLogDir = path.join(paths.LOG_DIR, info.appName);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(appLogDir, { recursive: true });
|
||||
fs.appendFileSync(`${appLogDir}/app.log`, info.ts.toISOString() + ' ' + message + '\n');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
try {
|
||||
fs.mkdirSync(appLogDir, { recursive: true });
|
||||
fs.appendFileSync(`${appLogDir}/app.log`, `${info.timestamp} ${info.message.trim()}\n`);
|
||||
} catch (error) {
|
||||
debug(error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', function (error) {
|
||||
console.error(`socket error: ${error}`);
|
||||
debug(`socket error: ${error}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM ubuntu:jammy-20230816@sha256:b492494d8e0113c4ad3fe4528a4b5ff89faa5331f7d52c5c138196f69ce176a6
|
||||
|
||||
RUN apt update && \
|
||||
apt install -y openssl mysql-client-8.0 sudo lsb-release vim gosu curl
|
||||
|
||||
RUN useradd --system --uid 808 --comment "Cloudron Box" --create-home --shell /usr/bin/bash yellowtent
|
||||
|
||||
RUN mkdir -p /mnt/cloudron-test-music /media/cloudron-test-music # volume test
|
||||
|
||||
# https://download.docker.com/linux/static/stable/x86_64/
|
||||
RUN cd /usr/bin && curl -L https://download.docker.com/linux/static/stable/x86_64/docker-25.0.5.tgz | tar -zxvf - --strip-components=1 docker/docker
|
||||
|
||||
COPY setup/start/sudoers /etc/sudoers.d/cloudron
|
||||
|
||||
COPY test/cloak /usr/bin/cloak
|
||||
RUN ln -s /usr/bin/cloak /usr/bin/systemd-run && \
|
||||
ln -s /usr/bin/cloak /usr/bin/systemctl
|
||||
|
||||
COPY test/entrypoint.sh /usr/bin/entrypoint.sh
|
||||
|
||||
WORKDIR /home/yellowtent
|
||||
USER yellowtent
|
||||
|
||||
RUN mkdir -p appsdata
|
||||
RUN mkdir -p boxdata/box boxdata/mail boxdata/certs boxdata/mail/dkim/localhost boxdata/mail/dkim/foobar.com
|
||||
RUN mkdir -p platformdata/addons/mail/banner platformdata/nginx/cert platformdata/nginx/applications/dashboard platformdata/collectd/collectd.conf.d platformdata/addons platformdata/logrotate.d platformdata/backup platformdata/logs/tasks platformdata/sftp/ssh platformdata/firewall platformdata/update
|
||||
|
||||
RUN bash -c 'openssl req -x509 -newkey rsa:2048 -keyout platformdata/nginx/cert/host.key -out platformdata/nginx/cert/host.cert -days 3650 -subj '/CN=localhost' -nodes -config <(cat /etc/ssl/openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:*.localhost"))'
|
||||
|
||||
WORKDIR /home/yellowtent/box
|
||||
|
||||
USER root
|
||||
ENTRYPOINT [ "/usr/bin/entrypoint.sh" ]
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eu
|
||||
|
||||
cmd=$(basename $BASH_SOURCE)
|
||||
|
||||
# echo $cmd "$@"
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eu
|
||||
|
||||
docker_gid=$(stat -c "%g" /run/docker.sock)
|
||||
addgroup --gid ${docker_gid} --system docker
|
||||
usermod -aG docker yellowtent
|
||||
|
||||
exec su yellowtent --command "$@"
|
||||
|
||||
Reference in New Issue
Block a user