diff --git a/CHANGES b/CHANGES index 55f0dddb1..f2e21ef34 100644 --- a/CHANGES +++ b/CHANGES @@ -2702,4 +2702,5 @@ * Cleanup backup validation mount point * dashboard: remove nginx config of old domain when domain changed * Show disk consumption of docker volumes for /run and /tmp of apps separately +* dns: add dnsimple automation diff --git a/dashboard/src/js/setupdns.js b/dashboard/src/js/setupdns.js index 995993175..2ecf91b8c 100644 --- a/dashboard/src/js/setupdns.js +++ b/dashboard/src/js/setupdns.js @@ -82,6 +82,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f { name: 'Bunny', value: 'bunny' }, { name: 'Cloudflare', value: 'cloudflare' }, { name: 'DigitalOcean', value: 'digitalocean' }, + { name: 'dnsimple', value: 'dnsimple' }, { name: 'Gandi LiveDNS', value: 'gandi' }, { name: 'GoDaddy', value: 'godaddy' }, { name: 'Google Cloud DNS', value: 'gcdns' }, @@ -112,6 +113,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f godaddyApiSecret: '', linodeToken: '', bunnyAccessKey: '', + dnsimpleAccessToken: '', hetznerToken: '', vultrToken: '', nameComUsername: '', @@ -204,6 +206,8 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f config.token = $scope.dnsCredentials.linodeToken; } else if (provider === 'bunny') { config.token = $scope.dnsCredentials.bunnyAccessKey; + } else if (provider === 'dnsimple') { + config.token = $scope.dnsCredentials.dnsimpleAccessToken; } else if (provider === 'hetzner') { config.token = $scope.dnsCredentials.hetznerToken; } else if (provider === 'vultr') { diff --git a/dashboard/src/setupdns.html b/dashboard/src/setupdns.html index faedfff38..dfe7c94b4 100644 --- a/dashboard/src/setupdns.html +++ b/dashboard/src/setupdns.html @@ -226,6 +226,12 @@

+ +

+ + +

+
diff --git a/dashboard/src/translation/en.json b/dashboard/src/translation/en.json index f31c90a52..5ebb637b1 100644 --- a/dashboard/src/translation/en.json +++ b/dashboard/src/translation/en.json @@ -1055,7 +1055,8 @@ "cloudflareDefaultProxyStatus": "Enable proxying for new DNS records", "porkbunApikey": "API Key", "porkbunSecretapikey": "Secret API Key", - "bunnyAccessKey": "Bunny Access Key" + "bunnyAccessKey": "Bunny Access Key", + "dnsimpleAccessToken": "Access Token" }, "removeDialog": { "title": "Really remove {{ domain }}?", diff --git a/dashboard/src/translation/es.json b/dashboard/src/translation/es.json index 537d6632b..ebb7fa6db 100644 --- a/dashboard/src/translation/es.json +++ b/dashboard/src/translation/es.json @@ -62,7 +62,7 @@ "switchToLoginAction": "¿Ya tienes una cuenta? Inicia sesión", "switchToSignUpAction": "¿No tienes una cuenta todavía? Regístrate", "createAccountAction": "Crear Cuenta", - "loginAction": "Iniciar sesión", + "loginAction": "Iniciar Sesión", "errorWrongPassword": "Contraseña errónea", "licenseCheckbox": "Acepto la licencia de Cloudron", "chooseAnOption": "Por favor escoge una opción…", @@ -97,7 +97,8 @@ }, "action": { "logs": "Registros", - "reboot": "Reiniciar" + "reboot": "Reiniciar", + "showLogs": "Mostrar registros" }, "pagination": { "perPageSelector": "Mostrar {{ n }} por página", @@ -141,7 +142,8 @@ "statusEnabled": "Habilitado", "statusDisabled": "Deshabilitado", "loadingPlaceholder": "Cargando", - "settings": "Ajustes" + "settings": "Ajustes", + "saveAction": "Guardar" }, "apps": { "domainsFilterHeader": "Todos los Dominios", @@ -166,7 +168,8 @@ "auth": { "nosso": "Inicia sesión con una cuenta dedicada", "sso": "Inicia sesión con las credenciales de Cloudron", - "email": "Inicia sesión con el correo electrónico" + "email": "Inicia sesión con el correo electrónico", + "openid": "Iniciar sesión con Cloudron OpenID" }, "addAppAction": "Añadir Aplicación", "addAppproxyAction": "Añadir Proxi de la Aplicación", @@ -218,7 +221,7 @@ "subscriptionRequired": "Estas características solo están habilitadas para planes de pago.", "require2FACheckbox": "Requerir que los usuarios configuren 2FA", "allowProfileEditCheckbox": "Permitir a los usuarios editar su nombre y correo", - "title": "Ajustes", + "title": "Ajustes de usuario", "require2FAWarning": "Configura primero 2FA para tu cuenta para evitar que la bloqueen." }, "groups": { @@ -521,7 +524,8 @@ "preserved": { "description": "Copia de seguridad persistente independientemente de la política de retención", "tooltip": "Esto también conservará el correo y las copias de seguridad de la aplicación {{ appsLength }}." - } + }, + "remotePath": "Ruta remota" } }, "profile": { @@ -611,7 +615,7 @@ "errorPasswordsDontMatch": "Las contraseñas no coinciden", "errorPasswordRequired": "Se requiere una contraseña", "newPasswordRepeat": "Repite nueva contraseña", - "newPassword": "Nueva contraseña", + "newPassword": "Nueva Contraseña", "currentPassword": "Contraseña actual", "title": "Cambia tu contraseña" }, @@ -632,7 +636,8 @@ }, "changeBackgroundImage": { "title": "Establecer imagen de fondo" - } + }, + "enable2FANotAvailable": "No disponible para usuarios de una fuente de autentificación externa" }, "emails": { "eventlog": { @@ -679,7 +684,8 @@ "info": "Esta configuración es global y se aplica a todos los dominios.", "title": "Ajustes", "acl": "Correo ACL", - "aclOverview": "{{ dnsblZonesCount }} zona(s) DNSBL" + "aclOverview": "{{ dnsblZonesCount }} zona(s) DNSBL", + "virtualAllMail": "Carpeta \"Todos los correos\"" }, "domains": { "testEmailTooltip": "Enviar Email de prueba", @@ -722,7 +728,7 @@ "manualInfo": "Agrega un registro A manualmente para el {{dominio}} a la IP pública de este Cloudron", "locationPlaceholder": "Dejar vacío para usar el dominio desnudo", "location": "Ubicación", - "description": "Cloudron realizará los cambios de DNS necesarios en todos los dominios y reiniciará el servidor de correo. Los clientes de correo electrónico de escritorio y móviles deben reconfigurarse para usar esta nueva ubicación como servidor IMAP y SMTP.", + "description": "Esto moverá el servidor IMAP y SMTP a la ubicación especificada.", "title": "Cambiar ubicación del Servidor de Correo" }, "aclDialog": { @@ -750,6 +756,10 @@ }, "action": { "queue": "Cola" + }, + "changeVirtualAllMailDialog": { + "title": "Carpeta \"Todos los correos\"", + "description": "La carpeta \"Todos los correos\" es una carpeta única que contiene todos los correos electrónicos de su bandeja de entrada. La carpeta puede resultar útil en clientes de correo que no admiten la búsqueda recursiva de carpetas." } }, "branding": { @@ -794,7 +804,8 @@ }, "dyndns": { "description": "Habilite esta opción para mantener todos sus registros DNS sincronizados con una dirección IP cambiante. Esto es útil cuando Cloudron se ejecuta en una red con una dirección IP pública que cambia con frecuencia, como una conexión doméstica.", - "title": "DNS Dinámico" + "title": "DNS Dinámico", + "showLogsAction": "Mostrar registros" }, "ipv4": { "address": "Dirección IPv4" @@ -806,7 +817,13 @@ }, "configureIpv6": { "title": "Configurar Proveedor de IPv6" - } + }, + "trustedIps": { + "summary": "{{ trustCount }} IPs confiables", + "description": "Se confiará en los encabezados HTTP de direcciones IP coincidentes", + "title": "Configurar IP confiables" + }, + "trustedIpRanges": "Rangos e IPs confiables " }, "services": { "configure": { @@ -826,7 +843,7 @@ "service": "Servicio", "description": "Los servicios de Cloudron implementan funcionalidades como bases de datos, correo electrónico y autentificación.", "title": "Servicios", - "refresh": "Actualizar" + "refresh": "Refrescar" }, "settings": { "appstoreAccount": { @@ -905,7 +922,7 @@ "domains": { "title": "Dominios y Certificados", "changeDashboardDomain": { - "description": "Esto moverá el Panel y el Servidor de Correo al subdominio my del dominio seleccionado.", + "description": "Esto moverá el panel al subdominio my del dominio seleccionado.", "showLogsAction": "Mostrar Registros", "cancelAction": "Cancelar", "changeAction": "Cambiar Dominio", @@ -1047,7 +1064,8 @@ "dataDirPlaceholder": "Dejar vacío para usar la plataforma predeterminada", "description": "Si el servidor se está quedando sin espacio en disco, usa esto para mover los datos de la aplicación a un volumen. Cualquier dato aquí es parte de la copia de seguridad de la aplicación.", "moveAction": "Mover datos", - "diskUsage": "Actualmente, la aplicación está usando {{ size }} de almacenamiento (hasta el {{ date }})." + "diskUsage": "Actualmente, la aplicación está usando {{ size }} de almacenamiento (hasta el {{ date }}).", + "mountTypeWarning": "El sistema de archivos de destino debe admitir permisos y propiedad de los archivos para que el traslado funcione" } }, "logsActionTooltip": "Registros", @@ -1321,6 +1339,17 @@ "label": "Etiqueta", "clearIconAction": "Borrar icono", "clearIconDescription": "Esto intentará obtener el favicon de la aplicación al guardar." + }, + "servicesTabTitle": "Servicios", + "turn": { + "title": "Configuración de TURN", + "enable": "Configura la aplicación para utilizar el servidor TURN integrado", + "disable": "No configures los ajustes de la aplicación TURN. Su configuración se deja como está. Puedes hacer los ajustes dentro de la aplicación." + }, + "redis": { + "title": "Configuración de Redis", + "enable": "Configura la aplicación para usar Redis", + "disable": "Deshabilitar Redis" } }, "lang": { @@ -1389,7 +1418,8 @@ "sshCheckbox": "Permitir que los ingenieros de soporte se conecten a este servidor a través de SSH", "emailPlaceholder": "Si es necesario, proporciona una dirección de correo electrónico diferente de la anterior para contactarte", "emailVerifyAction": "Verificar ahora", - "emailNotVerified": "El correo electrónico de su cuenta cloudron.io {{email}} no está verificado. Verifíquelo para abrir tickets de soporte." + "emailNotVerified": "El correo electrónico de su cuenta cloudron.io {{email}} no está verificado. Verifíquelo para abrir tickets de soporte.", + "typeBilling": "Problema de facturación" }, "title": "Soporte" }, @@ -1428,7 +1458,11 @@ "title": "Actualizar Volumen {{ volume }}" }, "tooltipEdit": "Editar Volumen", - "remountActionTooltip": "Volver a montar Volumen" + "remountActionTooltip": "Volver a montar Volumen", + "editVolumeDialog": { + "title": "Editar volumen {{ name }}" + }, + "editActionTooltip": "Editar Volumen" }, "eventlog": { "filterAllEvents": "Todos los Eventos", @@ -1507,7 +1541,8 @@ "copy": "Copiar", "paste": "Pegar", "selectAll": "Seleccionar todo", - "download": "Descargar" + "download": "Descargar", + "open": "Abrir" }, "mtime": "Modificado" }, @@ -1522,12 +1557,26 @@ }, "extract": { "error": "La extracción falló: {{ message }}" - } + }, + "extractionInProgress": "Extracción en progreso", + "uploader": { + "exitWarning": "Subida en progreso... ¿quieres realmente cerrar esta página?", + "uploading": "Subiendo" + }, + "textEditor": { + "undo": "Deshacer", + "redo": "Rehacer", + "save": "Guardar" + }, + "pasteInProgress": "Pegado en progreso", + "deleteInProgress": "Borrado en progreso" }, "logs": { "download": "Descarga los Registros Completos", "clear": "Borrar Vista", - "title": "Registros" + "title": "Registros", + "notFoundError": "No existe esa tarea o aplicación", + "logsGoneError": "Archivo(s) de registro no encontrados" }, "email": { "signature": { @@ -1763,7 +1812,7 @@ "newPassword": { "errorLength": "La contraseña debe tener al menos 8 y un máximo de 265 caracteres", "title": "Establecer nueva contraseña", - "password": "Nueva contraseña", + "password": "Nueva Contraseña", "passwordRepeat": "Repetir Contraseña", "errorMismatch": "Las contraseñas no coinciden" }, @@ -1823,7 +1872,7 @@ "username": "Nombre de usuario", "password": "Contraseña", "2faToken": "Token 2FA (si está habilitado)", - "signInAction": "Iniciar sesión", + "signInAction": "Iniciar Sesión", "resetPasswordAction": "Resetear contraseña", "errorIncorrect2FAToken": "El token 2FA es inválido", "errorInternal": "Error interno, prueba de nuevo más tarde" @@ -1879,5 +1928,6 @@ "newClient": "Nuevo cliente", "empty": "No hay clientes aún" } - } + }, + "automation": "Automatización" } diff --git a/dashboard/src/translation/nl.json b/dashboard/src/translation/nl.json index 68e1d46e0..ae462bbef 100644 --- a/dashboard/src/translation/nl.json +++ b/dashboard/src/translation/nl.json @@ -22,7 +22,8 @@ "auth": { "nosso": "Log in met specifiek account", "sso": "Log in met Cloudron aanmeldgegevens", - "email": "Log in met e-mailadres" + "email": "Log in met e-mailadres", + "openid": "Log in met Cloudron OpenID" }, "addAppAction": "App toevoegen", "addAppproxyAction": "App Proxy toevoegen", @@ -1812,7 +1813,11 @@ "mountStatus": "Koppel status", "localDirectory": "Lokale map", "type": "Type", - "remountActionTooltip": "Her-koppel Volume" + "remountActionTooltip": "Her-koppel Volume", + "editVolumeDialog": { + "title": "Bewerk volume {{ name }}" + }, + "editActionTooltip": "Bewerk Volume" }, "lang": { "it": "Italiaans", diff --git a/dashboard/src/translation/ru.json b/dashboard/src/translation/ru.json index 19b78d99d..5a7a08a63 100644 --- a/dashboard/src/translation/ru.json +++ b/dashboard/src/translation/ru.json @@ -8,7 +8,8 @@ "auth": { "sso": "Войдите, используя учётную запись Cloudron", "email": "Войдите, используя email", - "nosso": "Войдите, используя Вашу учётную запись" + "nosso": "Войдите, используя Вашу учётную запись", + "openid": "Войти с помощью Cloudron OpenID" }, "noAccess": { "description": "После открытия доступа приложения отобразятся здесь.", @@ -234,7 +235,7 @@ "groupBaseDn": "Групповой корневой элемент", "groupFilter": "Фильтр группы", "groupnameField": "Поле с именем группы", - "auth": "Войти", + "auth": "Авторизоваться", "autocreateUsersOnLogin": "Автоматически создавать пользователей после их входа в Cloudron", "showLogsAction": "Показать логи", "syncAction": "Синхронизировать", @@ -294,7 +295,7 @@ "description": "Ссылка для сброса пароля отправлена на электронную почту {{ email }}:", "sendEmailLinkAction": "Отправить ссылку пользователю по электронной почте", "emailSent": "Отправлено", - "newLinkAction": "Отправить ссылку для сброса пароля", + "newLinkAction": "Отправить ссылку для сброса", "reset2FAAction": "Сбросить 2FA", "sendAction": "Отправить письмо", "descriptionLink": "Скопировать ссылку для сброса пароля", @@ -409,7 +410,7 @@ "changePassword": { "currentPassword": "Текущий пароль", "errorPasswordInvalid": "Пароль должен быть не менее 8 и не более 265 символов", - "title": "Изменить пароль", + "title": "Изменить ваш пароль", "newPassword": "Новый пароль", "newPasswordRepeat": "Повторите новый пароль", "errorPasswordRequired": "Требуется пароль", @@ -976,7 +977,8 @@ "preserved": { "description": "Хранить резервную копию, игнорируя политику хранения", "tooltip": "Также будет сохранена почта и {{ appsLength } резервных копий." - } + }, + "remotePath": "Удаленный путь" } }, "branding": { @@ -1018,7 +1020,8 @@ "acl": "Почтовый ACL (Access Control List)", "maxMailSize": "Максимальный размер письма", "solrFts": "Полный поиск по тексту (Solr)", - "aclOverview": "{{ dnsblZonesCount }} DNSBL зон" + "aclOverview": "{{ dnsblZonesCount }} DNSBL зон", + "virtualAllMail": "Папка \"Вся почта\"" }, "eventlog": { "title": "Журнал событий электронной почты", @@ -1109,6 +1112,10 @@ }, "action": { "queue": "Очередь" + }, + "changeVirtualAllMailDialog": { + "title": "Папка \"Вся почта\"", + "description": "Папка \"Вся почта\" содержит все электронные письма из вашего почтового ящика. Данная папка может быть полезна в том случае, когда ваш почтовый клиент не поддерживает рекурсивный поиск по папкам." } }, "network": { @@ -1806,7 +1813,11 @@ "title": "Тома", "hostPath": "Назначение", "description": "Тома - локальные или удаленные файловые системы. Они могут быть использованы для хранения данных приложений или для создания общей директории для нескольких приложений.", - "localDirectory": "Локальный каталог" + "localDirectory": "Локальный каталог", + "editVolumeDialog": { + "title": "Редактирование тома {{ name }}" + }, + "editActionTooltip": "Редактировать том" }, "lang": { "en": "Английский", diff --git a/dashboard/src/translation/vi.json b/dashboard/src/translation/vi.json index af385bf1f..0d256f5e6 100644 --- a/dashboard/src/translation/vi.json +++ b/dashboard/src/translation/vi.json @@ -18,12 +18,18 @@ "title": "Chưa có app cài đặt!", "description": "Cài đặt một vài app nhé? Hãy xem trong Cửa hàng App" }, - "groupsFilterHeader": "Chọn nhóm", + "groupsFilterHeader": "Tất cả Nhóm", "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" - } + }, + "addAppAction": "Thêm App", + "addApplinkAction": "Thêm đường link App", + "filter": { + "clearAll": "Xoá tất cả" + }, + "addAppproxyAction": "Thêm proxy cho app" }, "main": { "logout": "Thoát", @@ -32,7 +38,8 @@ "save": "Lưu", "close": "Đóng", "no": "Không", - "yes": "Có" + "yes": "Có", + "delete": "Xoá" }, "username": "Tên đăng nhập", "displayName": "Tên hiển thị", @@ -42,7 +49,8 @@ "pagination": { "prev": "trước", "next": "tiếp", - "perPageSelector": "Hiển thị {{ n }} trên một trang" + "perPageSelector": "Hiển thị {{ n }} trên một trang", + "itemCount": "Đã tìm thấy {{ count }}" }, "action": { "reboot": "Khởi động lại", @@ -79,7 +87,9 @@ "users": "Người dùng" }, "enableAction": "Bật", - "disableAction": "Tắt" + "disableAction": "Tắt", + "loadingPlaceholder": "Đang tải", + "settings": "Cài đặt" }, "appstore": { "title": "Cửa hàng App", @@ -134,7 +144,8 @@ "configuredForCloudronEmail": "App này đã được cấu hình sẵn để sử dụng với Cloudron Email.", "doInstallAction": "Tải về {{ dnsOverwrite ? 'and overwrite DNS' : '' }}", "cloudflarePortWarning": "Cần tắt proxy Cloudflare để tên miền app này có thể truy cập được vào cổng", - "titleAndVersion": "App này đóng gói phần mềm {{ title }} {{ version }}" + "titleAndVersion": "App này đóng gói phần mềm {{ title }} {{ version }}", + "portReadOnly": "chỉ-đọc" }, "appNotFoundDialog": { "title": "Không tìm thấy app", @@ -256,7 +267,7 @@ "subscriptionRequired": "Chức năng này chỉ có trong gói trả phí.", "require2FACheckbox": "Yêu cầu người dùng cài đặt Mã xác minh 2 bước", "allowProfileEditCheckbox": "Cho phép người dùng chỉnh sửa tên và email", - "title": "Cài đặt", + "title": "Cài đặt Người dùng", "require2FAWarning": "Hãy cài đặt Mã xác minh 2 Bước cho tài khoản của bạn trước đề phòng bị khoá ra khỏi TK." }, "groups": { @@ -328,8 +339,9 @@ "label": "Giới hạn quyền truy cập" }, "secret": { - "label": "Mã bí mật", - "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 {{ userDN }}" + "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 {{ userDN }}", + "url": "URL máy chủ" } }, "userImportDialog": { @@ -435,7 +447,8 @@ "description": "Mã API mới:", "copyNow": "Xin copy mã API này bây giờ. Nó sẽ không được hiển thị lại vì lý do an ninh.", "generateToken": "Tạo mã API", - "name": "Tên cho mã API" + "name": "Tên cho mã API", + "access": "Truy cập API" }, "enable2FAAction": "Bật xác minh hai bước", "primaryEmail": "Email chính", @@ -458,7 +471,10 @@ "name": "Tên", "expiresAt": "Hết hiệu lực vào", "lastUsed": "Lần dùng cuối", - "neverUsed": "chưa từng dùng" + "neverUsed": "chưa từng dùng", + "readonly": "Chỉ đọc", + "scope": "Mức độ bao phủ", + "readwrite": "Đọc và Ghi" }, "loginTokens": { "title": "Mã đăng nhập", @@ -540,7 +556,7 @@ "mountPoint": "Điểm mount", "noopNote": "Lựa chọn này sẽ làm hỏng tính năng sao lưu và khôi phục của Cloudron và chỉ nên dùng khi test hệ thống. Xin đảm bảo rằng server được sao lưu toàn bộ bằng những phương tiện khác.", "format": "Định dạng lưu trữ", - "encryptedFilenames": "Mã hoá tên tập tin", + "encryptedFilenames": "Tên tập tin đã mã hoá", "chown": "Hệ thống tập tin bên ngoài có hỗ trợ chown", "username": "Tên đăng nhập", "server": "IP hoặc hostname máy chủ", @@ -552,7 +568,8 @@ "user": "Người dùng", "privateKey": "Mật mã riêng", "diskPath": "Đường dẫn đến ổ đĩa", - "cifsSealSupport": "Dùng mã hoá SEAL. Cần SMB thấp nhất là phiên bản v3" + "cifsSealSupport": "Dùng mã hoá SEAL. Cần SMB thấp nhất là phiên bản v3", + "encryptFilenames": "Mã hoá tên tập tin" }, "cleanupBackups": { "description": "Các bản sao lưu được dọn sạch tự động dựa trên thời gian lưu giữ. Thao tác này sẽ xoá ngay lập tức các bản sao lưu đang có.", @@ -879,7 +896,13 @@ }, "configureIpv6": { "title": "Cài đặt nhà cung cấp IPv6" - } + }, + "trustedIps": { + "summary": "{{ trustCount }} địa chỉ IP được tin tưởng", + "description": "Những HTTP header từ những địa chỉ IP trùng khớp sẽ được chấp thuận cho qua", + "title": "Thiết lập những địa chỉ IP đáng tin cậy" + }, + "trustedIpRanges": "Địa chỉ IP & Vùng được tin cậy " }, "emails": { "typeFilterHeader": "Tất cả sự kiện", @@ -914,7 +937,7 @@ "locationPlaceholder": "Để trống để dùng tên miền gốc", "location": "Vị trí", "title": "Thay đổi vị trí đặt mail server", - "description": "Cloudron sẽ thay đổi những giá trị DNS cần thiết cho tất cả tên miền và khởi động lại mail server. Những client nhận mail trên máy tính hay điện thoại cần được cài đặt lại để sử dụng vị trí mới này làm IMAP và SMTP server." + "description": "Hành động này sẽ di chuyển server IMAP và SMTP đến vị trí được xác định." }, "eventlog": { "searchPlaceholder": "Tìm kiếm", @@ -933,7 +956,10 @@ "queued": "Xếp hàng", "outgoing": "Gửi mail ra", "incoming": "Nhận mail vào", - "deferred": "Trì hoãn lại" + "deferred": "Trì hoãn lại", + "overQuotaInfo": "Hộp thư {{ mailbox }} đã đầy {{ quotaPercent }}%", + "underQuotaInfo": "Hộp thư {{ mailbox }} đã rơi xuống còn {{ quotaPercent }}% của hạn mức", + "quota": "Hạn mức hộp thư" }, "empty": "Log sự kiện hiện đang trống.", "details": "Chi tiết", @@ -950,8 +976,8 @@ "solrEnabled": "Đã bật", "solrDisabled": "Đã tắt", "changeDomainProgress": "Thay đổi tên miền email:", - "spamFilterOverview": "{{ blacklistCount }} email có trong danh sách đen.", - "location": "Nơi đặt mail server", + "spamFilterOverview": "{{ blacklistCount }} email có trong danh sách bị chặn.", + "location": "Nơi đặt máy chủ mail", "spamFilter": "Lọc spam", "maxMailSize": "Kích cỡ mail tối đa", "info": "Các cài đặt này áp dụng cho tất cả các tên miền.", @@ -981,6 +1007,19 @@ "dnsblZonesInfo": "Địa chỉ IP đang muốn kết nối đến được dò tìm trong những danh sách IP bị chặn này", "dnsblZonesPlaceholder": "Tên vùng (ghi xuống dòng)", "title": "Đổi danh sách quản lý truy cập mail" + }, + "queue": { + "empty": "Danh sách mail chờ đang trống", + "title": "Danh sách mail chờ gửi", + "rcptTo": "Gửi cho", + "mailFrom": "Đến từ", + "details": "Chi tiết", + "discardTooltip": "Bỏ qua", + "queueTime": "Thời gian chờ", + "resendTooltip": "Gửi lại ngay" + }, + "action": { + "queue": "Cho vào hàng chờ gửi sau" } }, "branding": { @@ -1009,10 +1048,11 @@ "selectPeriodLabel": "Chọn khoảng thời gian", "cpuUsage": { "graphTitle": "Phần trăm sử dụng", - "title": "Dung lượng CPU" + "title": "Dung lượng CPU", + "graphSubtext": "Chỉ những app sử dụng hơn {{ threshold }} cpu mới được hiển thị" }, "systemMemory": { - "graphSubtext": "Các giá trị bộ nhớ riêng từng app không hiển thị chồng lên nhau", + "graphSubtext": "Chỉ những app sử dụng hơn {{ threshold }} bộ nhớ mới được hiển thị", "title": "Bộ nhớ hệ thống" }, "diskUsage": { @@ -1020,7 +1060,11 @@ "diskContent": "Ổ đĩa {{ type }} này hiện chứa", "usageInfo": "Còn {{ available | prettyDiskSize }} trống trong tổng {{ size | prettyDiskSize }}", "mountedAt": "{{ filesystem }} được gắn ở {{ mountpoint }}", - "title": "Dung lượng ổ đĩa" + "title": "Dung lượng ổ đĩa", + "usedInfo": "{{ used }} đã dùng trong tổng {{ size }}", + "volumeContent": "Ổ đĩa này thuộc volume {{ name }}", + "uninstalledApp": "App đã xoá", + "diskSpeed": "Tốc độ: {{ speed }} MB/s" }, "title": "Hệ thống" }, @@ -1265,7 +1309,9 @@ "logs": { "download": "Tải xuống tất cả log", "clear": "Làm sạch phần xem log", - "title": "Log" + "title": "Log", + "notFoundError": "Không có tác vụ hay app đó", + "logsGoneError": "Tập tin log không được tìm thấy" }, "notifications": { "clearAll": "Xoá hết", @@ -1323,7 +1369,11 @@ "wellKnownDescription": "Những giá trị nhập vào này sẽ được dùng bởi Cloudron để phản hồi về những đường link /.well-known/. Lưu ý rằng một app cần được đang chạy cài đặt sẵn trên tên miền gốc {{ domain }} để tính năng này có thể hoạt động được. Xem phần hướng dẫn sử dụng để biết thêm thông tin.", "vultrToken": "Mật mã Vultr", "jitsiHostname": "Vị trí Jitsi", - "hetznerToken": "Mật mã Hetzner" + "hetznerToken": "Mật mã Hetzner", + "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" }, "subscriptionRequired": { "description": "Để thêm tên miền, hãy đăng ký gói trả phí.", @@ -1358,7 +1408,8 @@ "domainWellKnown": { "title": "Những vị trí Well-Known của {{ domain }}" }, - "tooltipWellKnown": "Cài đặt những vị trí Well-Known" + "tooltipWellKnown": "Cài đặt những vị trí Well-Known", + "count": "Tổng số tên miền: {{ count }}" }, "app": { "appInfo": { diff --git a/dashboard/src/views/domains.html b/dashboard/src/views/domains.html index 77318c1c2..ccc61128a 100644 --- a/dashboard/src/views/domains.html +++ b/dashboard/src/views/domains.html @@ -146,6 +146,12 @@
+ +
+ + +
+
diff --git a/dashboard/src/views/domains.js b/dashboard/src/views/domains.js index 856dc4682..0b661b255 100644 --- a/dashboard/src/views/domains.js +++ b/dashboard/src/views/domains.js @@ -47,6 +47,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat { name: 'Bunny', value: 'bunny' }, { name: 'Cloudflare', value: 'cloudflare' }, { name: 'DigitalOcean', value: 'digitalocean' }, + { name: 'dnsimple', value: 'dnsimple' }, { name: 'Gandi LiveDNS', value: 'gandi' }, { name: 'GoDaddy', value: 'godaddy' }, { name: 'Google Cloud DNS', value: 'gcdns' }, @@ -68,6 +69,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat case 'route53': return 'AWS Route53'; case 'cloudflare': return 'Cloudflare'; case 'digitalocean': return 'DigitalOcean'; + case 'dnsimple': return 'dnsimple'; case 'gandi': return 'Gandi LiveDNS'; case 'hetzner': return 'Hetzner DNS'; case 'linode': return 'Linode'; @@ -249,6 +251,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat cloudflareTokenType: 'GlobalApiKey', linodeToken: '', bunnyAccessKey: '', + dnsimpleAccessToken: '', hetznerToken: '', vultrToken: '', nameComToken: '', @@ -307,6 +310,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat $scope.domainConfigure.digitalOceanToken = domain.provider === 'digitalocean' ? domain.config.token : ''; $scope.domainConfigure.linodeToken = domain.provider === 'linode' ? domain.config.token : ''; $scope.domainConfigure.bunnyAccessKey = domain.provider === 'bunny' ? domain.config.accessKey : ''; + $scope.domainConfigure.dnsimpleAccessToken = domain.provider === 'dnsimple' ? domain.config.accessToken : ''; $scope.domainConfigure.hetznerToken = domain.provider === 'hetzner' ? domain.config.token : ''; $scope.domainConfigure.vultrToken = domain.provider === 'vultr' ? domain.config.token : ''; $scope.domainConfigure.gandiApiKey = domain.provider === 'gandi' ? domain.config.token : ''; @@ -379,6 +383,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat data.token = $scope.domainConfigure.linodeToken; } else if (provider === 'bunny') { data.accessKey = $scope.domainConfigure.bunnyAccessKey; + } else if (provider === 'dnsimple') { + data.accessToken = $scope.domainConfigure.dnsimpleAccessToken; } else if (provider === 'hetzner') { data.token = $scope.domainConfigure.hetznerToken; } else if (provider === 'vultr') { diff --git a/src/dns.js b/src/dns.js index 816f5acc1..a45040526 100644 --- a/src/dns.js +++ b/src/dns.js @@ -46,6 +46,7 @@ function api(provider) { switch (provider) { case 'bunny': return require('./dns/bunny.js'); case 'cloudflare': return require('./dns/cloudflare.js'); + case 'dnsimple': return require('./dns/dnsimple.js'); case 'route53': return require('./dns/route53.js'); case 'gcdns': return require('./dns/gcdns.js'); case 'digitalocean': return require('./dns/digitalocean.js'); diff --git a/src/dns/dnsimple.js b/src/dns/dnsimple.js new file mode 100644 index 000000000..30ac04e5b --- /dev/null +++ b/src/dns/dnsimple.js @@ -0,0 +1,263 @@ +'use strict'; + +exports = module.exports = { + removePrivateFields, + injectPrivateFields, + upsert, + get, + del, + wait, + verifyDomainConfig +}; + +const assert = require('assert'), + BoxError = require('../boxerror.js'), + constants = require('../constants.js'), + debug = require('debug')('box:dns/dnsimple'), + dig = require('../dig.js'), + dns = require('../dns.js'), + safe = require('safetydance'), + superagent = require('superagent'), + waitForDns = require('./waitfordns.js'); + +const DNSIMPLE_API = 'https://api.dnsimple.com/v2'; + +function formatError(response) { + return `dnsimple DNS error ${response.statusCode} ${JSON.stringify(response.body)}`; +} + +function removePrivateFields(domainObject) { + domainObject.config.accessToken = constants.SECRET_PLACEHOLDER; + return domainObject; +} + +function injectPrivateFields(newConfig, currentConfig) { + if (newConfig.accessToken === constants.SECRET_PLACEHOLDER) newConfig.accessToken = currentConfig.accessToken; +} + +async function getAccountId(domainConfig) { + assert.strictEqual(typeof domainConfig, 'object'); + + const [error, response] = await safe(superagent.get(`${DNSIMPLE_API}/accounts`) + .set('Authorization', `Bearer ${domainConfig.accessToken}`) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + + const accountId = safe.query(response.body, 'data[0].id', null); + if (!accountId || typeof accountId !== 'number') throw new BoxError(BoxError.EXTERNAL_ERROR, `Could not determine account id: ${JSON.stringify(response.body)}`); + return String(accountId); +} + +async function getZone(domainConfig, zoneName) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + + const accountId = await getAccountId(domainConfig); + const [error, response] = await safe(superagent.get(`${DNSIMPLE_API}/${accountId}/zones?name_like=${zoneName}`) + .set('Authorization', `Bearer ${domainConfig.accessToken}`) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + + if (!Array.isArray(response.body.data)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid data in response: ${JSON.stringify(response.body)}`); + + const item = response.body.data.filter(item => item.name === zoneName); + if (item.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found'); + return { accountId, zoneId: item[0].id }; +} + +async function getDnsRecords(domainConfig, zoneName, name, type) { + assert.strictEqual(typeof domainConfig, 'object'); + assert.strictEqual(typeof zoneName, 'string'); + assert.strictEqual(typeof name, 'string'); + assert.strictEqual(typeof type, 'string'); + + debug(`get: ${name} in zone ${zoneName} of type ${type}`); + + const { accountId, zoneId } = await getZone(domainConfig, zoneName); + const [error, response] = await safe(superagent.get(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records?name=${name}&type=${type}`) + .set('Authorization', `Bearer ${domainConfig.accessToken}`) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + if (!Array.isArray(response.body.data)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid data in response: ${JSON.stringify(response.body)}`); + + return response.body.data; +} + +async function upsert(domainObject, location, type, values) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + assert(Array.isArray(values)); + + const domainConfig = domainObject.config, + zoneName = domainObject.zoneName, + name = dns.getName(domainObject, location, type) || ''; + + debug(`upsert: ${name} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`); + + const { accountId, zoneId } = await getZone(domainConfig, zoneName); + const records = await getDnsRecords(domainConfig, zoneName, name, type); + + // used to track available records to update instead of create + let i = 0, recordIds = []; + + for (let value of values) { + let priority = 0; + + if (type === 'MX') { + priority = parseInt(value.split(' ')[0], 10); + value = value.split(' ')[1]; + } else if (type === 'TXT') { + value = value.replace(/^"(.*)"$/, '$1'); // strip any double quotes + } + + const data = { + type, + name, + content: value, + priority, + ttl: 60 + }; + + if (i >= records.length) { + const [error, response] = await safe(superagent.post(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records`) + .set('Authorization', `Bearer ${domainConfig.accessToken}`) + .send(data) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + recordIds.push(safe.query(response.body, 'data.id')); + } else { + const [error, response] = await safe(superagent.patch(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records/${records[i].id}`) + .set('Authorization', `Bearer ${domainConfig.accessToken}`) + .send(data) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + ++i; + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + recordIds.push(safe.query(response.body, 'data.id')); + } + } + + for (let j = values.length + 1; j < records.length; j++) { + const [error] = await safe(superagent.del(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records/${records[i].id}`) + .set('Authorization', `Bearer ${domainConfig.accessToken}`) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + + if (error) debug(`upsert: error removing record ${records[j].id}: ${error.message}`); + } + + debug('upsert: completed with recordIds:%j', recordIds); +} + +async function get(domainObject, location, type) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + + const domainConfig = domainObject.config, + zoneName = domainObject.zoneName, + name = dns.getName(domainObject, location, type) || ''; + + const records = await getDnsRecords(domainConfig, zoneName, name, type); + return records.map(r => r.content); +} + +async function del(domainObject, location, type, values) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof type, 'string'); + assert(Array.isArray(values)); + + const domainConfig = domainObject.config, + zoneName = domainObject.zoneName, + name = dns.getName(domainObject, location, type) || ''; + + debug(`del: ${name} in zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`); + + const { accountId, zoneId } = await getZone(domainConfig, zoneName); + const records = await getDnsRecords(domainConfig, zoneName, name, type); + const ids = records.map(r => r.id); + + for (const id of ids) { + const [error, response] = await safe(superagent.del(`${DNSIMPLE_API}/${accountId}/zones/${zoneId}/records/${id}`) + .set('Authorization', `Bearer ${domainConfig.accessToken}`) + .retry(5) + .timeout(30 * 1000) + .ok(() => true)); + + if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); + if (response.statusCode === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response)); + if (response.statusCode === 400) continue; + if (response.statusCode !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response)); + } +} + +async function wait(domainObject, subdomain, type, value, options) { + assert.strictEqual(typeof domainObject, 'object'); + assert.strictEqual(typeof subdomain, 'string'); + assert.strictEqual(typeof type, 'string'); + assert.strictEqual(typeof value, 'string'); + assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 } + + const fqdn = dns.fqdn(subdomain, domainObject.domain); + + await waitForDns(fqdn, domainObject.zoneName, type, value, options); +} + +async function verifyDomainConfig(domainObject) { + assert.strictEqual(typeof domainObject, 'object'); + + const domainConfig = domainObject.config, + zoneName = domainObject.zoneName; + + if (!domainConfig.accessToken || typeof domainConfig.accessToken !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'accessToken must be a non-empty string'); + + const ip = '127.0.0.1'; + + const credentials = { + accessToken: domainConfig.accessToken, + }; + + if (constants.TEST) return credentials; // this shouldn't be here + + const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 })); + if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain'); + if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers'); + + if (!nameservers.every(function (n) { return n.toLowerCase().indexOf('dnsimple') !== -1; })) { // can be dnsimple.com or dnsimple-edge.org + debug('verifyDomainConfig: %j does not contain dnsimple NS', nameservers); + throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to dnsimple'); + } + + const location = 'cloudrontestdns'; + + await upsert(domainObject, location, 'A', [ ip ]); + debug('verifyDomainConfig: Test A record added'); + + await del(domainObject, location, 'A', [ ip ]); + debug('verifyDomainConfig: Test A record removed again'); + + return credentials; +} diff --git a/src/domains.js b/src/domains.js index ca6c8ee7f..a2d6dc2e9 100644 --- a/src/domains.js +++ b/src/domains.js @@ -54,6 +54,7 @@ function api(provider) { switch (provider) { case 'bunny': return require('./dns/bunny.js'); case 'cloudflare': return require('./dns/cloudflare.js'); + case 'dnsimple': return require('./dns/dnsimple.js'); case 'route53': return require('./dns/route53.js'); case 'gcdns': return require('./dns/gcdns.js'); case 'digitalocean': return require('./dns/digitalocean.js');