Merge pull request #122 from jhd3197/dev

The last four bundled flagships move out to their own repos
This commit is contained in:
Juan Denis authored and GitHub committed 2026-08-31 01:25:10 -04:00
commit 2ef2d775a0
116 files changed
+1109 -29109

No files matched your search

+8 -1
View File
@@ -195,16 +195,23 @@ backend/app/plugins/serverkit-email/
backend/app/plugins/serverkit-ftp/
backend/app/plugins/serverkit-gpu/
backend/app/plugins/serverkit-gui/
backend/app/plugins/serverkit-git/
backend/app/plugins/serverkit-localkit/
backend/app/plugins/serverkit-cloudflare-ops/
backend/app/plugins/serverkit-k8s/
backend/app/plugins/serverkit-mail/
backend/app/plugins/serverkit-remote-access/
backend/app/plugins/serverkit-status/
# serverkit-status is deliberately NOT ignored: it is the tracked live copy
# that keeps test_builtin_extension_drift non-vacuous on a fresh clone.
# (serverkit-email held this role until it left the tree in plan 52.)
backend/app/plugins/serverkit-tramo/
backend/app/plugins/serverkit-wordpress/
frontend/src/plugins/serverkit-analytics/
frontend/src/plugins/serverkit-crowdsec/
frontend/src/plugins/serverkit-dns-server/
frontend/src/plugins/serverkit-email/
frontend/src/plugins/serverkit-git/
frontend/src/plugins/serverkit-cloudflare-ops/
frontend/src/plugins/serverkit-gpu/
frontend/src/plugins/serverkit-gui/
frontend/src/plugins/serverkit-k8s/
+36
View File
@@ -215,6 +215,42 @@ authorization decision — `@jwt_required()` alone is never sufficient
When in doubt: admin-only for panel-wide resources, `@developer_required` for
operational actions, the file's own inline ownership helper for user data.
#### Do not sweep `@jwt_required()` + inline `is_admin` to rbac decorators
An existing route shaped like this **already satisfies default-deny** —
`test_route_authz_static.py` counts inspecting `.is_admin` / `.is_developer` as
an inline role check (`ATTRIBUTE_GATES`):
```python
@bp.route('/<int:page_id>', methods=['PUT'])
@jwt_required()
def update_page(page_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
```
It is also the majority shape: ~91 routes across 21 files (audited 2026-08-31).
Converting one to `@admin_required` is **not** a refactor. Every rbac decorator
authenticates through `auth_required()`, which accepts an `X-API-Key`, so the
conversion *grants API-key callers access to that route*. The decision recorded
when plan 76 closed (2026-08-19) still governs:
> JWT-only is the deliberate default. A route becomes API-key capable one at a
> time, when an API-key use case exists for it, in a commit that says so —
> never as a mechanical sweep.
`backend/tests/jwt_only_census.py` ratchets the population (`JWT_ONLY_CEILING`,
scanning `app/api` **and** the git-tracked `builtin-extensions/` sources): it
may shrink through deliberate conversions and may not grow, so a NEW route must pick a policy decorator
instead of bare `@jwt_required()`.
The rule above ("do not stack `@jwt_required()` outside a gate") is about not
having *both*. It is not an instruction to convert routes that have neither.
Automated reviewers suggest this conversion routinely — decline it unless the
commit can name the API-key use case.
## Key Environment Variables
| Variable | Purpose |
+22
View File
@@ -129,6 +129,28 @@ outside one. `backend/tests/test_route_authz_static.py` (coverage),
`test_route_authz_sweep.py` (enforcement) and `test_auth_decorator_boundaries.py`
(one boundary) all fail the build otherwise. See AGENTS.md for the allowlists.
**`@jwt_required()` + an inline `is_admin` check is a valid explicit decision —
do NOT sweep it to rbac decorators.** `test_route_authz_static.py` counts
inspecting `.is_admin` / `.is_developer` as an inline role check
(`ATTRIBUTE_GATES`), so those routes already satisfy default-deny. They are
also the majority: ~91 routes across 21 files use exactly that shape.
The reason it is not a free refactor: every rbac decorator authenticates via
`auth_required()`, which accepts an `X-API-Key` as well as a JWT. Converting a
route therefore **grants API-key callers access to it** — an expansion of the
authenticated surface, not a cleanup. The recorded decision (2026-08-19,
closing plan 76) stands:
> JWT-only is the deliberate default. A route becomes API-key capable one at a
> time, when an API-key use case exists for it, in a commit that says so —
> never as a mechanical sweep.
`backend/tests/jwt_only_census.py` ratchets the population (`JWT_ONLY_CEILING`)
over `app/api` **and** the git-tracked `builtin-extensions/` sources: it may
shrink as routes are deliberately converted, and may not grow — a NEW route picks a policy decorator rather than
bare `@jwt_required()`. Automated reviewers (Copilot) suggest this conversion
routinely; decline it unless the commit can name the API-key use case.
## Key Environment Variables
| Variable | Purpose |
+1 -1
View File
@@ -1 +1 @@
1.9.11
1.9.14
@@ -1 +0,0 @@
"""ServerKit Email extension backend package."""
@@ -1,271 +0,0 @@
"""OpenDKIM management service for DKIM signing."""
import os
import re
import subprocess
from typing import Dict
from app.utils.system import (PackageManager, ServiceControl, run_checked,
run_privileged)
class DKIMService:
"""Service for managing OpenDKIM (DKIM email signing)."""
OPENDKIM_CONF = '/etc/opendkim.conf'
OPENDKIM_DIR = '/etc/opendkim'
OPENDKIM_KEYS_DIR = '/etc/opendkim/keys'
KEY_TABLE = '/etc/opendkim/KeyTable'
SIGNING_TABLE = '/etc/opendkim/SigningTable'
TRUSTED_HOSTS = '/etc/opendkim/TrustedHosts'
OPENDKIM_CONF_CONTENT = """# OpenDKIM configuration - Managed by ServerKit
Syslog yes
SyslogSuccess yes
LogWhy yes
Canonicalization relaxed/simple
Mode sv
SubDomains no
AutoRestart yes
AutoRestartRate 10/1M
Background yes
DNSTimeout 5
SignatureAlgorithm rsa-sha256
KeyTable refile:/etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList /etc/opendkim/TrustedHosts
InternalHosts /etc/opendkim/TrustedHosts
Socket inet:8891@localhost
PidFile /run/opendkim/opendkim.pid
OversignHeaders From
UserID opendkim
UMask 007
"""
TRUSTED_HOSTS_DEFAULT = """# Trusted hosts - Managed by ServerKit
127.0.0.1
::1
localhost
"""
@classmethod
def get_status(cls) -> Dict:
"""Get OpenDKIM installation and running status."""
installed = False
running = False
enabled = False
version = None
try:
result = run_checked(['which', 'opendkim'], timeout=None)
installed = result['success']
if not installed:
installed = PackageManager.is_installed('opendkim')
if installed:
running = ServiceControl.is_active('opendkim')
enabled = ServiceControl.is_enabled('opendkim')
result = run_checked(['opendkim', '-V'], timeout=None, merge_stderr=True)
match = re.search(r'OpenDKIM\s+Filter\s+v(\S+)', result['output'])
if match:
version = match.group(1)
except (subprocess.SubprocessError, FileNotFoundError):
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
}
@classmethod
def install(cls) -> Dict:
"""Install OpenDKIM."""
try:
result = PackageManager.install(['opendkim', 'opendkim-tools'], timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install OpenDKIM'}
# Create directories
run_privileged(['mkdir', '-p', cls.OPENDKIM_KEYS_DIR])
run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR])
# Create config files
run_privileged(['tee', cls.OPENDKIM_CONF], input=cls.OPENDKIM_CONF_CONTENT)
run_privileged(['tee', cls.TRUSTED_HOSTS], input=cls.TRUSTED_HOSTS_DEFAULT)
run_privileged(['touch', cls.KEY_TABLE])
run_privileged(['touch', cls.SIGNING_TABLE])
# Set permissions
run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR])
run_privileged(['chmod', '700', cls.OPENDKIM_KEYS_DIR])
# Create PID directory
run_privileged(['mkdir', '-p', '/run/opendkim'])
run_privileged(['chown', 'opendkim:opendkim', '/run/opendkim'])
# Add postfix to opendkim group
run_privileged(['usermod', '-aG', 'opendkim', 'postfix'])
ServiceControl.enable('opendkim')
ServiceControl.start('opendkim', timeout=30)
return {'success': True, 'message': 'OpenDKIM installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def generate_key(cls, domain: str, selector: str = 'default') -> Dict:
"""Generate DKIM key pair for a domain."""
try:
key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain)
run_privileged(['mkdir', '-p', key_dir])
# Generate key
result = run_privileged([
'opendkim-genkey',
'-s', selector,
'-d', domain,
'-D', key_dir,
'-b', '2048',
])
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Key generation failed'}
# Set permissions
run_privileged(['chown', '-R', 'opendkim:opendkim', key_dir])
run_privileged(['chmod', '600', os.path.join(key_dir, f'{selector}.private')])
# Read the public key TXT record
txt_file = os.path.join(key_dir, f'{selector}.txt')
result = run_privileged(['cat', txt_file])
public_key_record = result.stdout.strip() if result.returncode == 0 else ''
# Extract just the key value from the TXT record
key_match = re.search(r'p=([A-Za-z0-9+/=\s]+)', public_key_record)
public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '') if key_match else ''
return {
'success': True,
'domain': domain,
'selector': selector,
'private_key_path': os.path.join(key_dir, f'{selector}.private'),
'public_key': public_key,
'dns_record': public_key_record,
'dns_name': f'{selector}._domainkey.{domain}',
'dns_value': f'v=DKIM1; k=rsa; p={public_key}',
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def add_domain(cls, domain: str, selector: str = 'default') -> Dict:
"""Add domain to KeyTable and SigningTable."""
try:
key_path = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.private')
# Add to KeyTable
key_entry = f'{selector}._domainkey.{domain} {domain}:{selector}:{key_path}\n'
result = run_privileged(['cat', cls.KEY_TABLE])
if domain not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.KEY_TABLE], input=key_entry)
# Add to SigningTable
signing_entry = f'*@{domain} {selector}._domainkey.{domain}\n'
result = run_privileged(['cat', cls.SIGNING_TABLE])
if domain not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.SIGNING_TABLE], input=signing_entry)
# Add to TrustedHosts
result = run_privileged(['cat', cls.TRUSTED_HOSTS])
if domain not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.TRUSTED_HOSTS], input=f'*.{domain}\n')
# Reload OpenDKIM
ServiceControl.restart('opendkim', timeout=30)
return {'success': True, 'message': f'Domain {domain} added to DKIM'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def remove_domain(cls, domain: str) -> Dict:
"""Remove domain from DKIM configuration."""
try:
# Remove from KeyTable
result = run_privileged(['cat', cls.KEY_TABLE])
lines = [l for l in (result.stdout or '').splitlines() if domain not in l]
run_privileged(['tee', cls.KEY_TABLE], input='\n'.join(lines) + '\n')
# Remove from SigningTable
result = run_privileged(['cat', cls.SIGNING_TABLE])
lines = [l for l in (result.stdout or '').splitlines() if domain not in l]
run_privileged(['tee', cls.SIGNING_TABLE], input='\n'.join(lines) + '\n')
# Remove from TrustedHosts
result = run_privileged(['cat', cls.TRUSTED_HOSTS])
lines = [l for l in (result.stdout or '').splitlines() if domain not in l]
run_privileged(['tee', cls.TRUSTED_HOSTS], input='\n'.join(lines) + '\n')
# Remove key files
key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain)
run_privileged(['rm', '-rf', key_dir])
ServiceControl.restart('opendkim', timeout=30)
return {'success': True, 'message': f'Domain {domain} removed from DKIM'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_dns_record(cls, domain: str, selector: str = 'default') -> Dict:
"""Get the DKIM DNS TXT record content for a domain."""
try:
txt_file = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.txt')
result = run_privileged(['cat', txt_file])
if result.returncode != 0:
return {'success': False, 'error': 'DKIM key not found'}
record = result.stdout.strip()
key_match = re.search(r'p=([A-Za-z0-9+/=\s"]+)', record)
public_key = ''
if key_match:
public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '')
return {
'success': True,
'dns_name': f'{selector}._domainkey.{domain}',
'dns_value': f'v=DKIM1; k=rsa; p={public_key}',
'raw_record': record,
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def verify_key(cls, domain: str, selector: str = 'default') -> Dict:
"""Verify DKIM key configuration."""
try:
result = run_privileged([
'opendkim-testkey',
'-d', domain,
'-s', selector,
'-vvv',
])
success = result.returncode == 0
output = (result.stdout or '') + (result.stderr or '')
return {
'success': success,
'verified': success and 'key OK' in output,
'output': output,
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload OpenDKIM."""
try:
result = ServiceControl.restart('opendkim', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'OpenDKIM restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -1,374 +0,0 @@
"""Dovecot IMAP/POP3 server management service."""
import os
import re
import subprocess
from typing import Dict, Optional
from app.utils.system import (PackageManager, ServiceControl, is_command_available,
run_privileged)
from app import paths
class DovecotService:
"""Service for managing Dovecot (IMAP/POP3 server)."""
DOVECOT_CONF_DIR = '/etc/dovecot'
DOVECOT_CONF = '/etc/dovecot/dovecot.conf'
DOVECOT_AUTH_CONF = '/etc/dovecot/conf.d/10-auth.conf'
DOVECOT_MAIL_CONF = '/etc/dovecot/conf.d/10-mail.conf'
DOVECOT_MASTER_CONF = '/etc/dovecot/conf.d/10-master.conf'
DOVECOT_SSL_CONF = '/etc/dovecot/conf.d/10-ssl.conf'
DOVECOT_PASSWD_FILE = '/etc/dovecot/users'
AUTH_PASSWDFILE_CONF = '/etc/dovecot/conf.d/auth-passwdfile.conf.ext'
MAIL_CONF_CONTENT = """# Dovecot mail configuration - Managed by ServerKit
mail_location = maildir:{vmail_dir}/%d/%n/Maildir
namespace inbox {{
inbox = yes
separator = /
}}
mail_uid = {vmail_uid}
mail_gid = {vmail_gid}
mail_privileged_group = vmail
first_valid_uid = {vmail_uid}
last_valid_uid = {vmail_uid}
"""
AUTH_CONF_CONTENT = """# Dovecot auth configuration - Managed by ServerKit
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include auth-passwdfile.conf.ext
"""
AUTH_PASSWDFILE_CONTENT = """# Password file auth - Managed by ServerKit
passdb {{
driver = passwd-file
args = scheme=SHA512-CRYPT /etc/dovecot/users
}}
userdb {{
driver = static
args = uid={vmail_uid} gid={vmail_gid} home={vmail_dir}/%d/%n
}}
"""
MASTER_CONF_CONTENT = """# Dovecot master configuration - Managed by ServerKit
service imap-login {
inet_listener imap {
port = 0
}
inet_listener imaps {
port = 993
ssl = yes
}
}
service pop3-login {
inet_listener pop3 {
port = 0
}
inet_listener pop3s {
port = 995
ssl = yes
}
}
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0666
user = postfix
group = postfix
}
unix_listener auth-userdb {
mode = 0600
user = vmail
group = vmail
}
user = dovecot
}
service auth-worker {
user = vmail
}
"""
SSL_CONF_CONTENT = """# Dovecot SSL configuration - Managed by ServerKit
ssl = required
ssl_cert = <{tls_cert}
ssl_key = <{tls_key}
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes
"""
@classmethod
def get_status(cls) -> Dict:
"""Get Dovecot installation and running status."""
installed = False
running = False
enabled = False
version = None
# `which` searches $PATH only, and dovecot lives in /usr/sbin — absent
# from the panel unit's PATH — so it reported a false negative on a host
# where dovecot was installed. is_command_available() also searches the
# sbin dirs.
try:
installed = is_command_available('dovecot')
if not installed:
installed = PackageManager.is_installed('dovecot-core') or PackageManager.is_installed('dovecot')
except Exception:
installed = False
if installed:
try:
running = ServiceControl.is_active('dovecot')
enabled = ServiceControl.is_enabled('dovecot')
except (subprocess.SubprocessError, FileNotFoundError):
# No systemctl (container, non-systemd init), or systemd not
# answering inside PROBE_TIMEOUT: "could not check", not "not
# running" — and never a 500. Wider than FileNotFoundError on
# purpose: is_enabled already swallows the missing binary, but
# the probe timeout in both wrappers still raises
# TimeoutExpired straight through get_status and out of
# /api/v1/email/status. The sibling services in this extension
# catch the same pair.
running = None
enabled = None
# Its own try: failing to read the version says nothing about
# whether dovecot is installed. Collapsing the two is how
# FirewallService._check_ufw reported a working firewall as absent.
try:
result = run_privileged(['dovecot', '--version'])
version_match = re.search(r'(\d+\.\d+\.\d+)', result.stdout or '')
if version_match:
version = version_match.group(1)
except Exception:
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
}
@classmethod
def install(cls) -> Dict:
"""Install Dovecot packages."""
try:
manager = PackageManager.detect()
if manager == 'apt':
packages = ['dovecot-core', 'dovecot-imapd', 'dovecot-pop3d', 'dovecot-lmtpd', 'dovecot-sieve']
else:
packages = ['dovecot']
result = PackageManager.install(packages, timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install Dovecot'}
# Create empty passwd file
run_privileged(['touch', cls.DOVECOT_PASSWD_FILE])
run_privileged(['chown', 'vmail:dovecot', cls.DOVECOT_PASSWD_FILE])
run_privileged(['chmod', '640', cls.DOVECOT_PASSWD_FILE])
ServiceControl.enable('dovecot')
return {'success': True, 'message': 'Dovecot installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure(cls, tls_cert: str = None, tls_key: str = None) -> Dict:
"""Write Dovecot configuration files."""
try:
cert = tls_cert or '/etc/ssl/certs/ssl-cert-snakeoil.pem'
key = tls_key or '/etc/ssl/private/ssl-cert-snakeoil.key'
# 10-mail.conf
mail_conf = cls.MAIL_CONF_CONTENT.format(
vmail_dir=paths.VMAIL_DIR,
vmail_uid=paths.VMAIL_UID,
vmail_gid=paths.VMAIL_GID,
)
run_privileged(['tee', cls.DOVECOT_MAIL_CONF], input=mail_conf)
# 10-auth.conf
run_privileged(['tee', cls.DOVECOT_AUTH_CONF], input=cls.AUTH_CONF_CONTENT)
# auth-passwdfile.conf.ext
auth_passwd = cls.AUTH_PASSWDFILE_CONTENT.format(
vmail_uid=paths.VMAIL_UID,
vmail_gid=paths.VMAIL_GID,
vmail_dir=paths.VMAIL_DIR,
)
run_privileged(['tee', cls.AUTH_PASSWDFILE_CONF], input=auth_passwd)
# 10-master.conf
run_privileged(['tee', cls.DOVECOT_MASTER_CONF], input=cls.MASTER_CONF_CONTENT)
# 10-ssl.conf
ssl_conf = cls.SSL_CONF_CONTENT.format(tls_cert=cert, tls_key=key)
run_privileged(['tee', cls.DOVECOT_SSL_CONF], input=ssl_conf)
# Restart to apply
ServiceControl.restart('dovecot', timeout=30)
return {'success': True, 'message': 'Dovecot configured successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def create_mailbox(cls, email: str, password: str, domain: str, username: str, quota_mb: int = 1024) -> Dict:
"""Create a virtual mailbox with password entry."""
try:
# Generate password hash using doveadm
result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', password])
if result.returncode != 0:
return {'success': False, 'error': 'Failed to hash password'}
password_hash = result.stdout.strip()
# Build passwd-file entry
# Format: user@domain:{scheme}hash:uid:gid:home::userdb_quota_rule=*:storage=NM
entry = f'{email}:{password_hash}:{paths.VMAIL_UID}:{paths.VMAIL_GID}:{paths.VMAIL_DIR}/{domain}/{username}::userdb_quota_rule=*:storage={quota_mb}M'
# Append to passwd file
run_privileged(['tee', '-a', cls.DOVECOT_PASSWD_FILE], input=entry + '\n')
# Create Maildir
maildir = os.path.join(paths.VMAIL_DIR, domain, username, 'Maildir')
run_privileged(['mkdir', '-p', f'{maildir}/cur', f'{maildir}/new', f'{maildir}/tmp'])
run_privileged(['chown', '-R', f'{paths.VMAIL_UID}:{paths.VMAIL_GID}',
os.path.join(paths.VMAIL_DIR, domain, username)])
return {'success': True, 'message': f'Mailbox {email} created'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def delete_mailbox(cls, email: str, domain: str, username: str, remove_files: bool = False) -> Dict:
"""Delete a virtual mailbox."""
try:
# Remove from passwd file
result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE])
lines = (result.stdout or '').splitlines()
new_lines = [l for l in lines if not l.startswith(f'{email}:')]
run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n')
# Optionally remove Maildir
if remove_files:
mailbox_path = os.path.join(paths.VMAIL_DIR, domain, username)
run_privileged(['rm', '-rf', mailbox_path])
return {'success': True, 'message': f'Mailbox {email} deleted'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def change_password(cls, email: str, new_password: str) -> Dict:
"""Change a mailbox password."""
try:
# Generate new hash
result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', new_password])
if result.returncode != 0:
return {'success': False, 'error': 'Failed to hash password'}
new_hash = result.stdout.strip()
# Read current file
result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE])
lines = (result.stdout or '').splitlines()
updated = False
new_lines = []
for line in lines:
if line.startswith(f'{email}:'):
parts = line.split(':')
parts[1] = new_hash
new_lines.append(':'.join(parts))
updated = True
else:
new_lines.append(line)
if not updated:
return {'success': False, 'error': f'Account {email} not found'}
run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n')
return {'success': True, 'message': 'Password changed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def set_quota(cls, email: str, quota_mb: int) -> Dict:
"""Update mailbox quota."""
try:
result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE])
lines = (result.stdout or '').splitlines()
updated = False
new_lines = []
for line in lines:
if line.startswith(f'{email}:'):
# Replace quota in the userdb_quota_rule field
line = re.sub(r'userdb_quota_rule=\*:storage=\d+M',
f'userdb_quota_rule=*:storage={quota_mb}M', line)
new_lines.append(line)
updated = True
else:
new_lines.append(line)
if not updated:
return {'success': False, 'error': f'Account {email} not found'}
run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n')
return {'success': True, 'message': f'Quota set to {quota_mb}MB'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_quota_usage(cls, email: str) -> Dict:
"""Get mailbox quota usage."""
try:
result = run_privileged(['doveadm', 'quota', 'get', '-u', email])
if result.returncode != 0:
return {'success': False, 'error': 'Failed to get quota'}
# Parse output
usage = {'storage_used': 0, 'storage_limit': 0, 'message_count': 0}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[0] == 'STORAGE':
usage['storage_used'] = int(parts[1]) // 1024 # KB to MB
usage['storage_limit'] = int(parts[2]) // 1024 if parts[2] != '-' else 0
elif len(parts) >= 4 and parts[0] == 'MESSAGE':
usage['message_count'] = int(parts[1])
return {'success': True, **usage}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload Dovecot configuration."""
try:
result = ServiceControl.reload('dovecot', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'Dovecot reloaded'}
return {'success': False, 'error': result.stderr or 'Reload failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def restart(cls) -> Dict:
"""Restart Dovecot."""
try:
result = ServiceControl.restart('dovecot', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'Dovecot restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -1,426 +0,0 @@
"""Email Server API endpoints for managing mail services, domains, accounts, and DNS.
Lives in the serverkit-email extension (app.plugins.serverkit-email.email after
install). Intra-extension modules are imported relatively; core services
(postfix relay, DNS, relay-config, RBAC) are imported absolutely — the mail
server extends the core, never the other way around.
"""
from flask import Blueprint, request, jsonify
from app.middleware.rbac import admin_required, viewer_required
from app.services.dns_provider_service import DNSProviderService
from app.services.postfix_service import PostfixService
from app.services.email_relay_service import EmailRelayService
from .email_service import EmailService
from .spamassassin_service import SpamAssassinService
from .roundcube_service import RoundcubeService
email_bp = Blueprint('email', __name__)
# ── Status & Installation ──
@email_bp.route('/status', methods=['GET'])
@viewer_required
def get_status():
"""Get aggregate email server status."""
roundcube = RoundcubeService.get_status()
status = EmailService.get_status()
status['roundcube'] = roundcube
return jsonify(status), 200
@email_bp.route('/install', methods=['POST'])
@admin_required
def install():
"""Install and configure all email components."""
data = request.get_json() or {}
hostname = data.get('hostname')
result = EmailService.install_all(hostname)
return jsonify(result), 200 if result.get('success') else 500
@email_bp.route('/service/<component>/<action>', methods=['POST'])
@admin_required
def control_service(component, action):
"""Start/stop/restart an email component."""
result = EmailService.control_service(component, action)
return jsonify(result), 200 if result.get('success') else 400
# ── Domains ──
@email_bp.route('/domains', methods=['GET'])
@viewer_required
def list_domains():
"""List all email domains."""
domains = EmailService.get_domains()
return jsonify({'domains': domains}), 200
@email_bp.route('/domains', methods=['POST'])
@admin_required
def add_domain():
"""Add an email domain."""
data = request.get_json()
if not data or not data.get('name'):
return jsonify({'success': False, 'error': 'Domain name is required'}), 400
result = EmailService.add_domain(
data['name'],
dns_provider_id=data.get('dns_provider_id'),
dns_zone_id=data.get('dns_zone_id'),
)
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/domains/<int:domain_id>', methods=['GET'])
@viewer_required
def get_domain(domain_id):
"""Get domain details."""
result = EmailService.get_domain(domain_id)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@email_bp.route('/domains/<int:domain_id>', methods=['DELETE'])
@admin_required
def remove_domain(domain_id):
"""Remove an email domain."""
result = EmailService.remove_domain(domain_id)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/domains/<int:domain_id>/verify-dns', methods=['POST'])
@viewer_required
def verify_dns(domain_id):
"""Verify DNS records for a domain."""
result = EmailService.verify_dns(domain_id)
return jsonify(result), 200
@email_bp.route('/domains/<int:domain_id>/deploy-dns', methods=['POST'])
@admin_required
def deploy_dns(domain_id):
"""Deploy DNS records via provider API."""
from app.models.email import EmailDomain
domain = EmailDomain.query.get(domain_id)
if not domain:
return jsonify({'success': False, 'error': 'Domain not found'}), 404
if not domain.dns_provider_id or not domain.dns_zone_id:
return jsonify({'success': False, 'error': 'No DNS provider configured for this domain'}), 400
result = DNSProviderService.deploy_email_records(
domain.dns_provider_id,
domain.dns_zone_id,
domain.name,
domain.dkim_selector or 'default',
domain.dkim_public_key or '',
)
return jsonify(result), 200 if result.get('success') else 400
# ── Accounts ──
@email_bp.route('/domains/<int:domain_id>/accounts', methods=['GET'])
@viewer_required
def list_accounts(domain_id):
"""List email accounts for a domain."""
accounts = EmailService.get_accounts(domain_id)
return jsonify({'accounts': accounts}), 200
@email_bp.route('/domains/<int:domain_id>/accounts', methods=['POST'])
@admin_required
def create_account(domain_id):
"""Create an email account."""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({'success': False, 'error': 'Username and password are required'}), 400
result = EmailService.add_account(
domain_id,
username,
password,
quota_mb=data.get('quota_mb', 1024),
)
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/accounts/<int:account_id>', methods=['GET'])
@viewer_required
def get_account(account_id):
"""Get account details."""
from app.models.email import EmailAccount
account = EmailAccount.query.get(account_id)
if not account:
return jsonify({'success': False, 'error': 'Account not found'}), 404
return jsonify({'success': True, 'account': account.to_dict()}), 200
@email_bp.route('/accounts/<int:account_id>', methods=['PUT'])
@admin_required
def update_account(account_id):
"""Update account settings."""
data = request.get_json() or {}
result = EmailService.update_account(
account_id,
quota_mb=data.get('quota_mb'),
is_active=data.get('is_active'),
)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/accounts/<int:account_id>', methods=['DELETE'])
@admin_required
def delete_account(account_id):
"""Delete an email account."""
result = EmailService.delete_account(account_id)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/accounts/<int:account_id>/password', methods=['POST'])
@admin_required
def change_password(account_id):
"""Change account password."""
data = request.get_json()
if not data or not data.get('password'):
return jsonify({'success': False, 'error': 'New password is required'}), 400
result = EmailService.change_password(account_id, data['password'])
return jsonify(result), 200 if result.get('success') else 400
# ── Aliases ──
@email_bp.route('/domains/<int:domain_id>/aliases', methods=['GET'])
@viewer_required
def list_aliases(domain_id):
"""List email aliases for a domain."""
aliases = EmailService.get_aliases(domain_id)
return jsonify({'aliases': aliases}), 200
@email_bp.route('/domains/<int:domain_id>/aliases', methods=['POST'])
@admin_required
def create_alias(domain_id):
"""Create an email alias."""
data = request.get_json()
if not data or not data.get('source') or not data.get('destination'):
return jsonify({'success': False, 'error': 'Source and destination are required'}), 400
result = EmailService.add_alias(domain_id, data['source'], data['destination'])
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/aliases/<int:alias_id>', methods=['DELETE'])
@admin_required
def delete_alias(alias_id):
"""Delete an email alias."""
result = EmailService.remove_alias(alias_id)
return jsonify(result), 200 if result.get('success') else 400
# ── Forwarding Rules ──
@email_bp.route('/accounts/<int:account_id>/forwarding', methods=['GET'])
@viewer_required
def list_forwarding(account_id):
"""List forwarding rules for an account."""
rules = EmailService.get_forwarding(account_id)
return jsonify({'rules': rules}), 200
@email_bp.route('/accounts/<int:account_id>/forwarding', methods=['POST'])
@admin_required
def create_forwarding(account_id):
"""Create a forwarding rule."""
data = request.get_json()
if not data or not data.get('destination'):
return jsonify({'success': False, 'error': 'Destination is required'}), 400
result = EmailService.add_forwarding(
account_id,
data['destination'],
keep_copy=data.get('keep_copy', True),
)
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/forwarding/<int:rule_id>', methods=['PUT'])
@admin_required
def update_forwarding(rule_id):
"""Update a forwarding rule."""
data = request.get_json() or {}
result = EmailService.update_forwarding(
rule_id,
destination=data.get('destination'),
keep_copy=data.get('keep_copy'),
is_active=data.get('is_active'),
)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/forwarding/<int:rule_id>', methods=['DELETE'])
@admin_required
def delete_forwarding(rule_id):
"""Delete a forwarding rule."""
result = EmailService.remove_forwarding(rule_id)
return jsonify(result), 200 if result.get('success') else 400
# ── DNS Providers ──
# Moved to core (app/api/dns_providers.py, same /api/v1/email/dns-providers
# paths): the Connections page and wildcard TLS need them without this
# extension installed.
# ── Outbound SMTP Relay (smarthost) ──
@email_bp.route('/relay', methods=['GET'])
@viewer_required
def get_relay():
"""Get the outbound SMTP relay configuration (password masked)."""
return jsonify(EmailRelayService.get_config()), 200
@email_bp.route('/relay', methods=['PUT'])
@admin_required
def save_relay():
"""Save the relay configuration and apply it to Postfix (if installed)."""
data = request.get_json() or {}
if not data.get('host'):
return jsonify({'success': False, 'error': 'A relay host is required'}), 400
result = EmailRelayService.save_config(data)
return jsonify(result), 200
@email_bp.route('/relay/test', methods=['POST'])
@admin_required
def test_relay():
"""Open a real SMTP connection to validate the relay credentials."""
data = request.get_json() or {}
result = EmailRelayService.test(data)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/relay', methods=['DELETE'])
@admin_required
def disable_relay():
"""Disable the outbound relay (revert Postfix to direct delivery)."""
result = EmailRelayService.disable()
return jsonify(result), 200
# ── SpamAssassin Config ──
@email_bp.route('/spam/config', methods=['GET'])
@viewer_required
def get_spam_config():
"""Get SpamAssassin configuration."""
result = SpamAssassinService.get_config()
return jsonify(result), 200
@email_bp.route('/spam/config', methods=['PUT'])
@admin_required
def update_spam_config():
"""Update SpamAssassin configuration."""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
result = SpamAssassinService.configure(data)
if result.get('success'):
SpamAssassinService.reload()
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/spam/update-rules', methods=['POST'])
@admin_required
def update_spam_rules():
"""Update SpamAssassin rules."""
result = SpamAssassinService.update_rules()
return jsonify(result), 200 if result.get('success') else 400
# ── Roundcube Webmail ──
@email_bp.route('/webmail/status', methods=['GET'])
@viewer_required
def webmail_status():
"""Get Roundcube webmail status."""
result = RoundcubeService.get_status()
return jsonify(result), 200
@email_bp.route('/webmail/install', methods=['POST'])
@admin_required
def webmail_install():
"""Install Roundcube webmail."""
data = request.get_json() or {}
result = RoundcubeService.install(
imap_host=data.get('imap_host', 'host.docker.internal'),
smtp_host=data.get('smtp_host', 'host.docker.internal'),
domain=(data.get('domain') or '').strip() or None,
)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/webmail/service/<action>', methods=['POST'])
@admin_required
def webmail_control(action):
"""Start/stop/restart Roundcube."""
actions = {
'start': RoundcubeService.start,
'stop': RoundcubeService.stop,
'restart': RoundcubeService.restart,
}
if action not in actions:
return jsonify({'success': False, 'error': 'Invalid action'}), 400
result = actions[action]()
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/webmail/configure-proxy', methods=['POST'])
@admin_required
def webmail_configure_proxy():
"""Configure Nginx reverse proxy for Roundcube."""
data = request.get_json()
if not data or not data.get('domain'):
return jsonify({'success': False, 'error': 'Domain is required'}), 400
result = RoundcubeService.configure_nginx_proxy(data['domain'])
return jsonify(result), 200 if result.get('success') else 400
# ── Mail Queue & Logs ──
@email_bp.route('/queue', methods=['GET'])
@viewer_required
def get_queue():
"""Get Postfix mail queue."""
result = PostfixService.get_queue()
return jsonify(result), 200
@email_bp.route('/queue/flush', methods=['POST'])
@admin_required
def flush_queue():
"""Flush the mail queue."""
result = PostfixService.flush_queue()
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/queue/<queue_id>', methods=['DELETE'])
@admin_required
def delete_queue_item(queue_id):
"""Delete a message from the queue."""
result = PostfixService.delete_from_queue(queue_id)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/logs', methods=['GET'])
@viewer_required
def get_logs():
"""Get mail logs."""
lines = request.args.get('lines', 100, type=int)
result = PostfixService.get_logs(lines)
return jsonify(result), 200
@@ -1,550 +0,0 @@
"""Email service orchestrator — delegates to sub-services for each component."""
import logging
import subprocess
from typing import Dict, List, Optional
from app import db
from app.models.email import EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule
from app.services.postfix_service import PostfixService
from app.utils.system import PackageManager, ServiceControl, run_checked
from .dovecot_service import DovecotService
from .dkim_service import DKIMService
from .spamassassin_service import SpamAssassinService
from .roundcube_service import RoundcubeService
logger = logging.getLogger(__name__)
class EmailService:
"""High-level email service that coordinates Postfix, Dovecot, DKIM, and SpamAssassin."""
COMPONENTS = {
'postfix': PostfixService,
'dovecot': DovecotService,
'opendkim': DKIMService,
'spamassassin': SpamAssassinService,
}
# ── Status ──
@classmethod
def get_status(cls) -> Dict:
"""Get aggregate email server status."""
postfix = PostfixService.get_status()
dovecot = DovecotService.get_status()
dkim = DKIMService.get_status()
spam = SpamAssassinService.get_status()
all_installed = postfix['installed'] and dovecot['installed']
# bool(): a component whose probe could not run reports running=None
# (see DovecotService.get_status), and `True and None` is None — the
# top-level flag is documented as a boolean, so keep it one. The
# honest per-component tri-state is still in the nested dicts.
all_running = bool(postfix.get('running', False) and dovecot.get('running', False))
return {
'installed': all_installed,
'running': all_running,
'postfix': postfix,
'dovecot': dovecot,
'dkim': dkim,
'spamassassin': spam,
}
# ── Installation ──
@classmethod
def install_all(cls, hostname: str = None) -> Dict:
"""Install and configure all email components."""
results = {}
# 1. Install Postfix
results['postfix'] = PostfixService.install(hostname=hostname)
if not results['postfix'].get('success'):
return {'success': False, 'error': 'Postfix installation failed', 'results': results}
# 2. Configure Postfix
postfix_config = PostfixService.configure(hostname=hostname)
if not postfix_config.get('success'):
logger.warning(f"Postfix configuration warning: {postfix_config.get('error')}")
# 3. Install Dovecot
results['dovecot'] = DovecotService.install()
if not results['dovecot'].get('success'):
return {'success': False, 'error': 'Dovecot installation failed', 'results': results}
# 4. Configure Dovecot
dovecot_config = DovecotService.configure()
if not dovecot_config.get('success'):
logger.warning(f"Dovecot configuration warning: {dovecot_config.get('error')}")
# 5. Install OpenDKIM
results['dkim'] = DKIMService.install()
if not results['dkim'].get('success'):
logger.warning(f"DKIM installation warning: {results['dkim'].get('error')}")
# 6. Install SpamAssassin
results['spamassassin'] = SpamAssassinService.install()
if not results['spamassassin'].get('success'):
logger.warning(f"SpamAssassin installation warning: {results['spamassassin'].get('error')}")
all_ok = all(r.get('success') for r in results.values())
return {
'success': all_ok,
'message': 'All email components installed' if all_ok else 'Some components had issues',
'results': results,
}
# ── Service Control ──
@classmethod
def control_service(cls, component: str, action: str) -> Dict:
"""Start/stop/restart an email component."""
if component not in cls.COMPONENTS and component != 'roundcube':
return {'success': False, 'error': f'Unknown component: {component}'}
if action not in ('start', 'stop', 'restart', 'reload'):
return {'success': False, 'error': f'Invalid action: {action}'}
try:
if component == 'roundcube':
actions = {
'start': RoundcubeService.start,
'stop': RoundcubeService.stop,
'restart': RoundcubeService.restart,
'reload': RoundcubeService.restart,
}
return actions[action]()
service_name = component
if action == 'start':
result = ServiceControl.start(service_name, timeout=30)
elif action == 'stop':
result = ServiceControl.stop(service_name, timeout=30)
elif action == 'restart':
result = ServiceControl.restart(service_name, timeout=30)
elif action == 'reload':
result = ServiceControl.reload(service_name, timeout=30)
if result.returncode == 0:
return {'success': True, 'message': f'{component} {action} successful'}
return {'success': False, 'error': result.stderr or f'{action} failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── Domains ──
@classmethod
def get_domains(cls) -> List[Dict]:
"""List all email domains."""
domains = EmailDomain.query.all()
return [d.to_dict() for d in domains]
@classmethod
def add_domain(cls, name: str, dns_provider_id: int = None, dns_zone_id: str = None) -> Dict:
"""Add an email domain."""
try:
existing = EmailDomain.query.filter_by(name=name).first()
if existing:
return {'success': False, 'error': f'Domain {name} already exists'}
domain = EmailDomain(
name=name,
dns_provider_id=dns_provider_id,
dns_zone_id=dns_zone_id,
)
db.session.add(domain)
db.session.commit()
# Add to Postfix virtual domains
PostfixService.add_domain(name)
# Generate DKIM key
dkim_result = DKIMService.generate_key(name)
if dkim_result.get('success'):
domain.dkim_selector = 'default'
domain.dkim_private_key_path = dkim_result.get('private_key_path')
domain.dkim_public_key = dkim_result.get('public_key')
domain.spf_record = f'v=spf1 mx a ~all'
domain.dmarc_record = f'v=DMARC1; p=quarantine; rua=mailto:dmarc@{name}; pct=100'
DKIMService.add_domain(name)
db.session.commit()
return {'success': True, 'domain': domain.to_dict(), 'message': f'Domain {name} added'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def get_domain(cls, domain_id: int) -> Dict:
"""Get domain details."""
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
return {'success': True, 'domain': domain.to_dict()}
@classmethod
def remove_domain(cls, domain_id: int) -> Dict:
"""Remove an email domain and all its accounts/aliases."""
try:
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
domain_name = domain.name
# Remove accounts from Dovecot/Postfix
for account in domain.accounts:
DovecotService.delete_mailbox(account.email, domain_name, account.username, remove_files=True)
PostfixService.remove_mailbox(account.email)
# Remove aliases from Postfix
for alias in domain.aliases:
PostfixService.remove_alias(alias.source)
# Remove from DKIM
DKIMService.remove_domain(domain_name)
# Remove from Postfix virtual domains
PostfixService.remove_domain(domain_name)
# Delete from database
db.session.delete(domain)
db.session.commit()
return {'success': True, 'message': f'Domain {domain_name} removed'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def verify_dns(cls, domain_id: int) -> Dict:
"""Verify DNS records for a domain."""
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
results = {}
name = domain.name
# Check MX record
try:
result = run_checked(['dig', '+short', 'MX', name], timeout=10)
results['mx'] = {
'found': bool(result['output'].strip()),
'value': result['output'].strip() or None,
}
except Exception:
results['mx'] = {'found': False, 'error': 'DNS lookup failed'}
# Check SPF record
try:
result = run_checked(['dig', '+short', 'TXT', name], timeout=10)
spf_found = 'v=spf1' in result['output']
results['spf'] = {
'found': spf_found,
'value': result['output'].strip() or None,
}
except Exception:
results['spf'] = {'found': False, 'error': 'DNS lookup failed'}
# Check DKIM record
selector = domain.dkim_selector or 'default'
try:
dkim_name = f'{selector}._domainkey.{name}'
result = run_checked(['dig', '+short', 'TXT', dkim_name], timeout=10)
dkim_found = 'v=DKIM1' in result['output']
results['dkim'] = {
'found': dkim_found,
'value': result['output'].strip() or None,
'name': dkim_name,
}
except Exception:
results['dkim'] = {'found': False, 'error': 'DNS lookup failed'}
# Check DMARC record
try:
dmarc_name = f'_dmarc.{name}'
result = run_checked(['dig', '+short', 'TXT', dmarc_name], timeout=10)
dmarc_found = 'v=DMARC1' in result['output']
results['dmarc'] = {
'found': dmarc_found,
'value': result['output'].strip() or None,
}
except Exception:
results['dmarc'] = {'found': False, 'error': 'DNS lookup failed'}
all_ok = all(r.get('found') for r in results.values())
return {
'success': True,
'domain': name,
'all_verified': all_ok,
'records': results,
}
# ── Accounts ──
@classmethod
def get_accounts(cls, domain_id: int) -> List[Dict]:
"""List email accounts for a domain."""
accounts = EmailAccount.query.filter_by(domain_id=domain_id).all()
return [a.to_dict() for a in accounts]
@classmethod
def add_account(cls, domain_id: int, username: str, password: str, quota_mb: int = 1024) -> Dict:
"""Create an email account."""
try:
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
email = f'{username}@{domain.name}'
existing = EmailAccount.query.filter_by(email=email).first()
if existing:
return {'success': False, 'error': f'Account {email} already exists'}
# Create mailbox in Dovecot
dovecot_result = DovecotService.create_mailbox(email, password, domain.name, username, quota_mb)
if not dovecot_result.get('success'):
return dovecot_result
# Add to Postfix virtual mailboxes
PostfixService.add_mailbox(email, domain.name, username)
# Get password hash for storage
password_hash = dovecot_result.get('password_hash', 'stored_in_dovecot')
account = EmailAccount(
email=email,
username=username,
password_hash=password_hash,
domain_id=domain_id,
quota_mb=quota_mb,
)
db.session.add(account)
db.session.commit()
return {'success': True, 'account': account.to_dict(), 'message': f'Account {email} created'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def update_account(cls, account_id: int, quota_mb: int = None, is_active: bool = None) -> Dict:
"""Update account settings."""
try:
account = EmailAccount.query.get(account_id)
if not account:
return {'success': False, 'error': 'Account not found'}
if quota_mb is not None:
account.quota_mb = quota_mb
DovecotService.set_quota(account.email, quota_mb)
if is_active is not None:
account.is_active = is_active
db.session.commit()
return {'success': True, 'account': account.to_dict(), 'message': 'Account updated'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def delete_account(cls, account_id: int) -> Dict:
"""Delete an email account."""
try:
account = EmailAccount.query.get(account_id)
if not account:
return {'success': False, 'error': 'Account not found'}
email = account.email
domain_name = account.domain.name if account.domain else ''
username = account.username
# Remove from Dovecot
DovecotService.delete_mailbox(email, domain_name, username, remove_files=True)
# Remove from Postfix
PostfixService.remove_mailbox(email)
db.session.delete(account)
db.session.commit()
return {'success': True, 'message': f'Account {email} deleted'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def change_password(cls, account_id: int, new_password: str) -> Dict:
"""Change an account password."""
try:
account = EmailAccount.query.get(account_id)
if not account:
return {'success': False, 'error': 'Account not found'}
result = DovecotService.change_password(account.email, new_password)
if not result.get('success'):
return result
account.password_hash = 'updated_in_dovecot'
db.session.commit()
return {'success': True, 'message': 'Password changed successfully'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
# ── Aliases ──
@classmethod
def get_aliases(cls, domain_id: int) -> List[Dict]:
"""List email aliases for a domain."""
aliases = EmailAlias.query.filter_by(domain_id=domain_id).all()
return [a.to_dict() for a in aliases]
@classmethod
def add_alias(cls, domain_id: int, source: str, destination: str) -> Dict:
"""Create an email alias."""
try:
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
# Add @ domain if not already qualified
if '@' not in source:
source = f'{source}@{domain.name}'
alias = EmailAlias(
source=source,
destination=destination,
domain_id=domain_id,
)
db.session.add(alias)
db.session.commit()
# Add to Postfix virtual aliases
PostfixService.add_alias(source, destination)
return {'success': True, 'alias': alias.to_dict(), 'message': f'Alias {source} created'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def remove_alias(cls, alias_id: int) -> Dict:
"""Delete an email alias."""
try:
alias = EmailAlias.query.get(alias_id)
if not alias:
return {'success': False, 'error': 'Alias not found'}
PostfixService.remove_alias(alias.source)
db.session.delete(alias)
db.session.commit()
return {'success': True, 'message': 'Alias removed'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
# ── Forwarding Rules ──
@classmethod
def get_forwarding(cls, account_id: int) -> List[Dict]:
"""List forwarding rules for an account."""
rules = EmailForwardingRule.query.filter_by(account_id=account_id).all()
return [r.to_dict() for r in rules]
@classmethod
def add_forwarding(cls, account_id: int, destination: str, keep_copy: bool = True) -> Dict:
"""Create a forwarding rule."""
try:
account = EmailAccount.query.get(account_id)
if not account:
return {'success': False, 'error': 'Account not found'}
rule = EmailForwardingRule(
account_id=account_id,
destination=destination,
keep_copy=keep_copy,
)
db.session.add(rule)
db.session.commit()
# Update Postfix aliases for forwarding
cls._sync_forwarding_aliases(account)
return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule created'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def update_forwarding(cls, rule_id: int, destination: str = None,
keep_copy: bool = None, is_active: bool = None) -> Dict:
"""Update a forwarding rule."""
try:
rule = EmailForwardingRule.query.get(rule_id)
if not rule:
return {'success': False, 'error': 'Forwarding rule not found'}
if destination is not None:
rule.destination = destination
if keep_copy is not None:
rule.keep_copy = keep_copy
if is_active is not None:
rule.is_active = is_active
db.session.commit()
# Re-sync Postfix aliases
cls._sync_forwarding_aliases(rule.account)
return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule updated'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def remove_forwarding(cls, rule_id: int) -> Dict:
"""Delete a forwarding rule."""
try:
rule = EmailForwardingRule.query.get(rule_id)
if not rule:
return {'success': False, 'error': 'Forwarding rule not found'}
account = rule.account
db.session.delete(rule)
db.session.commit()
# Re-sync Postfix aliases
cls._sync_forwarding_aliases(account)
return {'success': True, 'message': 'Forwarding rule removed'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def _sync_forwarding_aliases(cls, account: EmailAccount):
"""Sync forwarding rules to Postfix virtual aliases for an account."""
active_rules = [r for r in account.forwarding_rules if r.is_active]
if not active_rules:
# Remove any forwarding alias
PostfixService.remove_alias(account.email)
return
# Build destination list
destinations = [r.destination for r in active_rules]
# If any rule wants to keep a copy, include the original mailbox
if any(r.keep_copy for r in active_rules):
destinations.append(account.email)
# Remove old alias and add new one
PostfixService.remove_alias(account.email)
PostfixService.add_alias(account.email, ', '.join(destinations))
@@ -1,51 +0,0 @@
{
"name": "serverkit-email",
"display_name": "Email Server",
"version": "1.0.0",
"description": "Full mail-server stack with Postfix/Dovecot, domains, mailboxes, aliases, DKIM/SPF/DMARC, SpamAssassin filtering, and Roundcube webmail. Most panels never run a mail server, so it ships as an opt-in extension.",
"author": "ServerKit",
"license": "MIT",
"category": "integration",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": [
"shell",
"filesystem",
"network"
],
"min_panel_version": "1.7.0",
"entry_point": "email:email_bp",
"url_prefix": "/api/v1/email",
"icon": "<path d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\"/><polyline points=\"22,6 12,13 2,6\"/>",
"contributions": {
"nav": [
{
"id": "email",
"label": "Email Server",
"route": "/email",
"category": "operations",
"icon": "<path d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\"/><polyline points=\"22,6 12,13 2,6\"/>"
}
],
"routes": [
{
"path": "email",
"component": "EmailPage"
},
{
"path": "email/:tab",
"component": "EmailPage"
}
],
"page_titles": {
"/email": "Email Server"
},
"command_palette": [
{
"label": "Email",
"path": "/email",
"category": "Pages",
"keywords": "smtp postfix dovecot mail imap dkim webmail"
}
]
}
}
@@ -1,198 +0,0 @@
"""Roundcube webmail management service (Docker-based)."""
import subprocess
from typing import Dict
from app.utils.system import run_checked, run_privileged, is_command_available
class RoundcubeService:
"""Service for managing Roundcube webmail via Docker."""
CONTAINER_NAME = 'serverkit-roundcube'
VOLUME_NAME = 'roundcube_data'
IMAGE = 'roundcube/roundcubemail:latest'
HOST_PORT = 9000
@classmethod
def get_status(cls) -> Dict:
"""Get Roundcube container status."""
if not is_command_available('docker'):
return {'installed': False, 'running': False, 'error': 'Docker not available'}
try:
result = run_checked(
['docker', 'inspect', '--format', '{{.State.Status}}', cls.CONTAINER_NAME],
timeout=None)
if not result['success']:
return {'installed': False, 'running': False}
status = result['output'].strip()
return {
'installed': True,
'running': status == 'running',
'status': status,
'port': cls.HOST_PORT,
}
except Exception as e:
return {'installed': False, 'running': False, 'error': str(e)}
@classmethod
def install(cls, imap_host: str = 'host.docker.internal',
smtp_host: str = 'host.docker.internal',
domain: str = None) -> Dict:
"""Install Roundcube via Docker container."""
if not is_command_available('docker'):
return {'success': False, 'error': 'Docker is not installed'}
try:
# Remove existing container if any
run_checked(['docker', 'rm', '-f', cls.CONTAINER_NAME], timeout=None)
# Create volume
run_checked(['docker', 'volume', 'create', cls.VOLUME_NAME], timeout=None)
# Run container
result = run_checked([
'docker', 'run', '-d',
'--name', cls.CONTAINER_NAME,
'--restart', 'unless-stopped',
'--add-host', 'host.docker.internal:host-gateway',
'-p', f'{cls.HOST_PORT}:80',
'-e', f'ROUNDCUBEMAIL_DEFAULT_HOST=ssl://{imap_host}',
'-e', f'ROUNDCUBEMAIL_SMTP_SERVER=tls://{smtp_host}',
'-e', 'ROUNDCUBEMAIL_DEFAULT_PORT=993',
'-e', 'ROUNDCUBEMAIL_SMTP_PORT=587',
'-e', 'ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=25M',
'-e', 'ROUNDCUBEMAIL_SKIN=elastic',
'-v', f'{cls.VOLUME_NAME}:/var/roundcube/db',
cls.IMAGE,
], timeout=None)
if not result['success']:
return {'success': False,
'error': result['error'] or 'Failed to start container'}
# Build a public URL: prefer a supplied domain, then auto-generate
# webmail.<panel_domain>, and only fall back to localhost:port.
from app.services.site_domain_service import SiteDomainService
public_url = None
warning = None
if domain:
public_url = f'http://{domain}'
proxy_res = cls.configure_nginx_proxy(domain)
if not proxy_res.get('success'):
warning = proxy_res.get('error')
else:
panel_origin = SiteDomainService.panel_origin()
panel_host = None
if panel_origin:
from urllib.parse import urlparse
panel_host = urlparse(panel_origin).hostname
if panel_host:
webmail_domain = f'webmail.{panel_host}'
public_url = f'http://{webmail_domain}'
proxy_res = cls.configure_nginx_proxy(webmail_domain)
if not proxy_res.get('success'):
warning = proxy_res.get('error')
if not public_url:
public_url = f'http://localhost:{cls.HOST_PORT}'
result = {
'success': True,
'message': 'Roundcube installed successfully',
'port': cls.HOST_PORT,
'url': public_url,
}
if warning:
result['warning'] = warning
return result
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def uninstall(cls) -> Dict:
"""Stop and remove Roundcube container."""
try:
run_checked(['docker', 'rm', '-f', cls.CONTAINER_NAME], timeout=None)
return {'success': True, 'message': 'Roundcube uninstalled'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def start(cls) -> Dict:
"""Start Roundcube container."""
try:
result = run_checked(['docker', 'start', cls.CONTAINER_NAME], timeout=None)
if result['success']:
return {'success': True, 'message': 'Roundcube started'}
return {'success': False, 'error': result['error'] or 'Start failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def stop(cls) -> Dict:
"""Stop Roundcube container."""
try:
result = run_checked(['docker', 'stop', cls.CONTAINER_NAME], timeout=None)
if result['success']:
return {'success': True, 'message': 'Roundcube stopped'}
return {'success': False, 'error': result['error'] or 'Stop failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def restart(cls) -> Dict:
"""Restart Roundcube container."""
try:
result = run_checked(['docker', 'restart', cls.CONTAINER_NAME], timeout=None)
if result['success']:
return {'success': True, 'message': 'Roundcube restarted'}
return {'success': False, 'error': result['error'] or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure_nginx_proxy(cls, domain: str) -> Dict:
"""Create Nginx reverse proxy config for Roundcube."""
try:
from app.services.nginx_service import NginxService
config = f"""# Roundcube Webmail - Managed by ServerKit
server {{
listen 80;
server_name {domain};
location / {{
proxy_pass http://127.0.0.1:{cls.HOST_PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}}
}}
"""
site_name = f'roundcube-{domain.replace(".", "-")}'
config_path = f'/etc/nginx/sites-available/{site_name}'
enabled_path = f'/etc/nginx/sites-enabled/{site_name}'
run_privileged(['tee', config_path], input=config)
run_privileged(['ln', '-sf', config_path, enabled_path])
# Test and reload
test = run_privileged(['nginx', '-t'])
if test.returncode != 0:
# Rollback
run_privileged(['rm', '-f', enabled_path])
return {'success': False, 'error': f'Nginx config test failed: {test.stderr}'}
run_privileged(['systemctl', 'reload', 'nginx'])
return {
'success': True,
'message': f'Nginx proxy configured for {domain}',
'url': f'http://{domain}',
}
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -1,254 +0,0 @@
"""SpamAssassin management service."""
import os
import re
import subprocess
from typing import Dict
from app.utils.system import (PackageManager, ServiceControl, run_checked,
run_privileged)
class SpamAssassinService:
"""Service for managing SpamAssassin spam filtering."""
SPAMASSASSIN_CONF = '/etc/spamassassin/local.cf'
SPAMASSASSIN_DEFAULT = '/etc/default/spamassassin'
SPAMASS_MILTER_DEFAULT = '/etc/default/spamass-milter'
LOCAL_CF_TEMPLATE = """# SpamAssassin local configuration - Managed by ServerKit
required_score {required_score}
report_safe {report_safe}
rewrite_header Subject {rewrite_subject}
use_bayes {use_bayes}
bayes_auto_learn {bayes_auto_learn}
bayes_auto_learn_threshold_nonspam 0.1
bayes_auto_learn_threshold_spam 12.0
skip_rbl_checks {skip_rbl_checks}
use_razor2 0
use_pyzor 0
# Network checks
dns_available yes
# Trusted networks
trusted_networks 127.0.0.0/8
internal_networks 127.0.0.0/8
"""
DEFAULT_CONFIG = {
'required_score': 5.0,
'report_safe': 0,
'rewrite_subject': '[SPAM]',
'use_bayes': 1,
'bayes_auto_learn': 1,
'skip_rbl_checks': 0,
}
@classmethod
def get_status(cls) -> Dict:
"""Get SpamAssassin installation and running status."""
installed = False
running = False
enabled = False
version = None
milter_installed = False
milter_running = False
try:
installed = PackageManager.is_installed('spamassassin')
if installed:
running = ServiceControl.is_active('spamassassin')
enabled = ServiceControl.is_enabled('spamassassin')
result = run_checked(['spamassassin', '--version'], timeout=None)
match = re.search(r'version\s+(\S+)', result['output'])
if match:
version = match.group(1)
# Check milter
milter_installed = PackageManager.is_installed('spamass-milter')
if milter_installed:
milter_running = ServiceControl.is_active('spamass-milter')
except (subprocess.SubprocessError, FileNotFoundError):
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
'milter_installed': milter_installed,
'milter_running': milter_running,
}
@classmethod
def install(cls) -> Dict:
"""Install SpamAssassin and spamass-milter."""
try:
manager = PackageManager.detect()
if manager == 'apt':
packages = ['spamassassin', 'spamass-milter', 'spamc']
else:
packages = ['spamassassin', 'spamass-milter-postfix']
result = PackageManager.install(packages, timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install SpamAssassin'}
# Enable spamd on Debian/Ubuntu
if manager == 'apt' and os.path.exists(cls.SPAMASSASSIN_DEFAULT):
result = run_privileged(['cat', cls.SPAMASSASSIN_DEFAULT])
content = result.stdout or ''
content = re.sub(r'ENABLED=0', 'ENABLED=1', content)
content = re.sub(r'CRON=0', 'CRON=1', content)
run_privileged(['tee', cls.SPAMASSASSIN_DEFAULT], input=content)
# Configure milter to listen on port 8893
milter_config = 'OPTIONS="-u spamass-milter -i 127.0.0.1 -p inet:8893@localhost -- --socket=/var/run/spamassassin/spamd.sock"\n'
if os.path.exists(cls.SPAMASS_MILTER_DEFAULT):
run_privileged(['tee', cls.SPAMASS_MILTER_DEFAULT], input=milter_config)
# Write default config
cls.configure(cls.DEFAULT_CONFIG)
# Update rules
run_privileged(['sa-update'], timeout=120)
ServiceControl.enable('spamassassin')
ServiceControl.start('spamassassin', timeout=30)
if PackageManager.is_installed('spamass-milter'):
ServiceControl.enable('spamass-milter')
ServiceControl.start('spamass-milter', timeout=30)
return {'success': True, 'message': 'SpamAssassin installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure(cls, settings: Dict = None) -> Dict:
"""Write SpamAssassin configuration."""
try:
config = dict(cls.DEFAULT_CONFIG)
if settings:
config.update(settings)
content = cls.LOCAL_CF_TEMPLATE.format(**config)
run_privileged(['tee', cls.SPAMASSASSIN_CONF], input=content)
return {'success': True, 'message': 'SpamAssassin configured'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_config(cls) -> Dict:
"""Get current SpamAssassin configuration."""
try:
if not os.path.exists(cls.SPAMASSASSIN_CONF):
return {'success': True, 'config': dict(cls.DEFAULT_CONFIG)}
result = run_privileged(['cat', cls.SPAMASSASSIN_CONF])
content = result.stdout or ''
config = dict(cls.DEFAULT_CONFIG)
# Parse values
score_match = re.search(r'required_score\s+(\S+)', content)
if score_match:
config['required_score'] = float(score_match.group(1))
report_match = re.search(r'report_safe\s+(\d+)', content)
if report_match:
config['report_safe'] = int(report_match.group(1))
subject_match = re.search(r'rewrite_header Subject\s+(.+)', content)
if subject_match:
config['rewrite_subject'] = subject_match.group(1).strip()
bayes_match = re.search(r'use_bayes\s+(\d+)', content)
if bayes_match:
config['use_bayes'] = int(bayes_match.group(1))
auto_learn_match = re.search(r'bayes_auto_learn\s+(\d+)', content)
if auto_learn_match:
config['bayes_auto_learn'] = int(auto_learn_match.group(1))
rbl_match = re.search(r'skip_rbl_checks\s+(\d+)', content)
if rbl_match:
config['skip_rbl_checks'] = int(rbl_match.group(1))
return {'success': True, 'config': config}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def update_rules(cls) -> Dict:
"""Update SpamAssassin rules."""
try:
result = run_privileged(['sa-update'], timeout=120)
# sa-update returns 0 for updates, 1 for no updates, 2+ for errors
if result.returncode <= 1:
ServiceControl.restart('spamassassin', timeout=30)
return {
'success': True,
'message': 'Rules updated' if result.returncode == 0 else 'Rules already up to date',
'updated': result.returncode == 0,
}
return {'success': False, 'error': result.stderr or 'Update failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def train_spam(cls, message_path: str) -> Dict:
"""Train SpamAssassin with a spam message."""
try:
result = run_privileged(['sa-learn', '--spam', message_path])
if result.returncode == 0:
return {'success': True, 'message': 'Message learned as spam'}
return {'success': False, 'error': result.stderr or 'Training failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def train_ham(cls, message_path: str) -> Dict:
"""Train SpamAssassin with a ham (non-spam) message."""
try:
result = run_privileged(['sa-learn', '--ham', message_path])
if result.returncode == 0:
return {'success': True, 'message': 'Message learned as ham'}
return {'success': False, 'error': result.stderr or 'Training failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_stats(cls) -> Dict:
"""Get SpamAssassin Bayes statistics."""
try:
result = run_privileged(['sa-learn', '--dump', 'magic'])
output = result.stdout or ''
stats = {
'nspam': 0,
'nham': 0,
'ntokens': 0,
}
for line in output.splitlines():
parts = line.split()
if len(parts) >= 4:
if parts[2] == 'nspam':
stats['nspam'] = int(parts[1])
elif parts[2] == 'nham':
stats['nham'] = int(parts[1])
elif parts[2] == 'ntokens':
stats['ntokens'] = int(parts[1])
return {'success': True, 'stats': stats}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload SpamAssassin."""
try:
result = ServiceControl.restart('spamassassin', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'SpamAssassin restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -0,0 +1,16 @@
"""ServerKit Status Pages extension backend package.
Extracted from core (plan 47): the status-pages blueprint (public status/badge
routes + authenticated management API) and its service ship here so a fresh panel
that never publishes a status page loads none of it. Mounted under
``/api/v1/status`` via the manifest's ``url_prefix``; the public /status/<slug>
and /status/badge/<slug> pages work whenever the extension is installed. The
StatusPage / StatusComponent / StatusIncident / HealthCheck models stay in core
(G2). Core's WordPress health-check job reaches
``StatusPageService.sync_component_from_health`` through
``plugin_service.get_installed_extension_attr`` only when installed. Upgrades
re-acquire it automatically (``CONVERTED_BUILTIN_SLUGS``).
"""
from .status_pages import status_pages_bp
__all__ = ['status_pages_bp']
@@ -0,0 +1,42 @@
{
"name": "serverkit-status",
"display_name": "Status Pages",
"version": "1.0.0",
"description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. The public /status/<slug> page itself stays core.",
"author": "ServerKit",
"license": "MIT",
"category": "monitoring",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": [],
"entry_point": "status_pages:status_pages_bp",
"url_prefix": "/api/v1/status",
"icon": "<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\"/><path d=\"M2 12h20\"/>",
"contributions": {
"tabs": [
{
"group": "monitoring",
"to": "/status-pages",
"label": "Status Pages",
"icon": "<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\"/><path d=\"M2 12h20\"/>"
}
],
"routes": [
{
"path": "status-pages",
"component": "StatusPagesPage",
"group": "monitoring"
}
],
"page_titles": {
"/status-pages": "Status Pages"
},
"command_palette": [
{
"label": "Status Pages",
"path": "/status-pages",
"category": "Pages",
"keywords": "uptime incidents public status"
}
]
}
}
@@ -0,0 +1,213 @@
"""Public status pages.
Scope note: this service owns *publishing* — pages, branding, grouping and the
public JSON/badge. It no longer owns the check engine. Monitors (what used to be
called "components") and their incidents are core, in
``app.services.monitor_service``, because watching a site must not depend on
having this extension installed. The component/health/incident methods below are
thin delegates kept so existing callers and the extension's own API blueprint
keep working unchanged.
"""
import logging
from app.models.status_page import StatusPage, StatusComponent, StatusIncident
from app.services.monitor_service import MonitorService
from app.utils.slug import validate_slug
from app import db
logger = logging.getLogger(__name__)
class StatusPageService:
"""Service for public status pages."""
@staticmethod
def normalize_slug(value):
return validate_slug(value)
# --- Pages ---
@staticmethod
def list_pages():
return StatusPage.query.order_by(StatusPage.name).all()
@staticmethod
def get_page(page_id):
return StatusPage.query.get(page_id)
@staticmethod
def get_page_by_slug(slug):
return StatusPage.query.filter_by(slug=slug).first()
@staticmethod
def create_page(data):
slug = StatusPageService.normalize_slug(data.get('slug'))
if StatusPage.query.filter_by(slug=slug).first():
raise ValueError(f"Status page '{slug}' already exists")
page = StatusPage(
name=data['name'],
slug=slug,
description=data.get('description', ''),
logo_url=data.get('logo_url'),
primary_color=data.get('primary_color', '#4f46e5'),
custom_domain=data.get('custom_domain'),
is_public=data.get('is_public', True),
show_uptime=data.get('show_uptime', True),
show_history=data.get('show_history', True),
)
db.session.add(page)
db.session.commit()
return page
@staticmethod
def update_page(page_id, data):
page = StatusPage.query.get(page_id)
if not page:
return None
for field in ['name', 'description', 'logo_url', 'primary_color',
'custom_domain', 'is_public', 'show_uptime', 'show_history']:
if field in data:
setattr(page, field, data[field])
db.session.commit()
return page
@staticmethod
def delete_page(page_id):
page = StatusPage.query.get(page_id)
if not page:
return False
db.session.delete(page)
db.session.commit()
return True
@staticmethod
def get_public_page(slug):
"""Get public status page data (no auth required)."""
page = StatusPage.query.filter_by(slug=slug, is_public=True).first()
if not page:
return None
# Internal probe config must not appear on the unauthenticated public page
# (a health-driven WP component may carry an internal localhost:port target).
public_hidden = ('check_type', 'check_target', 'check_interval', 'check_timeout',
'check_method', 'expected_status', 'keyword', 'follow_redirects',
'verify_tls', 'retries', 'consecutive_failures',
'cert_issuer', 'cert_expires_at', 'next_check_at')
components = page.components.all()
grouped = {}
for comp in components:
group = comp.group or 'Services'
cd = comp.to_dict()
for k in public_hidden:
cd.pop(k, None)
grouped.setdefault(group, []).append(cd)
# Active incidents
active_incidents = page.incidents.filter(
StatusIncident.status != 'resolved'
).limit(10).all()
# Recent resolved
resolved = page.incidents.filter_by(status='resolved').limit(5).all()
# Overall status
statuses = [c.status for c in components]
if any(s == 'major_outage' for s in statuses):
overall = 'major_outage'
elif any(s in ('partial_outage', 'degraded') for s in statuses):
overall = 'degraded'
elif any(s == 'maintenance' for s in statuses):
overall = 'maintenance'
else:
overall = 'operational'
return {
'page': page.to_dict(),
'overall_status': overall,
'groups': grouped,
'active_incidents': [i.to_dict() for i in active_incidents],
'recent_incidents': [i.to_dict() for i in resolved],
}
# --- Components (monitors attached to a page) ---
# Delegates to MonitorService; a component is just a monitor with a page_id.
@staticmethod
def create_component(page_id, data):
page = StatusPage.query.get(page_id)
if not page:
raise ValueError('Status page not found')
return MonitorService.create({**data, 'page_id': page_id})
@staticmethod
def attach_component(page_id, monitor_id):
"""Publish an existing monitor on a page. Lets an operator build a status
page out of monitors they already have instead of re-declaring probes."""
if not StatusPage.query.get(page_id):
raise ValueError('Status page not found')
return MonitorService.update(monitor_id, {'page_id': page_id})
@staticmethod
def detach_component(monitor_id):
"""Remove a monitor from its page without deleting the monitor."""
return MonitorService.update(monitor_id, {'page_id': None})
@staticmethod
def update_component(comp_id, data):
return MonitorService.update(comp_id, data)
@staticmethod
def delete_component(comp_id):
return MonitorService.delete(comp_id)
# --- Health Checks (delegated to the core engine) ---
@staticmethod
def run_check(component_id):
return MonitorService.run_check(component_id)
@staticmethod
def get_check_history(component_id, hours=24):
return MonitorService.get_check_history(component_id, hours=hours)
@staticmethod
def recompute_uptime(comp):
return MonitorService.recompute_uptime(comp)
@staticmethod
def sync_component_from_health(comp, overall_status, error=None):
return MonitorService.sync_component_from_health(comp, overall_status, error)
# --- Incidents (delegated) ---
@staticmethod
def create_incident(page_id, data):
return MonitorService.create_incident(page_id, data)
@staticmethod
def update_incident(incident_id, data):
return MonitorService.update_incident(incident_id, data)
@staticmethod
def delete_incident(incident_id):
return MonitorService.delete_incident(incident_id)
@staticmethod
def get_badge(slug):
"""Generate status badge data."""
page = StatusPage.query.filter_by(slug=slug).first()
if not page:
return None
components = page.components.all()
statuses = [c.status for c in components]
if not statuses or all(s == 'operational' for s in statuses):
return {'label': 'status', 'message': 'operational', 'color': 'brightgreen'}
elif any(s == 'major_outage' for s in statuses):
return {'label': 'status', 'message': 'major outage', 'color': 'red'}
elif any(s in ('partial_outage', 'degraded') for s in statuses):
return {'label': 'status', 'message': 'degraded', 'color': 'yellow'}
else:
return {'label': 'status', 'message': 'maintenance', 'color': 'blue'}
@@ -0,0 +1,200 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.middleware.rbac import get_current_user
from .status_page_service import StatusPageService
status_pages_bp = Blueprint('status_pages', __name__)
# --- Public endpoints (no auth) ---
@status_pages_bp.route('/public/<slug>', methods=['GET'])
def get_public_page(slug):
data = StatusPageService.get_public_page(slug)
if not data:
return jsonify({'error': 'Status page not found'}), 404
return jsonify(data)
@status_pages_bp.route('/badge/<slug>', methods=['GET'])
def get_badge(slug):
badge = StatusPageService.get_badge(slug)
if not badge:
return jsonify({'error': 'Not found'}), 404
return jsonify(badge)
# --- Admin endpoints ---
@status_pages_bp.route('/', methods=['GET'])
@jwt_required()
def list_pages():
pages = StatusPageService.list_pages()
return jsonify({'pages': [p.to_dict() for p in pages]})
@status_pages_bp.route('/<int:page_id>', methods=['GET'])
@jwt_required()
def get_page(page_id):
page = StatusPageService.get_page(page_id)
if not page:
return jsonify({'error': 'Not found'}), 404
return jsonify(page.to_dict())
@status_pages_bp.route('/', methods=['POST'])
@jwt_required()
def create_page():
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
if not data or 'name' not in data or 'slug' not in data:
return jsonify({'error': 'name and slug required'}), 400
try:
page = StatusPageService.create_page(data)
return jsonify(page.to_dict()), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
@status_pages_bp.route('/<int:page_id>', methods=['PUT'])
@jwt_required()
def update_page(page_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
page = StatusPageService.update_page(page_id, data)
if not page:
return jsonify({'error': 'Not found'}), 404
return jsonify(page.to_dict())
@status_pages_bp.route('/<int:page_id>', methods=['DELETE'])
@jwt_required()
def delete_page(page_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
if not StatusPageService.delete_page(page_id):
return jsonify({'error': 'Not found'}), 404
return jsonify({'message': 'Status page deleted'})
# --- Components ---
@status_pages_bp.route('/<int:page_id>/components', methods=['GET'])
@jwt_required()
def get_components(page_id):
page = StatusPageService.get_page(page_id)
if not page:
return jsonify({'error': 'Not found'}), 404
return jsonify({'components': [c.to_dict() for c in page.components.all()]})
@status_pages_bp.route('/<int:page_id>/components', methods=['POST'])
@jwt_required()
def create_component(page_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
try:
comp = StatusPageService.create_component(page_id, data)
return jsonify(comp.to_dict()), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
@status_pages_bp.route('/components/<int:comp_id>', methods=['PUT'])
@jwt_required()
def update_component(comp_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
comp = StatusPageService.update_component(comp_id, data)
if not comp:
return jsonify({'error': 'Not found'}), 404
return jsonify(comp.to_dict())
@status_pages_bp.route('/components/<int:comp_id>', methods=['DELETE'])
@jwt_required()
def delete_component(comp_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
if not StatusPageService.delete_component(comp_id):
return jsonify({'error': 'Not found'}), 404
return jsonify({'message': 'Component deleted'})
@status_pages_bp.route('/components/<int:comp_id>/check', methods=['POST'])
@jwt_required()
def run_check(comp_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
hc = StatusPageService.run_check(comp_id)
if not hc:
return jsonify({'error': 'Component not found'}), 404
return jsonify(hc.to_dict())
@status_pages_bp.route('/components/<int:comp_id>/history', methods=['GET'])
@jwt_required()
def get_history(comp_id):
hours = request.args.get('hours', 24, type=int)
checks = StatusPageService.get_check_history(comp_id, hours)
return jsonify({'checks': [c.to_dict() for c in checks]})
# --- Incidents ---
@status_pages_bp.route('/<int:page_id>/incidents', methods=['GET'])
@jwt_required()
def list_incidents(page_id):
page = StatusPageService.get_page(page_id)
if not page:
return jsonify({'error': 'Not found'}), 404
incidents = page.incidents.limit(50).all()
return jsonify({'incidents': [i.to_dict() for i in incidents]})
@status_pages_bp.route('/<int:page_id>/incidents', methods=['POST'])
@jwt_required()
def create_incident(page_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
if not data or 'title' not in data:
return jsonify({'error': 'title required'}), 400
incident = StatusPageService.create_incident(page_id, data)
return jsonify(incident.to_dict()), 201
@status_pages_bp.route('/incidents/<int:incident_id>', methods=['PUT'])
@jwt_required()
def update_incident(incident_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
incident = StatusPageService.update_incident(incident_id, data)
if not incident:
return jsonify({'error': 'Not found'}), 404
return jsonify(incident.to_dict())
@status_pages_bp.route('/incidents/<int:incident_id>', methods=['DELETE'])
@jwt_required()
def delete_incident(incident_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
if not StatusPageService.delete_incident(incident_id):
return jsonify({'error': 'Not found'}), 404
return jsonify({'message': 'Incident deleted'})
+13 -1
View File
@@ -28,7 +28,19 @@ def ensure_loadable():
def load(module_name):
"""Import a module from the extension backend (e.g. ``'cloudflare_service'``)."""
"""Import a module from the extension backend (e.g. ``'cloudflare_service'``).
Resolution order (plan 52 Ph2 cutover): a copy-installed extension first
(the registry install lands under the normal ``app.plugins`` path), then
the builtin registration (pre-cutover trees), then the historical core
location. Callers get ImportError when the extension truly is absent —
every caller is behind the extension's own routes, so absence is a 404
before this ever raises.
"""
try:
return importlib.import_module(f'{PKG}.{module_name}')
except ImportError:
pass
if ensure_loadable():
try:
return importlib.import_module(f'{PKG}.{module_name}')
+9 -4
View File
@@ -106,10 +106,10 @@ def _email_was_configured():
# Builtins auto-installed on upgrade ONLY when a usage predicate says the panel
# actually used the feature (D3/#34). Fresh installs and panels that never used
# the feature just see it in the Marketplace.
GATED_BUILTIN_SLUGS = {
'serverkit-email': _email_was_configured,
'serverkit-git': _gitea_was_used,
}
# Empty since the plan 52 Ph2 cutover — every gated slug rides the registry
# now. The dict (and its predicate wiring) stays: the next core->builtin
# conversion drops straight in.
GATED_BUILTIN_SLUGS = {}
def _has_command(cmd):
@@ -140,6 +140,11 @@ def _scanners_dir_populated():
# unmarked so the next boot retries; panels without the host tool just see
# the extension in the Marketplace.
REGISTRY_GATED_SLUGS = {
# serverkit-email / serverkit-git left the tree (plan 52 Ph2/Ph6
# cutover): their gated auto-installs ride the registry now, same
# predicates as before.
'serverkit-email': _email_was_configured,
'serverkit-git': _gitea_was_used,
'serverkit-fail2ban': _has_command('fail2ban-client'),
'serverkit-clamav': _has_command('clamscan'),
'serverkit-lynis': _has_command('lynis'),
+18 -6
View File
@@ -10,6 +10,7 @@ Handles sending notifications to various channels:
"""
import os
import html
import json
import requests
import smtplib
@@ -333,22 +334,33 @@ class NotificationService:
return {'success': True, 'message': 'No alerts to send'}
try:
# Build message
lines = [f"<b>🔔 ServerKit Alert</b>", f"<i>Host: {cls.get_hostname()}</i>", ""]
# parse_mode='HTML' means Telegram parses the body as markup, so every
# interpolated value has to be escaped. Unescaped, an alert message as
# ordinary as "Disk usage > 90% on /var & /tmp" makes the API reject
# the request with "can't parse entities" and the whole notification
# is dropped -- exactly when something is already wrong. Only the
# literal tags below are markup; everything substituted in is data.
def esc(value):
return html.escape(str(value), quote=False)
lines = [f"<b>🔔 ServerKit Alert</b>", f"<i>Host: {esc(cls.get_hostname())}</i>", ""]
for alert in alerts:
severity = alert.get('severity', 'info')
color_info = cls.SEVERITY_COLORS.get(severity, cls.SEVERITY_COLORS['info'])
lines.append(f"{color_info['emoji']} <b>{severity.upper()}: {alert.get('type', 'Alert').upper()}</b>")
lines.append(alert.get('message', 'No message'))
lines.append(
f"{color_info['emoji']} <b>{esc(severity.upper())}: "
f"{esc(alert.get('type', 'Alert').upper())}</b>"
)
lines.append(esc(alert.get('message', 'No message')))
if 'value' in alert or 'threshold' in alert:
details = []
if 'value' in alert:
details.append(f"Current: {alert['value']}")
details.append(f"Current: {esc(alert['value'])}")
if 'threshold' in alert:
details.append(f"Threshold: {alert['threshold']}")
details.append(f"Threshold: {esc(alert['threshold'])}")
lines.append(f"<code>{' | '.join(details)}</code>")
lines.append("")
+1 -1
View File
@@ -72,7 +72,7 @@ BUILTIN_EXTENSIONS_DIR = os.environ.get(
# onboarding via RECOMMENDED_EXTENSIONS_BY_USE_CASE. Its uninstall-marker
# semantics are preserved (the settings key machinery below is untouched), so
# a user who uninstalled WP before the extraction is never re-seeded.
FLAGSHIP_SLUGS = ['serverkit-cloudflare-ops']
FLAGSHIP_SLUGS = [] # cloudflare-ops left the tree (plan 52 Ph2 cutover); machinery kept for future flagships
_FLAGSHIP_UNINSTALLED_KEY = 'extensions.flagship_uninstalled'
# Wizard-optional flagships (plan 47): offered — not force-installed — during
+1 -1
View File
@@ -1 +1 @@
4799
4791
+1 -1
View File
@@ -1 +1 @@
577
608
+78
View File
@@ -229,6 +229,7 @@ def app(request, _flask_app):
needs_private_app = (
request.node.get_closest_marker('fresh_app') is not None
or 'wp_extension' in request.fixturenames
or 'cf_extension' in request.fixturenames
)
target = create_app('testing') if needs_private_app else _flask_app
@@ -379,6 +380,83 @@ def scoping_rbac(app):
# ServerKit clone's suite must pass without the extension source.
def sibling_extension_dir(slug, env_var=None):
"""A standalone extension repo's checkout dir, or None (plan 52 cutover).
Resolution mirrors _wp_ext_tests_dir: an explicit env override first
(SERVERKIT_<SLUG>_DIR, dashes/prefix dropped), then the default sibling
checkout next to the ServerKit workspace. Suites that exercise an
extracted extension mount it from here and skip cleanly when absent.
"""
env_name = env_var or 'SERVERKIT_{}_DIR'.format(
slug.replace('serverkit-', '').replace('-', '_').upper())
env = os.environ.get(env_name)
candidates = [env] if env else []
candidates.append(os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))), slug))
for cand in candidates:
if cand and os.path.isfile(os.path.join(cand, 'plugin.json')):
return cand
return None
def mount_sibling_extension(app, slug, url_prefix, entry_module, entry_attr,
display_name=None):
"""Mirror a real install of an extracted extension from its sibling repo:
register its backend dir as the ``app.plugins.<slug>`` package, seed an
active InstalledPlugin row (manifest included), and mount its blueprint.
The plan 52 cutover analogue of _wp_support.ensure_plugin."""
import importlib.util
import json as _json
src = sibling_extension_dir(slug)
if not src:
pytest.skip(f'{slug} checkout not available '
f'(set SERVERKIT_{slug.replace("serverkit-", "").replace("-", "_").upper()}_DIR)')
backend_dir = os.path.join(src, 'backend')
pkg = f'app.plugins.{slug}'
if pkg not in sys.modules:
spec = importlib.util.spec_from_file_location(
pkg, os.path.join(backend_dir, '__init__.py'),
submodule_search_locations=[backend_dir])
module = importlib.util.module_from_spec(spec)
sys.modules[pkg] = module
spec.loader.exec_module(module)
from app import db
from app.models.plugin import InstalledPlugin
with open(os.path.join(src, 'plugin.json'), encoding='utf-8') as f:
manifest = _json.load(f)
plugin = InstalledPlugin.query.filter_by(slug=slug).first()
if not plugin:
plugin = InstalledPlugin(
name=slug, display_name=display_name or manifest.get('display_name', slug),
slug=slug, version=manifest.get('version', '0.0.0'),
status=InstalledPlugin.STATUS_ACTIVE,
has_backend=True, url_prefix=url_prefix,
)
db.session.add(plugin)
plugin.status = InstalledPlugin.STATUS_ACTIVE
plugin.manifest = manifest
db.session.commit()
mod = __import__(f'{pkg}.{entry_module}', fromlist=[entry_attr])
bp = getattr(mod, entry_attr)
if bp.name not in app.blueprints:
app.register_blueprint(bp, url_prefix=url_prefix)
return plugin
@pytest.fixture
def cf_extension(app):
"""serverkit-cloudflare-ops mounted from its sibling checkout (skip when
absent). Structure-mutating — forces a private app like wp_extension."""
return mount_sibling_extension(
app, 'serverkit-cloudflare-ops', '/api/v1/cloudflare',
'cloudflare', 'cloudflare_bp', display_name='Cloudflare Zone Ops')
def _wp_ext_tests_dir():
"""The standalone serverkit-wordpress repo's tests/ dir, or None."""
env = os.environ.get('SERVERKIT_WORDPRESS_DIR')
+59 -9
View File
@@ -33,11 +33,32 @@ Usage (from ``backend/``)::
import ast
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
BACKEND = os.path.dirname(HERE)
API_DIR = os.path.join(BACKEND, 'app', 'api')
# Extension routes are the same authenticated surface as core routes, and were
# invisible here until 2026-08-31: the serverkit-status blueprint carried 15
# bare-@jwt_required() routes this census never saw, so the ratchet could not
# object when a review proposed converting them wholesale.
#
# They are counted from builtin-extensions/ (the SOURCE), not app/plugins/ (the
# live copy the loader imports). The two are pinned equal by
# test_builtin_extension_drift.py, so either would give the same answer on a
# clean checkout -- but app/plugins/ is mostly GITIGNORED, so counting it makes
# the number depend on which extensions a given box happens to have installed.
# That is not hypothetical: it shipped a ceiling of 611 from a dev box where CI
# measured 591.
#
# For the same reason the population is filtered to git-TRACKED files: a stale
# ignored directory left behind by an extraction (cloudflare-ops, git, localkit
# after plan 52) still sits on some working copies and would otherwise be
# counted. Without git (release tarball, Docker build) the tree has no such
# leftovers and the unfiltered walk agrees.
EXT_DIR = os.path.join(BACKEND, os.pardir, 'builtin-extensions')
SCAN_DIRS = (API_DIR, EXT_DIR)
CEILING_FILE = os.path.join(HERE, 'JWT_ONLY_CEILING')
SKIP_DIRS = {'__pycache__'}
@@ -70,17 +91,46 @@ def count_file(path):
return sorted(found)
REPO = os.path.dirname(BACKEND)
def _repo_rel(path):
return os.path.relpath(path, REPO).replace(os.sep, '/')
def _tracked_files():
"""Repo-relative paths git tracks, or None when git cannot answer.
Run from the repo ROOT, not backend/: `git ls-files` only lists paths under
its working directory, so from backend/ the builtin-extensions entries are
absent and every extension file would be filtered out as "untracked".
"""
try:
proc = subprocess.run(['git', 'ls-files'], cwd=REPO,
capture_output=True, text=True)
except OSError:
return None
if proc.returncode != 0:
return None
return {line.strip() for line in proc.stdout.splitlines() if line.strip()}
def census():
found = {}
for dirpath, dirnames, filenames in os.walk(API_DIR):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for name in sorted(filenames):
if not name.endswith('.py'):
continue
path = os.path.join(dirpath, name)
hits = count_file(path)
if hits:
found[_rel(path)] = hits
tracked = _tracked_files()
for root in SCAN_DIRS:
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for name in sorted(filenames):
if not name.endswith('.py'):
continue
path = os.path.join(dirpath, name)
if tracked is not None and _repo_rel(path) not in tracked:
continue
rel = _rel(path)
hits = count_file(path)
if hits:
found[rel] = hits
return found
+11 -10
View File
@@ -13,11 +13,11 @@ Written while drift is **zero** (21 file pairs). That is the point — this lock
in a clean invariant rather than repairing a dirty one, and it only stays cheap
while that is true.
Scope note: only pairs present on BOTH sides are compared. Several extensions
legitimately have one side only — cloudflare-ops and localkit ship source with
no materialised live copy, serverkit-git uses a different layout — and most live
copies are gitignored, so a fresh clone sees fewer pairs than a dev machine.
`test_drift_check_is_not_vacuous` stops that from quietly reducing to nothing.
Scope note: only pairs present on BOTH sides are compared. Some extensions
legitimately have one side only, and most live copies are gitignored, so a
fresh clone sees fewer pairs than a dev machine. serverkit-status is the one
live copy git tracks, so a fresh clone always has at least one comparable
pair; `test_drift_check_is_not_vacuous` stops that reducing to nothing.
That machine-dependence is also why the test-count ratchet runs collection with
`SERVERKIT_CLEAN_COLLECT=1` — see `CLEAN_COLLECT` below.
@@ -149,15 +149,16 @@ def test_drift_check_is_not_vacuous():
"""Guard the guard.
Most live copies are gitignored, so a fresh clone sees far fewer pairs than
a dev machine. serverkit-email's live copy predates that ignore rule and is
tracked, so at least one extension must always be comparable. Without this,
a reorganisation could silently reduce the parametrised test above to zero
cases and it would still report green.
a dev machine. serverkit-status's live copy is deliberately tracked (see the
note in .gitignore), so at least one extension must always be comparable.
Without this, a reorganisation could silently reduce the parametrised test
above to zero cases and it would still report green -- which is exactly what
happened when serverkit-email, the previous anchor, left the tree in plan 52.
"""
assert PAIRS, (
'No extension had both a builtin-extensions/<ext>/backend and an '
'app/plugins/<ext> copy, so the drift check compared nothing. Either '
'the layout moved or the tracked serverkit-email copy is gone.'
'the layout moved or the tracked serverkit-status copy is gone.'
)
+19 -15
View File
@@ -1,9 +1,11 @@
"""Prove the Cloudflare zone-ops extraction (Phase 5, #36).
"""Prove the Cloudflare zone-ops extraction (Phase 5, #36; standalone repo
since the plan 52 Ph2 cutover).
Zone-ops (the /api/v1/cloudflare blueprint + CloudflareService) physically moved
into the bundled, default-installed serverkit-cloudflare-ops extension (a flagship,
like WordPress). The DNS layer stays core: the moved service borrows the single
core CloudflareClient via DNSZoneService — there is no duplicate Cloudflare client.
Zone-ops (the /api/v1/cloudflare blueprint + CloudflareService) lives in the
standalone serverkit-cloudflare-ops repo now — no longer a seeded flagship. A
lean panel boots with no cloudflare routes; the suite mounts the extension
from the sibling checkout (skip when absent). The DNS layer stays core: the
moved service borrows the single core CloudflareClient via DNSZoneService.
"""
import importlib
@@ -27,7 +29,7 @@ def test_dns_client_stays_core_single_source():
assert CloudflareClient.__module__ == 'app.services.dns.cloudflare'
def test_bridge_resolves_service_from_extension():
def test_bridge_resolves_service_from_extension(cf_extension):
CloudflareService = cloudflare_ops_bridge.cloudflare_service()
assert CloudflareService.__name__ == 'CloudflareService'
assert CloudflareService.__module__.startswith(f'app.plugins.{SLUG}')
@@ -38,22 +40,24 @@ def test_bridge_resolves_service_from_extension():
assert 'DNSZoneService._resolve_credential' in src
def test_cloudflare_seeded_as_flagship(app):
plugin = InstalledPlugin.query.filter_by(slug=SLUG).first()
assert plugin is not None, 'Cloudflare zone-ops flagship should be seeded on boot'
assert plugin.status == InstalledPlugin.STATUS_ACTIVE
assert plugin.url_prefix == '/api/v1/cloudflare'
@pytest.mark.fresh_app
def test_cloudflare_is_not_seeded_on_a_lean_panel(app):
"""The flagship era ended with the cutover: a fresh panel has no
cloudflare-ops row and no zone routes until a registry install."""
assert InstalledPlugin.query.filter_by(slug=SLUG).first() is None
rules = {r.rule for r in app.url_map.iter_rules()}
assert not any(r.startswith('/api/v1/cloudflare/') for r in rules)
def test_cloudflare_blueprint_registered_from_extension(app):
def test_cloudflare_blueprint_registered_from_extension(cf_extension, app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert any(r.startswith('/api/v1/cloudflare/zones') for r in rules), \
'Cloudflare zone-ops API should be registered from the extension'
def test_cloudflare_api_reachable_not_404(app, client, auth_headers):
def test_cloudflare_api_reachable_not_404(cf_extension, app, client, auth_headers):
"""The extension blueprint is live (not 404) and the status guard passes for
the active flagship (not 503). It may 400/502 without a real CF zone — we only
assert the route is wired."""
the mounted extension (not 503). It may 400/502 without a real CF zone — we
only assert the route is wired."""
resp = client.get('/api/v1/cloudflare/zones/1/settings', headers=auth_headers)
assert resp.status_code not in (404, 503), resp.status_code
+8
View File
@@ -11,6 +11,14 @@ import types
import pytest
# The zone-ops backend lives in the standalone serverkit-cloudflare-ops repo
# since the plan 52 Ph2 cutover; every test here runs against the extension
# mounted from the sibling checkout (module skips when it is absent).
@pytest.fixture(autouse=True)
def _mounted_cf(cf_extension):
yield
class _Resp:
def __init__(self, js, status=200):
@@ -10,6 +10,14 @@ imported ``requests`` module (the generic helper uses ``requests.request``).
"""
import pytest
# The zone-ops backend lives in the standalone serverkit-cloudflare-ops repo
# since the plan 52 Ph2 cutover; every test here runs against the extension
# mounted from the sibling checkout (module skips when it is absent).
@pytest.fixture(autouse=True)
def _mounted_cf(cf_extension):
yield
class _Resp:
def __init__(self, js, status=200):
+10 -2
View File
@@ -22,8 +22,16 @@ from pathlib import Path
import pytest
_DOVECOT = (Path(__file__).resolve().parent.parent
/ 'app' / 'plugins' / 'serverkit-email' / 'dovecot_service.py')
from tests.conftest import sibling_extension_dir
_EMAIL_DIR = sibling_extension_dir('serverkit-email')
_DOVECOT = (Path(_EMAIL_DIR) / 'backend' / 'dovecot_service.py'
if _EMAIL_DIR else None)
pytestmark = pytest.mark.skipif(
_DOVECOT is None or not _DOVECOT.is_file(),
reason='serverkit-email checkout not available '
'(set SERVERKIT_EMAIL_DIR to its checkout)')
def _load():
+27 -10
View File
@@ -1,7 +1,8 @@
"""Prove the Email extraction (Phase 4, #35).
"""Prove the Email extraction (Phase 4, #35; standalone repo since plan 52 Ph2).
A core panel no longer carries the mail-server API; installing the serverkit-email
builtin registers `/api/v1/email` and its routes respond (which also exercises the
A core panel no longer carries the mail-server API; installing serverkit-email
(now a standalone repo — mounted from the sibling checkout, skip when absent)
registers `/api/v1/email` and its routes respond (which also exercises the
extension's relative-import rewiring). Uninstall removes it again.
"""
import sys
@@ -10,6 +11,7 @@ import pytest
import app as app_pkg
from app.models.plugin import InstalledPlugin
from tests.conftest import sibling_extension_dir
from app.services import plugin_service
# Installs plugins, and plugin_service hot-loads their blueprints onto the
@@ -62,12 +64,27 @@ def install_dirs(tmp_path, monkeypatch):
del sys.modules[name]
def test_install_email_extension_registers_routes(app, client, auth_headers, install_dirs):
# The real builtin folder is discovered from the repo's builtin-extensions/.
available = {e['slug'] for e in plugin_service.list_builtin_extensions()}
assert SLUG in available, 'serverkit-email builtin folder should exist'
def _install_email_from_sibling(monkeypatch):
src = sibling_extension_dir(SLUG)
if not src:
pytest.skip('serverkit-email checkout not available '
'(set SERVERKIT_EMAIL_DIR to its checkout)')
# The sibling repo pins min_panel_version to the first SDK-1.4.0 release;
# the working tree may still carry the previous version string.
from app.utils import version as version_mod
monkeypatch.setattr(version_mod, 'get_panel_version', lambda: '1.9.11')
monkeypatch.setattr(plugin_service, 'get_panel_version',
lambda: '1.9.11', raising=False)
return plugin_service.install_from_path(src, force=True)
plugin = plugin_service.install_builtin_extension('serverkit-email')
def test_email_is_no_longer_a_builtin(app):
available = {e['slug'] for e in plugin_service.list_builtin_extensions()}
assert SLUG not in available, 'serverkit-email left the tree (plan 52 Ph2)'
def test_install_email_extension_registers_routes(app, client, auth_headers, install_dirs, monkeypatch):
plugin = _install_email_from_sibling(monkeypatch)
assert plugin.status == InstalledPlugin.STATUS_ACTIVE
assert plugin.has_backend is True
assert plugin.url_prefix == '/api/v1/email'
@@ -79,7 +96,7 @@ def test_install_email_extension_registers_routes(app, client, auth_headers, ins
assert resp.status_code not in (404, 503), resp.status_code
def test_uninstall_removes_email_plugin(app, install_dirs):
plugin = plugin_service.install_builtin_extension('serverkit-email')
def test_uninstall_removes_email_plugin(app, install_dirs, monkeypatch):
plugin = _install_email_from_sibling(monkeypatch)
assert plugin_service.uninstall_plugin(plugin.id) is True
assert InstalledPlugin.query.filter_by(slug=SLUG).first() is None
+15 -2
View File
@@ -48,6 +48,18 @@ ALLOWLIST = {
BUILTIN_EXTENSIONS_DIR = (Path(__file__).resolve().parents[2]
/ 'builtin-extensions')
# Prefixes owned by EXTRACTED (standalone-repo) extensions that core clients
# still probe fail-soft — the plan 52 cutover deleted their builtin dirs, so
# their plugin.json can no longer vouch for them. Kept explicit and minimal:
# each entry names the extension serving the prefix when installed. The
# core callers behind them degrade to "not installed" on 404 by design
# (Settings relay tile, the app-creation repo pickers).
EXTRACTED_EXTENSION_PREFIXES = {
'/git': 'serverkit-git',
'/email': 'serverkit-email',
'/cloudflare': 'serverkit-cloudflare-ops',
}
def _extension_prefixes():
"""/api/v1-relative url_prefix of every builtin extension.
@@ -55,9 +67,10 @@ def _extension_prefixes():
Paths under these prefixes are owned by extensions the session app may
not have loaded, so an unresolved path there is excused — but only
while a plugin.json still declares the prefix, so a removed extension
turns its frontend calls back into failures."""
turns its frontend calls back into failures. Extracted extensions keep
their prefixes excused via EXTRACTED_EXTENSION_PREFIXES above."""
import json
prefixes = {}
prefixes = dict(EXTRACTED_EXTENSION_PREFIXES)
for manifest in sorted(BUILTIN_EXTENSIONS_DIR.glob('*/plugin.json')):
prefix = json.loads(manifest.read_text(encoding='utf-8')).get('url_prefix', '')
if prefix.startswith('/api/v1/'):
@@ -0,0 +1,106 @@
"""Telegram alerts are sent with parse_mode='HTML', so every interpolated
value has to be escaped.
Unescaped, an alert message as ordinary as "Disk usage > 90% on /var & /tmp"
makes the Bot API reject the request with "can't parse entities" and the whole
notification is dropped — precisely when something is already wrong on the box.
These pin the escaping and that the literal markup survives it.
"""
from unittest.mock import patch
from app.services.notification_service import NotificationService
TELEGRAM_CONFIG = {
'enabled': True,
'bot_token': 'test-token',
'chat_id': '123456',
'notify_on': ['critical', 'warning', 'info'],
}
class _FakeResponse:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _send(alerts, hostname='test-host'):
"""Run send_telegram against a stubbed API and return the posted payload."""
captured = {}
def fake_post(url, json=None, timeout=None):
captured['url'] = url
captured['payload'] = json
return _FakeResponse({'ok': True})
with patch('app.services.notification_service.requests.post', side_effect=fake_post), \
patch.object(NotificationService, 'get_hostname', return_value=hostname):
result = NotificationService.send_telegram(alerts, config=dict(TELEGRAM_CONFIG))
assert result['success'] is True, result
return captured['payload']
def test_alert_message_with_html_metacharacters_is_escaped():
"""The regression: > and & in a message used to reach Telegram raw."""
payload = _send([{
'severity': 'warning',
'type': 'disk',
'message': 'Disk usage > 90% on /var & /tmp',
}])
text = payload['text']
assert 'Disk usage &gt; 90% on /var &amp; /tmp' in text
# The raw form must be gone, or the API rejects the whole message.
assert 'Disk usage > 90%' not in text
def test_angle_bracket_payload_cannot_inject_markup():
"""A value that looks like a tag is data, not markup."""
payload = _send([{
'severity': 'critical',
'type': 'service',
'message': 'container <b>web</b> exited',
}])
text = payload['text']
assert '&lt;b&gt;web&lt;/b&gt;' in text
assert '<b>web</b>' not in text
def test_hostname_and_threshold_values_are_escaped():
"""Every interpolated field, not just the message."""
payload = _send(
[{
'severity': 'warning',
'type': 'cpu',
'message': 'high load',
'value': '95% > limit',
'threshold': '90% & rising',
}],
hostname='box<1>&2',
)
text = payload['text']
assert 'box&lt;1&gt;&amp;2' in text
assert 'Current: 95% &gt; limit' in text
assert 'Threshold: 90% &amp; rising' in text
def test_literal_markup_and_parse_mode_survive_escaping():
"""Escaping data must not escape the template's own tags."""
payload = _send([{
'severity': 'info',
'type': 'test',
'message': 'plain message',
}])
text = payload['text']
assert payload['parse_mode'] == 'HTML'
assert '<b>🔔 ServerKit Alert</b>' in text
assert '<i>Host: test-host</i>' in text
assert '<b>INFO: TEST</b>' in text
@@ -117,20 +117,24 @@ def test_finalize_setup_flagships_marks_uninstalled_when_absent(app, monkeypatch
def test_finalize_setup_flagships_keeps_installed(app, monkeypatch):
"""An INSTALLED wizard-optional flagship is never marked uninstalled."""
monkeypatch.setattr(ps, 'WIZARD_OPTIONAL_FLAGSHIP_SLUGS', ['serverkit-cloudflare-ops'])
"""An INSTALLED wizard-optional flagship is never marked uninstalled.
FLAGSHIP_SLUGS is empty since the cloudflare-ops cutover (plan 52 Ph2),
so the machinery is proven with a fabricated installed row."""
monkeypatch.setattr(ps, 'WIZARD_OPTIONAL_FLAGSHIP_SLUGS', ['serverkit-demo-opt'])
with app.app_context():
# the flagship seeder keeps cloudflare-ops installed under TESTING
assert InstalledPlugin.query.filter_by(slug='serverkit-cloudflare-ops').first()
db.session.add(InstalledPlugin(
name='serverkit-demo-opt', display_name='Demo', slug='serverkit-demo-opt',
version='1.0.0', status=InstalledPlugin.STATUS_ACTIVE))
db.session.commit()
ps.finalize_setup_flagships()
assert 'serverkit-cloudflare-ops' not in ps._flagship_uninstalled_set()
assert 'serverkit-demo-opt' not in ps._flagship_uninstalled_set()
def test_wordpress_not_seeded_post_extraction(app):
"""Plan 52 Phase 5: WordPress is a registry extension now — the boot-time
flagship seeder never installs it (fresh OR completed setup), while the
remaining in-tree flagship (cloudflare-ops) still seeds. The wizard offers
WP through the use-case map instead (proven above)."""
"""Plan 52 Phase 5 + Ph2 cutover: WordPress AND cloudflare-ops are
registry extensions now — the boot-time flagship seeder (an empty list)
installs neither. The wizard offers them through the catalog instead."""
with app.app_context():
InstalledPlugin.query.filter_by(slug='serverkit-wordpress').delete()
db.session.commit()
@@ -140,7 +144,7 @@ def test_wordpress_not_seeded_post_extraction(app):
assert InstalledPlugin.query.filter_by(
slug='serverkit-wordpress').first() is None
assert InstalledPlugin.query.filter_by(
slug='serverkit-cloudflare-ops').first() is not None
slug='serverkit-cloudflare-ops').first() is None
finally:
app.config['TESTING'] = True
@@ -0,0 +1,108 @@
"""A JSON body of literal `null` must not 500 the status-page routes.
Flask already answers the two obvious bad bodies itself — an empty body is a
400 and a wrong content-type is a 415 — so those were never the exposure. The
one that reaches the view is `null`, which is valid JSON: `get_json()` returns
None, and the service then does `if field in data` and raises TypeError.
Measured on Flask 3.1.3:
body='null' + application/json -> get_json() is None
body='' + application/json -> 400 (raised by Flask)
body='x' + text/plain -> 415 (raised by Flask)
"""
import importlib.util
import pathlib
import re
import pytest
EXT_DIR = pathlib.Path(__file__).resolve().parent.parent / 'app' / 'plugins' / 'serverkit-status'
def _load(module_name, filename):
"""Load a module out of the hyphenated plugin dir (not importable normally)."""
spec = importlib.util.spec_from_file_location(module_name, EXT_DIR / filename)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_service_still_assumes_a_dict(app):
"""Pins WHY the route guard matters: the service has no defence of its own."""
service = _load('_sk_status_service', 'status_page_service.py').StatusPageService
with app.app_context():
# The crash only happens past the not-found early return, so the row
# has to exist — with no page, update_page returns None harmlessly and
# the test would pass for the wrong reason.
page = service.create_page({'name': 'Probe', 'slug': 'probe-null-body'})
# None is exactly what an unguarded request.get_json() handed it.
with pytest.raises(TypeError):
service.update_page(page.id, None)
# The value the route now passes instead is safe.
assert service.update_page(page.id, {}) is not None
def test_no_route_passes_a_possibly_none_body_to_the_service():
"""Every call site either coerces None away or rejects a falsy body inline."""
src = (EXT_DIR / 'status_pages.py').read_text(encoding='utf-8')
lines = src.splitlines()
assert 'request.get_json()' in src, 'no get_json call sites — did the file move?'
unguarded = []
for i, line in enumerate(lines):
if line.strip() == 'data = request.get_json()':
following = ' '.join(lines[i + 1:i + 3])
if 'if not data' not in following:
unguarded.append(i + 1)
assert not unguarded, (
f'status_pages.py lines {unguarded} pass a possibly-None body straight '
'into the service; use `request.get_json() or {}` or reject it inline.'
)
def test_live_copy_and_source_agree_on_the_guard():
"""The extension exists twice on disk; a one-sided fix is the classic drift."""
repo = pathlib.Path(__file__).resolve().parent.parent.parent
source = repo / 'builtin-extensions' / 'serverkit-status' / 'backend' / 'status_pages.py'
if not source.is_file():
pytest.skip('builtin-extensions source copy not present in this checkout')
def norm(p):
return p.read_bytes().replace(b'\r\n', b'\n')
assert norm(EXT_DIR / 'status_pages.py') == norm(source), (
'status_pages.py differs between app/plugins/ and builtin-extensions/ — '
'the loader imports the live copy, so edit BOTH.'
)
def test_flask_still_handles_the_bodies_we_rely_on_it_for():
"""If Flask ever stopped raising on these, the routes would need their own
handling — this is the assumption the guard is scoped against.
Deliberately a throwaway Flask app rather than the shared `app` fixture:
adding a route there leaks into every route-level sweep in the suite (the
mutating-route authz sweep would see an unauthenticated PUT and fail).
"""
from flask import Flask, request
probe = Flask(__name__)
@probe.route('/probe', methods=['PUT'])
def _probe(): # pragma: no cover - exercised via the client below
return {'is_none': request.get_json() is None}
client = probe.test_client()
assert client.put('/probe', data='null',
content_type='application/json').get_json() == {'is_none': True}
assert client.put('/probe', data='',
content_type='application/json').status_code == 400
assert client.put('/probe', data='x',
content_type='text/plain').status_code == 415
@@ -1 +0,0 @@
"""ServerKit Cloudflare zone-ops extension backend package."""
@@ -1,547 +0,0 @@
"""Cloudflare operations API — zone settings (SSL/TLS, Speed, Caching, Security)
and one-click hardening, on top of the existing Cloudflare DNS connection.
Zones are addressed by their ServerKit ``DNSZone`` id (same as the ``/dns`` API);
the service resolves the Cloudflare credential + zone id server-side. Reads are
available to any authenticated user; writes require admin. Every mutating call is
captured by the global audit fallback (method, route args, sanitized body).
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.middleware.rbac import require_admin_user
from .cloudflare_service import CloudflareService, CloudflareError
cloudflare_bp = Blueprint('cloudflare', __name__)
def _service_response(res):
"""Map a service result dict to JSON. A failed *provider* call (reached
Cloudflare, got an error back) is a 502; resolution errors are raised as
CloudflareError upstream and handled separately as 400s."""
if not res.get('success'):
return jsonify({'error': res.get('error', 'Cloudflare request failed')}), 502
return jsonify(res)
# ── Zone settings ────────────────────────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/settings', methods=['GET'])
@jwt_required()
def get_zone_settings(zone_id):
try:
res = CloudflareService.get_settings(zone_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/settings/apply-preset', methods=['POST'])
@jwt_required()
def apply_preset(zone_id):
require_admin_user()
try:
res = CloudflareService.apply_recommended(zone_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
# A preset is best-effort across many toggles; return 200 with the per-setting
# report even when the plan gated some (the UI surfaces partial success).
return jsonify(res)
@cloudflare_bp.route('/zones/<int:zone_id>/settings/<setting_id>', methods=['GET'])
@jwt_required()
def get_zone_setting(zone_id, setting_id):
try:
res = CloudflareService.get_setting(zone_id, setting_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/settings/<setting_id>', methods=['PATCH'])
@jwt_required()
def update_zone_setting(zone_id, setting_id):
require_admin_user()
data = request.get_json(silent=True) or {}
if 'value' not in data:
return jsonify({'error': 'A "value" field is required'}), 400
try:
res = CloudflareService.update_setting(zone_id, setting_id, data['value'])
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# ── Cache ────────────────────────────────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/purge-cache', methods=['POST'])
@jwt_required()
def purge_cache(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.purge_cache(
zone_id,
everything=bool(data.get('purge_everything') or data.get('everything')),
files=data.get('files'), hosts=data.get('hosts'),
prefixes=data.get('prefixes'), tags=data.get('tags'))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# ── WAF custom rules ─────────────────────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/waf/rules', methods=['GET'])
@jwt_required()
def list_waf_rules(zone_id):
try:
res = CloudflareService.list_waf_rules(zone_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/waf/rules', methods=['POST'])
@jwt_required()
def add_waf_rule(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.add_waf_rule(
zone_id,
description=data.get('description'),
expression=data.get('expression'),
action=data.get('action'),
enabled=data.get('enabled', True))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/waf/presets/<preset_key>', methods=['POST'])
@jwt_required()
def apply_waf_preset(zone_id, preset_key):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.apply_waf_preset(zone_id, preset_key, data.get('params') or {})
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/waf/rulesets/<ruleset_id>/rules/<rule_id>',
methods=['PATCH'])
@jwt_required()
def update_waf_rule(zone_id, ruleset_id, rule_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.update_waf_rule(zone_id, ruleset_id, rule_id, data)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/waf/rulesets/<ruleset_id>/rules/<rule_id>',
methods=['DELETE'])
@jwt_required()
def delete_waf_rule(zone_id, ruleset_id, rule_id):
require_admin_user()
try:
res = CloudflareService.delete_waf_rule(zone_id, ruleset_id, rule_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# ── Workers (edge hosting) ───────────────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/workers', methods=['GET'])
@jwt_required()
def list_workers(zone_id):
try:
res = CloudflareService.list_workers(zone_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/workers', methods=['POST'])
@jwt_required()
def deploy_worker(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.deploy_worker(
zone_id,
name=data.get('name'),
code=data.get('code'),
compatibility_date=data.get('compatibility_date'),
route_pattern=data.get('route_pattern'))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/workers/routes', methods=['POST'])
@jwt_required()
def add_worker_route(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.add_worker_route(zone_id, data.get('pattern'), data.get('script'))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/workers/routes/<route_id>', methods=['DELETE'])
@jwt_required()
def delete_worker_route(zone_id, route_id):
require_admin_user()
try:
res = CloudflareService.delete_worker_route(zone_id, route_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# Note: this dynamic <name> route is registered last so it can't shadow the more
# specific /workers/routes paths above.
@cloudflare_bp.route('/zones/<int:zone_id>/workers/<name>', methods=['DELETE'])
@jwt_required()
def delete_worker(zone_id, name):
require_admin_user()
try:
res = CloudflareService.delete_worker(zone_id, name)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# ── Tunnels (cloudflared) ────────────────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/tunnels', methods=['GET'])
@jwt_required()
def list_tunnels(zone_id):
try:
res = CloudflareService.list_tunnels(zone_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/tunnels', methods=['POST'])
@jwt_required()
def create_tunnel(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.create_tunnel(zone_id, data.get('name'))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/tunnels/<tunnel_id>/install', methods=['GET'])
@jwt_required()
def tunnel_install(zone_id, tunnel_id):
require_admin_user()
try:
res = CloudflareService.get_tunnel_install(zone_id, tunnel_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/tunnels/<tunnel_id>/hostnames', methods=['GET'])
@jwt_required()
def tunnel_hostnames(zone_id, tunnel_id):
try:
res = CloudflareService.get_tunnel_hostnames(zone_id, tunnel_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/tunnels/<tunnel_id>/hostnames', methods=['POST'])
@jwt_required()
def add_tunnel_hostname(zone_id, tunnel_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.add_tunnel_hostname(
zone_id, tunnel_id, data.get('hostname'), data.get('service'))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/tunnels/<tunnel_id>/hostnames', methods=['DELETE'])
@jwt_required()
def remove_tunnel_hostname(zone_id, tunnel_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.remove_tunnel_hostname(zone_id, tunnel_id, data.get('hostname'))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/tunnels/<tunnel_id>', methods=['DELETE'])
@jwt_required()
def delete_tunnel(zone_id, tunnel_id):
require_admin_user()
try:
res = CloudflareService.delete_tunnel(zone_id, tunnel_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# ── Developer platform: R2 / KV / D1 ─────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/storage', methods=['GET'])
@jwt_required()
def list_storage(zone_id):
try:
res = CloudflareService.list_storage(zone_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/storage/r2', methods=['POST'])
@jwt_required()
def create_r2_bucket(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.create_r2_bucket(zone_id, data.get('name'))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/storage/r2/<bucket>', methods=['DELETE'])
@jwt_required()
def delete_r2_bucket(zone_id, bucket):
require_admin_user()
try:
res = CloudflareService.delete_r2_bucket(zone_id, bucket)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/storage/kv', methods=['POST'])
@jwt_required()
def create_kv_namespace(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.create_kv_namespace(zone_id, data.get('title'))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/storage/kv/<namespace_id>', methods=['DELETE'])
@jwt_required()
def delete_kv_namespace(zone_id, namespace_id):
require_admin_user()
try:
res = CloudflareService.delete_kv_namespace(zone_id, namespace_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/storage/d1', methods=['POST'])
@jwt_required()
def create_d1_database(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.create_d1_database(zone_id, data.get('name'))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/storage/d1/<database_id>', methods=['DELETE'])
@jwt_required()
def delete_d1_database(zone_id, database_id):
require_admin_user()
try:
res = CloudflareService.delete_d1_database(zone_id, database_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# ── DNSSEC ────────────────────────────────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/dnssec', methods=['GET'])
@jwt_required()
def get_dnssec(zone_id):
try:
res = CloudflareService.get_dnssec(zone_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/dnssec', methods=['PATCH'])
@jwt_required()
def set_dnssec(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
if 'enabled' not in data:
return jsonify({'error': 'An "enabled" boolean is required'}), 400
try:
res = CloudflareService.set_dnssec(zone_id, bool(data['enabled']))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# ── Origin CA certificates ─────────────────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/origin-certificates', methods=['GET'])
@jwt_required()
def list_origin_certificates(zone_id):
try:
res = CloudflareService.list_origin_certificates(zone_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/origin-certificates', methods=['POST'])
@jwt_required()
def issue_origin_certificate(zone_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.issue_origin_certificate(
zone_id,
hostnames=data.get('hostnames'),
validity_days=int(data.get('validity_days', 5475)),
install=bool(data.get('install', True)))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
except (TypeError, ValueError):
return jsonify({'error': 'validity_days must be a number'}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/origin-certificates/<certificate_id>',
methods=['DELETE'])
@jwt_required()
def revoke_origin_certificate(zone_id, certificate_id):
require_admin_user()
try:
res = CloudflareService.revoke_origin_certificate(zone_id, certificate_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# ── Redirect + Transform rules ─────────────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/rules/<slug>', methods=['GET'])
@jwt_required()
def list_rules(zone_id, slug):
try:
res = CloudflareService.list_rules(zone_id, slug)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/rules/<slug>', methods=['POST'])
@jwt_required()
def add_rule(zone_id, slug):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.add_rule(
zone_id, slug,
description=data.get('description'),
expression=data.get('expression'),
action=data.get('action'),
action_parameters=data.get('action_parameters'),
enabled=data.get('enabled', True))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/rules/<slug>/presets/<preset_key>', methods=['POST'])
@jwt_required()
def apply_rule_preset(zone_id, slug, preset_key):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.apply_rule_preset(zone_id, slug, preset_key, data.get('params') or {})
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/rules/<slug>/rulesets/<ruleset_id>/rules/<rule_id>',
methods=['PATCH'])
@jwt_required()
def update_rule(zone_id, slug, ruleset_id, rule_id):
require_admin_user()
data = request.get_json(silent=True) or {}
try:
res = CloudflareService.update_rule(zone_id, slug, ruleset_id, rule_id, data)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/rules/<slug>/rulesets/<ruleset_id>/rules/<rule_id>',
methods=['DELETE'])
@jwt_required()
def delete_rule(zone_id, slug, ruleset_id, rule_id):
require_admin_user()
try:
res = CloudflareService.delete_rule(zone_id, slug, ruleset_id, rule_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
# ── Activity + scope diagnostics ───────────────────────────────────────────────
@cloudflare_bp.route('/zones/<int:zone_id>/activity', methods=['GET'])
@jwt_required()
def list_activity(zone_id):
try:
res = CloudflareService.list_activity(
zone_id,
product=request.args.get('product'),
result=request.args.get('result'),
limit=min(int(request.args.get('limit', 100) or 100), 500))
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
except (TypeError, ValueError):
return jsonify({'error': 'limit must be a number'}), 400
return _service_response(res)
@cloudflare_bp.route('/zones/<int:zone_id>/scope-check', methods=['GET'])
@jwt_required()
def scope_check(zone_id):
try:
res = CloudflareService.scope_check(zone_id)
except CloudflareError as e:
return jsonify({'error': str(e)}), 400
return _service_response(res)
File diff suppressed because it is too large. Load diff
@@ -1,335 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { ShieldAlert } from 'lucide-react';
import api from '../services/cloudflare';
import {
Button, Input, Textarea, Switch, Badge,
Select, SelectTrigger, SelectContent, SelectItem, SelectValue,
EmptyState, useToast,
} from 'serverkit-sdk';
import { useTranslation } from 'react-i18next';
const ACTION_OPTIONS = [
{ value: 'block', labelKey: 'app.cloudflareWafPanel.block', label: 'Block' },
{ value: 'managed_challenge', labelKey: 'app.cloudflareWafPanel.managedChallenge', label: 'Managed Challenge' },
{ value: 'js_challenge', labelKey: 'app.cloudflareWafPanel.jsChallenge', label: 'JS Challenge' },
{ value: 'log', labelKey: 'app.cloudflareWafPanel.log', label: 'Log' },
];
const EXPRESSION_PLACEHOLDER = '(http.request.uri.path contains "/admin")';
export default function CloudflareWafPanel({ zoneId, isAdmin }) {
const { t } = useTranslation();
const toast = useToast();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [rulesetId, setRulesetId] = useState(null);
const [rules, setRules] = useState([]);
const [presets, setPresets] = useState([]);
// Preset form state
const [selectedPreset, setSelectedPreset] = useState('');
const [presetParams, setPresetParams] = useState({});
// Custom rule form state
const [customDescription, setCustomDescription] = useState('');
const [customExpression, setCustomExpression] = useState('');
const [customAction, setCustomAction] = useState('block');
// Tracks any in-flight write so buttons can disable
const [applying, setApplying] = useState(false);
const loadRules = useCallback(async () => {
try {
const data = await api.getCloudflareWafRules(zoneId);
setRulesetId(data.ruleset_id ?? null);
setRules(data.rules || []);
setPresets(data.presets || []);
setError(null);
} catch (err) {
setError(err.message);
}
}, [zoneId]);
useEffect(() => {
let active = true;
setLoading(true);
(async () => {
try {
const data = await api.getCloudflareWafRules(zoneId);
if (!active) return;
setRulesetId(data.ruleset_id ?? null);
setRules(data.rules || []);
setPresets(data.presets || []);
setError(null);
} catch (err) {
if (active) setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [zoneId]);
const activePreset = presets.find((p) => p.key === selectedPreset) || null;
const presetParamDefs = activePreset?.params || [];
const handleSelectPreset = (key) => {
setSelectedPreset(key);
setPresetParams({});
};
const handleParamChange = (key, value) => {
setPresetParams((prev) => ({ ...prev, [key]: value }));
};
const handleApplyPreset = async () => {
if (!activePreset) return;
setApplying(true);
try {
await api.applyCloudflareWafPreset(zoneId, activePreset.key, presetParams);
await loadRules();
setPresetParams({});
toast.success(t('app.cloudflareWafPanel.addedQuickRule', 'Added quick rule: {{label}}', { label: activePreset.label }));
} catch (err) {
toast.error(err.message);
} finally {
setApplying(false);
}
};
const handleAddCustomRule = async () => {
setApplying(true);
try {
await api.addCloudflareWafRule(zoneId, {
description: customDescription,
expression: customExpression,
action: customAction,
enabled: true,
});
await loadRules();
setCustomDescription('');
setCustomExpression('');
setCustomAction('block');
toast.success(t('app.cloudflareWafPanel.customRuleAdded', 'Custom rule added'));
} catch (err) {
toast.error(err.message);
} finally {
setApplying(false);
}
};
const handleToggleRule = async (rule) => {
setApplying(true);
try {
await api.updateCloudflareWafRule(zoneId, rulesetId, rule.id, {
enabled: !rule.enabled,
});
await loadRules();
} catch (err) {
toast.error(err.message);
} finally {
setApplying(false);
}
};
const handleDeleteRule = async (rule) => {
setApplying(true);
try {
await api.deleteCloudflareWafRule(zoneId, rulesetId, rule.id);
await loadRules();
toast.success(t('app.cloudflareWafPanel.ruleDeleted', 'Rule deleted'));
} catch (err) {
toast.error(err.message);
} finally {
setApplying(false);
}
};
if (loading) {
return <div className="cf-waf__loading">{t('app.cloudflareWafPanel.loadingFirewallRules', 'Loading firewall rules…')}</div>;
}
if (error) {
return (
<EmptyState
icon={ShieldAlert}
title={t('app.cloudflareWafPanel.firewallRulesUnavailable', 'Firewall rules unavailable')}
description={error}
/>
);
}
const writeDisabled = !isAdmin || applying;
return (
<div className="cf-waf">
{/* Presets / quick rules */}
<section className="cf-waf__section">
<h3 className="cf-waf__heading">{t('app.cloudflareWafPanel.quickRules', 'Quick rules')}</h3>
<p className="cf-waf__hint">
{t('app.cloudflareWafPanel.applyACuratedCloudflareRuleWithout', 'Apply a curated Cloudflare rule without writing an expression by hand.')}
</p>
<div className="cf-waf__field">
<Select
value={selectedPreset}
onValueChange={handleSelectPreset}
disabled={writeDisabled}
>
<SelectTrigger className="cf-waf__select">
<SelectValue placeholder={t('app.cloudflareWafPanel.chooseAQuickRule', 'Choose a quick rule…')} />
</SelectTrigger>
<SelectContent>
{presets.map((preset) => (
<SelectItem key={preset.key} value={preset.key}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{activePreset && (
<p className="cf-waf__preset-desc">{activePreset.description}</p>
)}
{presetParamDefs.length > 0 && (
<div className="cf-waf__params">
{presetParamDefs.map((param) => (
<div className="cf-waf__field" key={param.key}>
<label className="cf-waf__label">{param.label}</label>
<Input
value={presetParams[param.key] || ''}
placeholder={param.placeholder}
onChange={(e) => handleParamChange(param.key, e.target.value)}
disabled={writeDisabled}
/>
</div>
))}
</div>
)}
<div className="cf-waf__actions">
<Button
size="sm"
onClick={handleApplyPreset}
disabled={writeDisabled || !activePreset}
>
{t('app.cloudflareWafPanel.addRule', 'Add rule')}
</Button>
</div>
</section>
{/* Custom rule */}
<section className="cf-waf__section">
<h3 className="cf-waf__heading">{t('app.cloudflareWafPanel.customRule', 'Custom rule')}</h3>
<p className="cf-waf__hint">
{t('app.cloudflareWafPanel.writeYourOwnCloudflareFirewallExpression', 'Write your own Cloudflare firewall expression for full control.')}
</p>
<div className="cf-waf__field">
<label className="cf-waf__label">{t('common.labels.description', 'Description')}</label>
<Input
value={customDescription}
placeholder={t('app.cloudflareWafPanel.blockAdminFromOutsideTheOffice', 'Block admin from outside the office')}
onChange={(e) => setCustomDescription(e.target.value)}
disabled={writeDisabled}
/>
</div>
<div className="cf-waf__field">
<label className="cf-waf__label">{t('app.cloudflareWafPanel.expression', 'Expression')}</label>
<Textarea
rows={3}
value={customExpression}
placeholder={EXPRESSION_PLACEHOLDER}
onChange={(e) => setCustomExpression(e.target.value)}
disabled={writeDisabled}
/>
</div>
<div className="cf-waf__field">
<label className="cf-waf__label">{t('common.labels.action', 'Action')}</label>
<Select
value={customAction}
onValueChange={setCustomAction}
disabled={writeDisabled}
>
<SelectTrigger className="cf-waf__select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="cf-waf__actions">
<Button
size="sm"
onClick={handleAddCustomRule}
disabled={writeDisabled || !customExpression.trim()}
>
{t('app.cloudflareWafPanel.addCustomRule', 'Add custom rule')}
</Button>
</div>
</section>
{/* Active rules list */}
<section className="cf-waf__section">
<h3 className="cf-waf__heading">{t('app.cloudflareWafPanel.activeRules', 'Active rules (')}{rules.length})</h3>
{rules.length === 0 ? (
<EmptyState
icon={ShieldAlert}
title={t('app.cloudflareWafPanel.noCustomFirewallRules', 'No custom firewall rules')}
description={t('app.cloudflareWafPanel.addAQuickRuleOrA', 'Add a quick rule or a custom rule above.')}
/>
) : (
<ul className="cf-waf__list">
{rules.map((rule) => (
<li className="cf-waf__rule" key={rule.id}>
<div className="cf-waf__rule-main">
<div className="cf-waf__rule-head">
<Badge
variant={rule.action === 'block' ? 'destructive' : 'secondary'}
>
{rule.action}
</Badge>
<span className="cf-waf__rule-desc">
{rule.description || 'Untitled rule'}
</span>
</div>
<code className="cf-waf__rule-expr">{rule.expression}</code>
</div>
<div className="cf-waf__rule-actions">
<Switch
checked={rule.enabled}
onCheckedChange={() => handleToggleRule(rule)}
disabled={writeDisabled}
/>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteRule(rule)}
disabled={writeDisabled}
>
{t('common.actions.delete', 'Delete')}
</Button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
);
}
@@ -1,266 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { HardDrive } from 'lucide-react';
import api from '../services/cloudflare';
import { Button, Input, EmptyState, useToast } from 'serverkit-sdk';
import { useTranslation } from 'react-i18next';
function StorageSection({
title,
items,
error,
placeholder,
hint,
renderItem,
onCreate,
onDelete,
isAdmin,
busy,
}) {
const { t } = useTranslation();
const [value, setValue] = useState('');
const writeDisabled = !isAdmin || busy;
const handleCreate = async () => {
const trimmed = value.trim();
if (!trimmed) return;
// Only clear the input when the create succeeds; a throw leaves the text intact.
await onCreate(trimmed);
setValue('');
};
return (
<section className="cf-storage__section">
<h3 className="cf-storage__heading">
{title} ({error ? 0 : items.length})
</h3>
{hint && <p className="cf-storage__hint">{hint}</p>}
{error ? (
<p className="cf-storage__error">{error}</p>
) : (
<>
<div className="cf-storage__create">
<Input
value={value}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
disabled={writeDisabled}
/>
<Button
size="sm"
onClick={handleCreate}
disabled={writeDisabled || !value.trim()}
>
{t('common.actions.add', 'Add')}
</Button>
</div>
{items.length === 0 ? (
<p className="cf-storage__hint">{`No ${title.toLowerCase()} yet.`}</p>
) : (
<ul className="cf-storage__list">
{items.map((item) => {
const { key, label } = renderItem(item);
return (
<li className="cf-storage__item" key={key}>
<code className="cf-storage__name">{label}</code>
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(item)}
disabled={writeDisabled}
>
{t('common.actions.delete', 'Delete')}
</Button>
</li>
);
})}
</ul>
)}
</>
)}
</section>
);
}
export default function StoragePanel({ zoneId, isAdmin }) {
const { t } = useTranslation();
const toast = useToast();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
// Tracks any in-flight write so all write controls can disable
const [working, setWorking] = useState(false);
const loadData = useCallback(async () => {
try {
const res = await api.getCloudflareStorage(zoneId);
setData(res);
setError(null);
} catch (err) {
setError(err.message);
}
}, [zoneId]);
useEffect(() => {
let active = true;
setLoading(true);
(async () => {
try {
const res = await api.getCloudflareStorage(zoneId);
if (!active) return;
setData(res);
setError(null);
} catch (err) {
if (active) setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [zoneId]);
const handleCreateR2 = async (name) => {
setWorking(true);
try {
await api.createCloudflareR2Bucket(zoneId, name);
toast.success(t('app.storagePanel.createdBucket', 'Created bucket "{{name}}"', { name: name }));
await loadData();
} catch (err) {
toast.error(err.message);
throw err;
} finally {
setWorking(false);
}
};
const handleDeleteR2 = async (bucket) => {
setWorking(true);
try {
await api.deleteCloudflareR2Bucket(zoneId, bucket.name);
toast.success(t('app.storagePanel.deleted', 'Deleted'));
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleCreateKv = async (title) => {
setWorking(true);
try {
await api.createCloudflareKvNamespace(zoneId, title);
toast.success(t('app.storagePanel.createdNamespace', 'Created namespace "{{title}}"', { title: title }));
await loadData();
} catch (err) {
toast.error(err.message);
throw err;
} finally {
setWorking(false);
}
};
const handleDeleteKv = async (namespace) => {
setWorking(true);
try {
await api.deleteCloudflareKvNamespace(zoneId, namespace.id);
toast.success(t('app.storagePanel.deleted', 'Deleted'));
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleCreateD1 = async (name) => {
setWorking(true);
try {
await api.createCloudflareD1Database(zoneId, name);
toast.success(t('app.storagePanel.createdDatabase', 'Created database "{{name}}"', { name: name }));
await loadData();
} catch (err) {
toast.error(err.message);
throw err;
} finally {
setWorking(false);
}
};
const handleDeleteD1 = async (database) => {
setWorking(true);
try {
await api.deleteCloudflareD1Database(zoneId, database.uuid);
toast.success(t('app.storagePanel.deleted', 'Deleted'));
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
if (loading) {
return <div className="cf-storage__loading">{t('app.storagePanel.loadingStorage', 'Loading storage…')}</div>;
}
if (error) {
return (
<EmptyState
icon={HardDrive}
title={t('app.storagePanel.storageUnavailable', 'Storage unavailable')}
description={error}
/>
);
}
const errors = data.errors || {};
return (
<div className="cf-storage">
<StorageSection
title={t('app.storagePanel.r2Buckets', 'R2 buckets')}
items={data.r2 || []}
error={errors.r2}
placeholder="my-bucket"
hint={t('app.storagePanel.r2IsS3CompatibleSoA', 'R2 is S3-compatible, so a bucket here can back ServerKit backups later.')}
renderItem={(b) => ({ key: b.name, label: b.name })}
onCreate={handleCreateR2}
onDelete={handleDeleteR2}
isAdmin={isAdmin}
busy={working}
/>
<StorageSection
title={t('app.storagePanel.kvNamespaces', 'KV namespaces')}
items={data.kv || []}
error={errors.kv}
placeholder="my-namespace"
renderItem={(n) => ({ key: n.id, label: n.title })}
onCreate={handleCreateKv}
onDelete={handleDeleteKv}
isAdmin={isAdmin}
busy={working}
/>
<StorageSection
title={t('app.storagePanel.d1Databases', 'D1 databases')}
items={data.d1 || []}
error={errors.d1}
placeholder="my-database"
renderItem={(d) => ({ key: d.uuid, label: d.name })}
onCreate={handleCreateD1}
onDelete={handleDeleteD1}
isAdmin={isAdmin}
busy={working}
/>
</div>
);
}
@@ -1,362 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Network } from 'lucide-react';
import api from '../services/cloudflare';
import { Button, Input, Badge, EmptyState, useToast, copyToClipboard } from 'serverkit-sdk';
import { useTranslation } from 'react-i18next';
function InstallBox({ command }) {
const { t } = useTranslation();
const toast = useToast();
if (!command) {
return <p className="cf-tunnels__hint">{t('app.tunnelsPanel.noInstallCommandAvailable', 'No install command available.')}</p>;
}
const handleCopy = async () => {
if (await copyToClipboard(command)) toast.success(t('app.tunnelsPanel.copied', 'Copied'));
else toast.error(t('app.tunnelsPanel.couldNotCopyTheInstallCommand', 'Could not copy the install command'));
};
return (
<div className="cf-tunnels__install">
<code className="cf-tunnels__cmd">{command}</code>
<Button size="sm" variant="outline" onClick={handleCopy}>
{t('common.actions.copy', 'Copy')}
</Button>
</div>
);
}
function TunnelRow({ zoneId, tunnel, isAdmin, onChanged }) {
const { t } = useTranslation();
const toast = useToast();
const [install, setInstall] = useState(null);
const [expanded, setExpanded] = useState(false);
const [hostnames, setHostnames] = useState([]);
const [hostname, setHostname] = useState('');
const [service, setService] = useState('');
const [working, setWorking] = useState(false);
const loadHostnames = useCallback(async () => {
try {
const res = await api.getCloudflareTunnelHostnames(zoneId, tunnel.id);
setHostnames(res.hostnames || []);
} catch (err) {
toast.error(err.message);
}
}, [zoneId, tunnel.id, toast]);
const handleToggleInstall = async () => {
if (install) {
setInstall(null);
return;
}
setWorking(true);
try {
const res = await api.getCloudflareTunnelInstall(zoneId, tunnel.id);
setInstall(res.install || null);
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleToggleHostnames = async () => {
const next = !expanded;
setExpanded(next);
if (next && hostnames.length === 0) {
await loadHostnames();
}
};
const handleDelete = async () => {
setWorking(true);
try {
await api.deleteCloudflareTunnel(zoneId, tunnel.id);
toast.success(t('app.tunnelsPanel.deletedCloudflareTunnel', 'Deleted Cloudflare Tunnel "{{name}}"', { name: tunnel.name }));
onChanged();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleRemoveHostname = async (h) => {
setWorking(true);
try {
await api.removeCloudflareTunnelHostname(zoneId, tunnel.id, h.hostname);
toast.success(t('app.tunnelsPanel.hostnameRemoved', 'Hostname removed'));
await loadHostnames();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleAddHostname = async () => {
setWorking(true);
try {
const res = await api.addCloudflareTunnelHostname(
zoneId,
tunnel.id,
hostname,
service,
);
toast.success(t('app.tunnelsPanel.hostnameRouted', 'Hostname routed'));
if (res.dns && !res.dns.created) {
toast.error(t('app.tunnelsPanel.routeSetButTheDnsRecord', 'Route set, but the DNS record failed: ') + res.dns.error);
}
setHostname('');
setService('');
await loadHostnames();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const writeDisabled = !isAdmin || working;
return (
<li className="cf-tunnels__item">
<div className="cf-tunnels__item-head">
<span className="cf-tunnels__name">{tunnel.name}</span>
<code className="cf-tunnels__meta">{tunnel.id.slice(0, 12)}…</code>
{tunnel.status && (
<span className="cf-tunnels__meta">{tunnel.status}</span>
)}
{tunnel.connections != null && (
<span className="cf-tunnels__meta">{tunnel.connections}</span>
)}
{tunnel.managed && <Badge variant="secondary">{t('common.labels.serverKit', 'ServerKit')}</Badge>}
</div>
<div className="cf-tunnels__item-actions">
<Button
size="sm"
variant="outline"
onClick={handleToggleInstall}
disabled={!isAdmin || working}
>
{t('app.tunnelsPanel.installCommand', 'Install command')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleToggleHostnames}
disabled={working}
>
{t('app.tunnelsPanel.hostnames', 'Hostnames')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={writeDisabled}
>
{t('common.actions.delete', 'Delete')}
</Button>
</div>
{install && <InstallBox command={install} />}
{expanded && (
<div className="cf-tunnels__hostnames">
{hostnames.length === 0 ? (
<p className="cf-tunnels__hint">{t('app.tunnelsPanel.noPublicHostnamesRoutedYet', 'No public hostnames routed yet.')}</p>
) : (
hostnames.map((h) => (
<div className="cf-tunnels__host" key={h.hostname}>
<span>
<code>{h.hostname}</code> &rarr; <code>{h.service}</code>
</span>
<Button
variant="destructive"
size="sm"
onClick={() => handleRemoveHostname(h)}
disabled={writeDisabled}
>
{t('common.actions.remove', 'Remove')}
</Button>
</div>
))
)}
<div className="cf-tunnels__host-add">
<Input
value={hostname}
placeholder="app.example.com"
onChange={(e) => setHostname(e.target.value)}
disabled={writeDisabled}
/>
<Input
value={service}
placeholder="http://localhost:8080"
onChange={(e) => setService(e.target.value)}
disabled={writeDisabled}
/>
<Button
size="sm"
onClick={handleAddHostname}
disabled={writeDisabled || !hostname.trim() || !service.trim()}
>
{t('common.actions.add', 'Add')}
</Button>
</div>
<p className="cf-tunnels__hint">
{t('app.tunnelsPanel.theHostnameShouldBeInsideThis', 'The hostname should be inside this zone\'s domain; a proxied DNS record is created automatically.')}
</p>
</div>
)}
</li>
);
}
export default function TunnelsPanel({ zoneId, isAdmin }) {
const { t } = useTranslation();
const toast = useToast();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [tunnels, setTunnels] = useState([]);
// Create form state
const [name, setName] = useState('');
const [creating, setCreating] = useState(false);
const [lastInstall, setLastInstall] = useState(null);
const loadData = useCallback(async () => {
try {
const data = await api.getCloudflareTunnels(zoneId);
setTunnels(data.tunnels || []);
setError(null);
} catch (err) {
setError(err.message);
}
}, [zoneId]);
useEffect(() => {
let active = true;
setLoading(true);
(async () => {
try {
const data = await api.getCloudflareTunnels(zoneId);
if (!active) return;
setTunnels(data.tunnels || []);
setError(null);
} catch (err) {
if (active) setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [zoneId]);
const handleCreate = async () => {
setCreating(true);
try {
const res = await api.createCloudflareTunnel(zoneId, name);
toast.success(t('app.tunnelsPanel.createdTunnel', 'Created tunnel "{{name}}"', { name: name }));
setLastInstall({ name, install: res.install, token: res.token });
setName('');
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setCreating(false);
}
};
if (loading) {
return <div className="cf-tunnels__loading">{t('app.tunnelsPanel.loadingCloudflareTunnels', 'Loading Cloudflare Tunnels…')}</div>;
}
if (error) {
return (
<EmptyState
icon={Network}
title={t('app.tunnelsPanel.cloudflareTunnelsUnavailable', 'Cloudflare Tunnels unavailable')}
description={error}
/>
);
}
const createDisabled = !isAdmin || creating || !name.trim();
return (
<div className="cf-tunnels">
{/* Create a Cloudflare Tunnel */}
<section className="cf-tunnels__section">
<h3 className="cf-tunnels__heading">{t('app.tunnelsPanel.createACloudflareTunnel', 'Create a Cloudflare Tunnel')}</h3>
<p className="cf-tunnels__hint">
{t('app.tunnelsPanel.aCloudflareTunnelExposesALocal', 'A Cloudflare Tunnel exposes a local or private service through Cloudflare\'s edge — no public IP or open ports required.')}
</p>
<div className="cf-tunnels__field">
<label className="cf-tunnels__label">{t('common.labels.name', 'Name')}</label>
<Input
value={name}
placeholder="home-jellyfin"
onChange={(e) => setName(e.target.value)}
disabled={!isAdmin || creating}
/>
</div>
<div className="cf-tunnels__actions">
<Button onClick={handleCreate} disabled={createDisabled}>
{t('common.actions.create', 'Create')}
</Button>
</div>
{lastInstall && (
<div className="cf-tunnels__install">
<p className="cf-tunnels__hint">
{t('app.tunnelsPanel.runThisOnceOnTheMachine', 'Run this once on the machine hosting your local service. The connector token is shown only now.')}
</p>
<InstallBox command={lastInstall.install} />
<Button
size="sm"
variant="ghost"
onClick={() => setLastInstall(null)}
>
{t('common.actions.dismiss', 'Dismiss')}
</Button>
</div>
)}
</section>
{/* Cloudflare Tunnels list */}
<section className="cf-tunnels__section">
<h3 className="cf-tunnels__heading">{t('app.tunnelsPanel.cloudflareTunnels', 'Cloudflare Tunnels (')}{tunnels.length})</h3>
{tunnels.length === 0 ? (
<EmptyState
icon={Network}
title={t('app.tunnelsPanel.noCloudflareTunnels', 'No Cloudflare Tunnels')}
description={t('app.tunnelsPanel.createOneAboveToExposeA', 'Create one above to expose a local service without a public IP.')}
/>
) : (
<ul className="cf-tunnels__list">
{tunnels.map((tunnel) => (
<TunnelRow
key={tunnel.id}
zoneId={zoneId}
tunnel={tunnel}
isAdmin={isAdmin}
onChanged={loadData}
/>
))}
</ul>
)}
</section>
</div>
);
}
@@ -1,253 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Zap } from 'lucide-react';
import api from '../services/cloudflare';
import { Button, Input, Textarea, Badge, EmptyState, useToast } from 'serverkit-sdk';
import { useTranslation } from 'react-i18next';
const STARTER_SCRIPT = `export default {
async fetch(request, env, ctx) {
return new Response("Hello from ServerKit + Cloudflare Workers!", {
headers: { "content-type": "text/plain" },
});
},
};`;
export default function WorkersPanel({ zoneId, isAdmin }) {
const { t } = useTranslation();
const toast = useToast();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [workers, setWorkers] = useState([]);
const [routes, setRoutes] = useState([]);
// Deploy form state
const [name, setName] = useState('');
const [code, setCode] = useState(STARTER_SCRIPT);
const [routePattern, setRoutePattern] = useState('');
// Tracks any in-flight write so buttons can disable
const [deploying, setDeploying] = useState(false);
const [working, setWorking] = useState(false);
const loadData = useCallback(async () => {
try {
const data = await api.getCloudflareWorkers(zoneId);
setWorkers(data.workers || []);
setRoutes(data.routes || []);
setError(null);
} catch (err) {
setError(err.message);
}
}, [zoneId]);
useEffect(() => {
let active = true;
setLoading(true);
(async () => {
try {
const data = await api.getCloudflareWorkers(zoneId);
if (!active) return;
setWorkers(data.workers || []);
setRoutes(data.routes || []);
setError(null);
} catch (err) {
if (active) setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [zoneId]);
const handleDeploy = async () => {
setDeploying(true);
try {
const res = await api.deployCloudflareWorker(zoneId, {
name,
code,
route_pattern: routePattern || undefined,
});
toast.success(t('app.workersPanel.deployedWorker', 'Deployed worker "{{name}}"', { name: name }));
if (res.route && !res.route.success) {
toast.error(t('app.workersPanel.workerDeployedButTheRouteFailed', 'Worker deployed, but the route failed: ') + res.route.error);
}
setName('');
setRoutePattern('');
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setDeploying(false);
}
};
const handleDeleteWorker = async (worker) => {
setWorking(true);
try {
await api.deleteCloudflareWorker(zoneId, worker.name);
await loadData();
toast.success(t('app.workersPanel.deletedWorker', 'Deleted worker "{{name}}"', { name: worker.name }));
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleDeleteRoute = async (route) => {
setWorking(true);
try {
await api.deleteCloudflareWorkerRoute(zoneId, route.id);
await loadData();
toast.success(t('app.workersPanel.routeRemoved', 'Route removed'));
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
if (loading) {
return <div className="cf-workers__loading">{t('app.workersPanel.loadingWorkers', 'Loading workers…')}</div>;
}
if (error) {
return (
<EmptyState
icon={Zap}
title={t('app.workersPanel.workersUnavailable', 'Workers unavailable')}
description={error}
/>
);
}
const writeDisabled = !isAdmin || working;
const deployDisabled = !isAdmin || deploying || !name.trim() || !code.trim();
return (
<div className="cf-workers">
{/* Deploy a Worker */}
<section className="cf-workers__section">
<h3 className="cf-workers__heading">{t('app.workersPanel.deployAWorker', 'Deploy a Worker')}</h3>
<div className="cf-workers__field">
<label className="cf-workers__label">{t('common.labels.name', 'Name')}</label>
<Input
value={name}
placeholder="my-worker"
onChange={(e) => setName(e.target.value)}
disabled={!isAdmin || deploying}
/>
</div>
<div className="cf-workers__field">
<label className="cf-workers__label">{t('app.workersPanel.code', 'Code')}</label>
<Textarea
rows={8}
className="cf-workers__code"
value={code}
onChange={(e) => setCode(e.target.value)}
disabled={!isAdmin || deploying}
/>
</div>
<div className="cf-workers__field">
<label className="cf-workers__label">{t('app.workersPanel.routePatternOptional', 'Route pattern (optional)')}</label>
<Input
value={routePattern}
placeholder="example.com/*"
onChange={(e) => setRoutePattern(e.target.value)}
disabled={!isAdmin || deploying}
/>
<p className="cf-workers__hint">
{t('app.workersPanel.attachesTheWorkerToThisDomain', 'Attaches the worker to this domain so matching traffic runs the script.')}
</p>
</div>
<div className="cf-workers__actions">
<Button onClick={handleDeploy} disabled={deployDisabled}>
{t('app.workersPanel.deploy', 'Deploy')}
</Button>
</div>
</section>
{/* Deployed workers */}
<section className="cf-workers__section">
<h3 className="cf-workers__heading">{t('app.workersPanel.workers', 'Workers (')}{workers.length})</h3>
{workers.length === 0 ? (
<EmptyState
icon={Zap}
title={t('app.workersPanel.noWorkersDeployed', 'No Workers deployed')}
description={t('app.workersPanel.deployYourFirstEdgeScriptAbove', 'Deploy your first edge script above.')}
/>
) : (
<ul className="cf-workers__list">
{workers.map((worker) => (
<li className="cf-workers__item" key={worker.name}>
<div className="cf-workers__name">
<code>{worker.name}</code>
{worker.managed && (
<Badge variant="secondary">{t('common.labels.serverKit', 'ServerKit')}</Badge>
)}
{worker.modified_on && (
<span className="cf-workers__meta">
{new Date(worker.modified_on).toLocaleString()}
</span>
)}
</div>
<div className="cf-workers__item-actions">
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteWorker(worker)}
disabled={writeDisabled}
>
{t('common.actions.delete', 'Delete')}
</Button>
</div>
</li>
))}
</ul>
)}
</section>
{/* Routes */}
<section className="cf-workers__section">
<h3 className="cf-workers__heading">{t('app.workersPanel.routes', 'Routes (')}{routes.length})</h3>
{routes.length === 0 ? (
<p className="cf-workers__hint">
{t('app.workersPanel.noRoutesYetAddARoute', 'No routes yet. Add a route pattern when deploying to send traffic to a worker.')}
</p>
) : (
<ul className="cf-workers__list">
{routes.map((route) => (
<li className="cf-workers__item" key={route.id}>
<div className="cf-workers__name">
<code>{route.pattern}</code>
<span className="cf-workers__meta">&rarr; {route.script}</span>
</div>
<div className="cf-workers__item-actions">
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteRoute(route)}
disabled={writeDisabled}
>
{t('common.actions.remove', 'Remove')}
</Button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
);
}
@@ -1,26 +0,0 @@
// Cloudflare Zone Ops UI, contributed through the extension system. Both
// halves live in this extension: the zone-ops backend (the /api/v1/cloudflare
// blueprint + CloudflareService) under backend/, and the full page here
// (pages/CloudflareZoneSettings.jsx + the WAF/Workers/Tunnels/Storage panels
// + its own API client + SCSS). Imports go through 'serverkit-sdk' so the
// same source works baked in-tree (the panel's build-time glob) and as a
// runtime-ESM bundle once the extension leaves the tree (plan 52 Phase 2).
//
// DNS records and the Cloudflare connection itself stay core (they back
// /domains) — this page only adds the per-zone control panel, reached from
// the "Open in Cloudflare" button on a Cloudflare-managed domain.
//
// It's a single plain full-bleed route (cloudflare/zones/:zoneId, own
// PageTopbar + internal Settings/WAF/Workers/Tunnels/Storage tabs), so no
// tab-group shell or self-rendered sub-router is needed — unlike WordPress.
import './styles/cloudflare-zone.scss';
import CloudflareZoneSettings from './pages/CloudflareZoneSettings';
export function CloudflareZoneSettingsPage() {
return <CloudflareZoneSettings />;
}
// No default export on purpose: PluginLoader legacy-auto-renders any plugin
// default export globally (the page then runs with zoneId=undefined). The
// route contribution resolves the NAMED export via resolveComponent.
@@ -1,465 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Cloud, ShieldCheck, Lock, Gauge, Database, Wand2, Eraser, Flame, Zap, Network, HardDrive } from 'lucide-react';
import CloudflareWafPanel from '../components/CloudflareWafPanel';
import WorkersPanel from '../components/WorkersPanel';
import TunnelsPanel from '../components/TunnelsPanel';
import StoragePanel from '../components/StoragePanel';
import api from '../services/cloudflare';
import {
PageLayout, Tabs, TabsList, TabsTrigger, TabsContent, Switch, Button,
Badge, Textarea, Select, SelectTrigger, SelectContent, SelectItem,
SelectValue, PageLoader, EmptyState, ConfirmDialog, useToast, useAuth,
} from 'serverkit-sdk';
import { useTranslation } from 'react-i18next';
// HSTS max-age presets (seconds). 0 disables the max-age while keeping HSTS off.
const HSTS_MAX_AGE = [
{ value: 0, labelKey: 'app.cloudflareZoneSettings.off', label: 'Off' },
{ value: 86400, labelKey: 'app.cloudflareZoneSettings.1Day', label: '1 day' },
{ value: 604800, labelKey: 'app.cloudflareZoneSettings.1Week', label: '1 week' },
{ value: 2592000, labelKey: 'app.cloudflareZoneSettings.1Month', label: '1 month' },
{ value: 15552000, labelKey: 'app.cloudflareZoneSettings.6Months', label: '6 months' },
{ value: 31536000, labelKey: 'app.cloudflareZoneSettings.1Year', label: '1 year' },
{ value: 63072000, labelKey: 'app.cloudflareZoneSettings.2Years', label: '2 years' },
];
const TAB_ICONS = {
ssl: <Lock size={15} />,
speed: <Gauge size={15} />,
caching: <Database size={15} />,
security: <ShieldCheck size={15} />,
};
const CloudflareZoneSettings = () => {
const { t } = useTranslation();
const { zoneId } = useParams();
const toast = useToast();
const { user } = useAuth();
const isAdmin = !!user?.is_admin;
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [zone, setZone] = useState(null);
const [groups, setGroups] = useState([]);
const [settings, setSettings] = useState({});
const [saving, setSaving] = useState(null); // setting id in flight
const [applying, setApplying] = useState(false);
const [purgeUrls, setPurgeUrls] = useState('');
const [purging, setPurging] = useState(false);
const [confirmPurgeAll, setConfirmPurgeAll] = useState(false);
const loadSettings = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getCloudflareZoneSettings(zoneId);
setZone(data.zone || null);
setGroups(data.groups || []);
setSettings(data.settings || {});
} catch (err) {
setError(err.message || 'Failed to load Cloudflare settings');
} finally {
setLoading(false);
}
}, [zoneId]);
useEffect(() => { loadSettings(); }, [loadSettings]);
// Optimistically reflect the change, PATCH it, and reload truth on failure so
// the UI never drifts from Cloudflare.
const save = useCallback(async (settingId, value) => {
setSaving(settingId);
setSettings(prev => ({ ...prev, [settingId]: { ...(prev[settingId] || {}), value } }));
try {
await api.updateCloudflareZoneSetting(zoneId, settingId, value);
toast.success(t('app.cloudflareZoneSettings.settingSaved', 'Setting saved'));
} catch (err) {
toast.error(err.message || t('app.cloudflareZoneSettings.failedToSaveSetting', 'Failed to save setting'));
loadSettings();
} finally {
setSaving(null);
}
}, [zoneId, toast, loadSettings]);
const handleApplyPreset = async () => {
setApplying(true);
try {
const res = await api.applyCloudflareSettingsPreset(zoneId);
const failed = (res.results || []).filter(r => !r.success);
if (failed.length === 0) {
toast.success(t('app.cloudflareZoneSettings.appliedRecommendedHardeningSettings', 'Applied recommended hardening ({{applied}} settings)', { applied: res.applied }));
} else {
toast.info(t('app.cloudflareZoneSettings.appliedNeedAHigherCloudflarePlan', 'Applied {{applied}}/{{total}}. {{length}} need a higher Cloudflare plan.', { applied: res.applied, total: res.total, length: failed.length }));
}
loadSettings();
} catch (err) {
toast.error(err.message || t('app.cloudflareZoneSettings.failedToApplyPreset', 'Failed to apply preset'));
} finally {
setApplying(false);
}
};
const handlePurgeEverything = async () => {
setConfirmPurgeAll(false);
setPurging(true);
try {
await api.purgeCloudflareCache(zoneId, { purge_everything: true });
toast.success(t('app.cloudflareZoneSettings.purgedTheEntireCache', 'Purged the entire cache'));
} catch (err) {
toast.error(err.message || t('app.cloudflareZoneSettings.failedToPurgeCache', 'Failed to purge cache'));
} finally {
setPurging(false);
}
};
const handlePurgeFiles = async () => {
const files = purgeUrls.split('\n').map(u => u.trim()).filter(Boolean);
if (files.length === 0) {
toast.error(t('app.cloudflareZoneSettings.enterAtLeastOneUrlTo', 'Enter at least one URL to purge'));
return;
}
setPurging(true);
try {
const res = await api.purgeCloudflareCache(zoneId, { files });
const n = res.purged?.files?.length ?? files.length;
toast.success(t('app.cloudflareZoneSettings.purgedUrlFromCache', 'Purged {{n}} URL{{value}} from cache', { n: n, value: n === 1 ? '' : 's' }));
setPurgeUrls('');
} catch (err) {
toast.error(err.message || t('app.cloudflareZoneSettings.failedToPurgeUrls', 'Failed to purge URLs'));
} finally {
setPurging(false);
}
};
if (loading) return <PageLoader />;
const crumbs = (
<span className="cf-crumbs">
<Link to="/dns">{t('app.cloudflareZoneSettings.dnsZones', 'DNS Zones')}</Link>
<span className="cf-crumbs__sep">/</span>
<span className="cf-crumbs__cur">{zone?.domain || `Zone ${zoneId}`}</span>
<span className="cf-crumbs__sep">/</span>
<span className="cf-crumbs__cur">{t('app.cloudflareZoneSettings.cloudflare', 'Cloudflare')}</span>
</span>
);
if (error) {
return (
<PageLayout
className="cf-zone"
contentClassName="app-detail-page app-detail-page--wide"
icon={<Cloud size={18} />}
title={crumbs}
>
<div className="cf-zone__body">
<EmptyState
icon={Cloud}
title={t('app.cloudflareZoneSettings.cloudflareSettingsUnavailable', 'Cloudflare settings unavailable')}
description={error}
/>
<div className="cf-zone__error-actions">
<Button variant="outline" onClick={loadSettings}>{t('common.actions.retry', 'Retry')}</Button>
<Link to="/dns"><Button variant="ghost">{t('app.cloudflareZoneSettings.backToDnsZones', 'Back to DNS Zones')}</Button></Link>
</div>
</div>
</PageLayout>
);
}
const tabGroups = groups.filter(g => g.settings?.length);
const firstTab = tabGroups[0]?.key || 'actions';
return (
<PageLayout
className="cf-zone"
contentClassName="app-detail-page app-detail-page--wide"
icon={<Cloud size={18} />}
title={crumbs}
meta={zone?.provider_zone_id ? `Zone ${zone.provider_zone_id}` : undefined}
>
<div className="cf-zone__body">
<Tabs defaultValue={firstTab} className="cf-zone__tabs">
<TabsList>
{tabGroups.map(g => (
<TabsTrigger key={g.key} value={g.key}>
{TAB_ICONS[g.key]}{g.label}
</TabsTrigger>
))}
<TabsTrigger value="waf"><Flame size={15} />WAF</TabsTrigger>
<TabsTrigger value="workers"><Zap size={15} />{t('app.cloudflareZoneSettings.workers', 'Workers')}</TabsTrigger>
<TabsTrigger value="tunnels"><Network size={15} />{t('app.cloudflareZoneSettings.tunnels', 'Tunnels')}</TabsTrigger>
<TabsTrigger value="storage"><HardDrive size={15} />{t('common.labels.storage', 'Storage')}</TabsTrigger>
<TabsTrigger value="actions"><Wand2 size={15} />{t('common.labels.actions', 'Actions')}</TabsTrigger>
</TabsList>
{tabGroups.map(g => (
<TabsContent key={g.key} value={g.key}>
<div className="cf-panel">
{g.settings.map(setting => (
<SettingRow
key={setting.id}
setting={setting}
state={settings[setting.id]}
saving={saving === setting.id}
disabled={!isAdmin}
onSave={save}
/>
))}
</div>
</TabsContent>
))}
<TabsContent value="waf">
<div className="cf-panel">
<CloudflareWafPanel zoneId={zoneId} isAdmin={isAdmin} />
</div>
</TabsContent>
<TabsContent value="workers">
<div className="cf-panel">
<WorkersPanel zoneId={zoneId} isAdmin={isAdmin} />
</div>
</TabsContent>
<TabsContent value="tunnels">
<div className="cf-panel">
<TunnelsPanel zoneId={zoneId} isAdmin={isAdmin} />
</div>
</TabsContent>
<TabsContent value="storage">
<div className="cf-panel">
<StoragePanel zoneId={zoneId} isAdmin={isAdmin} />
</div>
</TabsContent>
<TabsContent value="actions">
<div className="cf-panel cf-actions">
<div className="cf-action">
<div className="cf-action__text">
<h3>{t('app.cloudflareZoneSettings.applyRecommendedHardening', 'Apply recommended hardening')}</h3>
<p>
{t('app.cloudflareZoneSettings.setsFullStrictSslAlwaysUse', 'Sets Full (strict) SSL, Always Use HTTPS, HSTS (6 months), a TLS 1.2 floor with TLS 1.3, Brotli, HTTP/3, and a 4-hour browser cache. Settings your plan doesn\'t allow are skipped.')}
</p>
</div>
<Button onClick={handleApplyPreset} disabled={applying || !isAdmin}>
{applying ? 'Applying…' : 'Apply preset'}
</Button>
</div>
{!isAdmin && (
<p className="cf-actions__note">
{t('app.cloudflareZoneSettings.changingCloudflareSettingsRequiresAnAdmin', 'Changing Cloudflare settings requires an admin account.')}
</p>
)}
</div>
<div className="cf-panel cf-actions">
<div className="cf-action">
<div className="cf-action__text">
<h3><Eraser size={15} /> {t('app.cloudflareZoneSettings.purgeCache', 'Purge cache')}</h3>
<p>
{t('app.cloudflareZoneSettings.clearCloudflareSCachedCopyOf', 'Clear Cloudflare\'s cached copy of your site so visitors get fresh content. Purge everything, or list specific URLs below.')}
</p>
</div>
<Button
variant="destructive"
onClick={() => setConfirmPurgeAll(true)}
disabled={purging || !isAdmin}
>
{purging ? 'Purging…' : 'Purge everything'}
</Button>
</div>
<div className="cf-purge-files">
<label htmlFor="cf-purge-urls" className="cf-purge-files__label">
{t('app.cloudflareZoneSettings.purgeSpecificUrlsOnePerLine', 'Purge specific URLs (one per line, up to 30)')}
</label>
<Textarea
id="cf-purge-urls"
rows={4}
placeholder={'https://example.com/style.css\nhttps://example.com/app.js'}
value={purgeUrls}
disabled={purging || !isAdmin}
onChange={(e) => setPurgeUrls(e.target.value)}
/>
<div className="cf-purge-files__actions">
<Button
variant="outline"
onClick={handlePurgeFiles}
disabled={purging || !isAdmin || !purgeUrls.trim()}
>
{t('app.cloudflareZoneSettings.purgeUrls', 'Purge URLs')}
</Button>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
{confirmPurgeAll && (
<ConfirmDialog
isOpen
title={t('app.cloudflareZoneSettings.purgeEntireCache', 'Purge entire cache')}
message={t('app.cloudflareZoneSettings.clearCloudflareSEntireCacheFor', 'Clear Cloudflare\'s entire cache for {{value}}?', { value: zone?.domain || 'this zone' })
+ t('app.cloudflareZoneSettings.theNextVisitToEachPage', 'The next visit to each page will be served from your origin until ')
+ t('app.cloudflareZoneSettings.itReCachesThisIsSafe', 'it re-caches. This is safe but can briefly increase origin load.')}
confirmText={t('app.cloudflareZoneSettings.purgeEverything', 'Purge everything')}
onConfirm={handlePurgeEverything}
onCancel={() => setConfirmPurgeAll(false)}
variant="danger"
/>
)}
</PageLayout>
);
};
// A single setting row — label + help on the left, the control on the right.
// Reads the current value/editability from the live settings state; renders the
// control declaratively from the setting's `type`.
function SettingRow({ setting, state, saving, disabled, onSave }) {
const { t } = useTranslation();
const present = state !== undefined && state !== null;
const editable = present ? state.editable !== false : false;
const locked = disabled || !editable || saving;
return (
<div className="cf-setting">
<div className="cf-setting__info">
<span className="cf-setting__label">{setting.label}</span>
{setting.help && <span className="cf-setting__help">{setting.help}</span>}
{!present && (
<Badge variant="secondary" className="cf-setting__badge">
{t('app.cloudflareZoneSettings.notAvailableOnThisZone', 'Not available on this zone')}
</Badge>
)}
{present && !editable && (
<Badge variant="secondary" className="cf-setting__badge">
{t('app.cloudflareZoneSettings.lockedByYourCloudflarePlan', 'Locked by your Cloudflare plan')}
</Badge>
)}
</div>
<div className="cf-setting__control">
<SettingControl
setting={setting}
value={present ? state.value : undefined}
locked={locked || !present}
onSave={onSave}
/>
</div>
</div>
);
}
function SettingControl({ setting, value, locked, onSave }) {
if (setting.type === 'toggle') {
return (
<Switch
checked={value === 'on'}
disabled={locked}
onCheckedChange={(checked) => onSave(setting.id, checked ? 'on' : 'off')}
/>
);
}
if (setting.type === 'select') {
const current = value === undefined || value === null ? '' : String(value);
return (
<Select
value={current}
disabled={locked}
onValueChange={(v) => {
const opt = setting.options.find(o => String(o.value) === v);
onSave(setting.id, opt ? opt.value : v);
}}
>
<SelectTrigger className="cf-setting__select"><SelectValue /></SelectTrigger>
<SelectContent>
{setting.options.map(o => (
<SelectItem key={String(o.value)} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (setting.type === 'hsts') {
return <HstsControl value={value} locked={locked} onSave={onSave} settingId={setting.id} />;
}
return null;
}
// HSTS is a compound object setting; editing any field re-sends the whole
// strict_transport_security object so the others are preserved.
function HstsControl({ value, locked, onSave, settingId }) {
const { t } = useTranslation();
const sts = (value && value.strict_transport_security) || {};
const enabled = !!sts.enabled;
const patch = (changes) => {
const next = {
enabled: sts.enabled || false,
max_age: sts.max_age || 0,
include_subdomains: sts.include_subdomains || false,
preload: sts.preload || false,
nosniff: sts.nosniff !== undefined ? sts.nosniff : true,
...changes,
};
onSave(settingId, { strict_transport_security: next });
};
return (
<div className="cf-hsts">
<div className="cf-hsts__row">
<span>{t('app.cloudflareZoneSettings.enabled', 'Enabled')}</span>
<Switch
checked={enabled}
disabled={locked}
onCheckedChange={(checked) => patch({
enabled: checked,
// Give a sane max-age when first enabling.
max_age: checked && !sts.max_age ? 15552000 : sts.max_age || 0,
})}
/>
</div>
{enabled && (
<>
<div className="cf-hsts__row">
<span>{t('app.cloudflareZoneSettings.maxAge', 'Max age')}</span>
<Select
value={String(sts.max_age || 0)}
disabled={locked}
onValueChange={(v) => patch({ max_age: Number(v) })}
>
<SelectTrigger className="cf-setting__select"><SelectValue /></SelectTrigger>
<SelectContent>
{HSTS_MAX_AGE.map(o => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="cf-hsts__row">
<span>{t('app.cloudflareZoneSettings.includeSubdomains', 'Include subdomains')}</span>
<Switch
checked={!!sts.include_subdomains}
disabled={locked}
onCheckedChange={(checked) => patch({ include_subdomains: checked })}
/>
</div>
<div className="cf-hsts__row">
<span>{t('app.cloudflareZoneSettings.preload', 'Preload')}</span>
<Switch
checked={!!sts.preload}
disabled={locked}
onCheckedChange={(checked) => patch({ preload: checked })}
/>
</div>
</>
)}
</div>
);
}
export default CloudflareZoneSettings;
@@ -1,220 +0,0 @@
// Cloudflare operations — zone settings (SSL/TLS, Speed, Caching, Security) and
// one-click hardening, layered on the existing Cloudflare DNS connection. Zones
// are addressed by their ServerKit DNS zone id (same as the /dns API).
//
// The extension's own /api/v1/cloudflare client, built on the panel's
// ApiClient via the SDK singleton — moved out of the host's services/api/
// with the zone-ops surface (plan 52 Phase 2). The DNS-record layer and the
// Cloudflare connection itself stay core (they back /domains).
import { api } from 'serverkit-sdk';
async function getCloudflareZoneSettings(zoneId) {
return api.request(`/cloudflare/zones/${zoneId}/settings`);
}
async function getCloudflareZoneSetting(zoneId, settingId) {
return api.request(`/cloudflare/zones/${zoneId}/settings/${settingId}`);
}
async function updateCloudflareZoneSetting(zoneId, settingId, value) {
return api.request(`/cloudflare/zones/${zoneId}/settings/${settingId}`, {
method: 'PATCH',
body: { value },
});
}
async function applyCloudflareSettingsPreset(zoneId) {
return api.request(`/cloudflare/zones/${zoneId}/settings/apply-preset`, {
method: 'POST',
});
}
// Purge the zone's Cloudflare cache. payload is { purge_everything: true } or
// { files: [...] } (and on Enterprise, hosts/prefixes/tags).
async function purgeCloudflareCache(zoneId, payload) {
return api.request(`/cloudflare/zones/${zoneId}/purge-cache`, {
method: 'POST',
body: payload,
});
}
// WAF custom firewall rules (http_request_firewall_custom phase).
async function getCloudflareWafRules(zoneId) {
return api.request(`/cloudflare/zones/${zoneId}/waf/rules`);
}
async function addCloudflareWafRule(zoneId, rule) {
return api.request(`/cloudflare/zones/${zoneId}/waf/rules`, {
method: 'POST',
body: rule,
});
}
async function applyCloudflareWafPreset(zoneId, presetKey, params = {}) {
return api.request(`/cloudflare/zones/${zoneId}/waf/presets/${presetKey}`, {
method: 'POST',
body: { params },
});
}
async function updateCloudflareWafRule(zoneId, rulesetId, ruleId, fields) {
return api.request(`/cloudflare/zones/${zoneId}/waf/rulesets/${rulesetId}/rules/${ruleId}`, {
method: 'PATCH',
body: fields,
});
}
async function deleteCloudflareWafRule(zoneId, rulesetId, ruleId) {
return api.request(`/cloudflare/zones/${zoneId}/waf/rulesets/${rulesetId}/rules/${ruleId}`, {
method: 'DELETE',
});
}
// Workers (edge hosting). Account is resolved from the zone server-side.
async function getCloudflareWorkers(zoneId) {
return api.request(`/cloudflare/zones/${zoneId}/workers`);
}
async function deployCloudflareWorker(zoneId, payload) {
return api.request(`/cloudflare/zones/${zoneId}/workers`, {
method: 'POST',
body: payload,
});
}
async function deleteCloudflareWorker(zoneId, name) {
return api.request(`/cloudflare/zones/${zoneId}/workers/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
}
async function addCloudflareWorkerRoute(zoneId, pattern, script) {
return api.request(`/cloudflare/zones/${zoneId}/workers/routes`, {
method: 'POST',
body: { pattern, script },
});
}
async function deleteCloudflareWorkerRoute(zoneId, routeId) {
return api.request(`/cloudflare/zones/${zoneId}/workers/routes/${routeId}`, {
method: 'DELETE',
});
}
// Cloudflare Tunnels (cloudflared) — expose a local service through the edge.
async function getCloudflareTunnels(zoneId) {
return api.request(`/cloudflare/zones/${zoneId}/tunnels`);
}
async function createCloudflareTunnel(zoneId, name) {
return api.request(`/cloudflare/zones/${zoneId}/tunnels`, {
method: 'POST',
body: { name },
});
}
async function deleteCloudflareTunnel(zoneId, tunnelId) {
return api.request(`/cloudflare/zones/${zoneId}/tunnels/${tunnelId}`, {
method: 'DELETE',
});
}
async function getCloudflareTunnelInstall(zoneId, tunnelId) {
return api.request(`/cloudflare/zones/${zoneId}/tunnels/${tunnelId}/install`);
}
async function getCloudflareTunnelHostnames(zoneId, tunnelId) {
return api.request(`/cloudflare/zones/${zoneId}/tunnels/${tunnelId}/hostnames`);
}
async function addCloudflareTunnelHostname(zoneId, tunnelId, hostname, service) {
return api.request(`/cloudflare/zones/${zoneId}/tunnels/${tunnelId}/hostnames`, {
method: 'POST',
body: { hostname, service },
});
}
async function removeCloudflareTunnelHostname(zoneId, tunnelId, hostname) {
return api.request(`/cloudflare/zones/${zoneId}/tunnels/${tunnelId}/hostnames`, {
method: 'DELETE',
body: { hostname },
});
}
// Developer platform — R2 buckets, KV namespaces, D1 databases (account-scoped).
async function getCloudflareStorage(zoneId) {
return api.request(`/cloudflare/zones/${zoneId}/storage`);
}
async function createCloudflareR2Bucket(zoneId, name) {
return api.request(`/cloudflare/zones/${zoneId}/storage/r2`, {
method: 'POST',
body: { name },
});
}
async function deleteCloudflareR2Bucket(zoneId, name) {
return api.request(`/cloudflare/zones/${zoneId}/storage/r2/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
}
async function createCloudflareKvNamespace(zoneId, title) {
return api.request(`/cloudflare/zones/${zoneId}/storage/kv`, {
method: 'POST',
body: { title },
});
}
async function deleteCloudflareKvNamespace(zoneId, namespaceId) {
return api.request(`/cloudflare/zones/${zoneId}/storage/kv/${namespaceId}`, {
method: 'DELETE',
});
}
async function createCloudflareD1Database(zoneId, name) {
return api.request(`/cloudflare/zones/${zoneId}/storage/d1`, {
method: 'POST',
body: { name },
});
}
async function deleteCloudflareD1Database(zoneId, databaseId) {
return api.request(`/cloudflare/zones/${zoneId}/storage/d1/${databaseId}`, {
method: 'DELETE',
});
}
const cloudflareApi = {
getCloudflareZoneSettings,
getCloudflareZoneSetting,
updateCloudflareZoneSetting,
applyCloudflareSettingsPreset,
purgeCloudflareCache,
getCloudflareWafRules,
addCloudflareWafRule,
applyCloudflareWafPreset,
updateCloudflareWafRule,
deleteCloudflareWafRule,
getCloudflareWorkers,
deployCloudflareWorker,
deleteCloudflareWorker,
addCloudflareWorkerRoute,
deleteCloudflareWorkerRoute,
getCloudflareTunnels,
createCloudflareTunnel,
deleteCloudflareTunnel,
getCloudflareTunnelInstall,
getCloudflareTunnelHostnames,
addCloudflareTunnelHostname,
removeCloudflareTunnelHostname,
getCloudflareStorage,
createCloudflareR2Bucket,
deleteCloudflareR2Bucket,
createCloudflareKvNamespace,
deleteCloudflareKvNamespace,
createCloudflareD1Database,
deleteCloudflareD1Database,
};
export default cloudflareApi;
@@ -1,572 +0,0 @@
// Compiled through the extension's module graph (imported by index.jsx).
// The host's design-token partial is imported via a sync-safe relative
// path — the baked copy lives at frontend/src/plugins/serverkit-cloudflare-ops/,
// so this resolves to frontend/src/styles/_variables.scss.
@import '../../../styles/variables';
// Cloudflare zone settings — detail page reached from a Cloudflare-managed DNS
// zone. Full-bleed top bar (PageTopbar) + tabbed setting groups.
.cf-zone {
&__body {
padding: $spacing-lg;
max-width: 880px;
}
&__tabs {
margin-top: $spacing-sm;
}
&__error-actions {
display: flex;
justify-content: center;
gap: $spacing-sm;
margin-top: $spacing-md;
}
}
// Breadcrumb in the top-bar title slot (mirrors the service-detail crumbs).
.cf-crumbs {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
font-size: 0.95rem;
a {
color: $text-secondary;
text-decoration: none;
&:hover { color: $primary-color; }
}
&__sep { color: $text-tertiary; }
&__cur { color: $text-primary; font-weight: 600; }
}
.cf-panel {
background: $card-bg;
border: 1px solid $border-color;
border-radius: $radius-lg;
padding: $spacing-xs $spacing-lg;
margin-top: $spacing-md;
}
// One setting: label/help on the left, control hugging the right.
.cf-setting {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-lg;
padding: $spacing-md 0;
border-bottom: 1px solid $border-color;
&:last-child { border-bottom: none; }
&__info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
&__label {
font-weight: 600;
color: $text-primary;
}
&__help {
font-size: 0.85rem;
color: $text-muted;
max-width: 52ch;
line-height: 1.4;
}
&__badge {
align-self: flex-start;
margin-top: 2px;
}
&__control {
flex-shrink: 0;
display: flex;
align-items: center;
min-height: 32px;
}
&__select {
min-width: 200px;
}
}
// HSTS compound control — a small stack of rows.
.cf-hsts {
display: flex;
flex-direction: column;
gap: $spacing-sm;
min-width: 240px;
&__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
font-size: 0.88rem;
color: $text-secondary;
}
}
.cf-actions {
& + & { margin-top: $spacing-md; }
.cf-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-lg;
padding: $spacing-md 0;
&__text {
h3 {
margin: 0 0 4px;
font-size: 1rem;
display: inline-flex;
align-items: center;
gap: $spacing-xs;
}
p { margin: 0; color: $text-muted; font-size: 0.88rem; max-width: 60ch; line-height: 1.5; }
}
}
&__note {
margin: $spacing-sm 0 0;
color: $text-muted;
font-size: 0.85rem;
}
}
.cf-purge-files {
border-top: 1px solid $border-color;
padding-top: $spacing-md;
&__label {
display: block;
font-size: 0.85rem;
color: $text-secondary;
margin-bottom: $spacing-xs;
}
&__actions {
display: flex;
justify-content: flex-end;
margin-top: $spacing-sm;
}
}
// WAF tab — preset picker, custom-rule form, and the active-rules list.
.cf-waf {
&__loading {
padding: $spacing-lg;
color: $text-muted;
}
&__section {
padding: $spacing-md 0;
border-bottom: 1px solid $border-color;
&:last-child { border-bottom: none; }
}
&__heading {
margin: 0 0 4px;
font-size: 0.95rem;
}
&__hint {
margin: 0 0 $spacing-sm;
color: $text-muted;
font-size: 0.85rem;
}
&__field {
margin-bottom: $spacing-sm;
}
&__label {
display: block;
font-size: 0.82rem;
color: $text-secondary;
margin-bottom: 4px;
}
&__select {
max-width: 320px;
}
&__preset-desc {
margin: 0 0 $spacing-sm;
color: $text-secondary;
font-size: 0.85rem;
line-height: 1.4;
}
&__params {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
}
&__actions {
display: flex;
justify-content: flex-end;
margin-top: $spacing-sm;
}
&__list {
list-style: none;
margin: $spacing-sm 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
&__rule {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-md;
padding: $spacing-md;
background: $bg-elevated;
border: 1px solid $border-color;
border-radius: $radius-md;
}
&__rule-main {
min-width: 0;
flex: 1;
}
&__rule-head {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-bottom: $spacing-xs;
}
&__rule-desc {
font-weight: 600;
color: $text-primary;
}
&__rule-expr {
display: block;
font-family: $font-mono;
font-size: 0.8rem;
color: $text-secondary;
background: $bg-code;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
&__rule-actions {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-shrink: 0;
}
}
// Workers tab — deploy form + script/route lists.
.cf-workers {
&__loading {
padding: $spacing-lg;
color: $text-muted;
}
&__section {
padding: $spacing-md 0;
border-bottom: 1px solid $border-color;
&:last-child { border-bottom: none; }
}
&__heading {
margin: 0 0 $spacing-sm;
font-size: 0.95rem;
}
&__hint {
margin: $spacing-xs 0 0;
color: $text-muted;
font-size: 0.82rem;
}
&__field {
margin-bottom: $spacing-sm;
}
&__label {
display: block;
font-size: 0.82rem;
color: $text-secondary;
margin-bottom: 4px;
}
&__code {
font-family: $font-mono;
font-size: 0.82rem;
}
&__actions {
display: flex;
justify-content: flex-end;
}
&__list {
list-style: none;
margin: $spacing-sm 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
&__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
padding: $spacing-sm $spacing-md;
background: $bg-elevated;
border: 1px solid $border-color;
border-radius: $radius-md;
}
&__name {
display: flex;
align-items: center;
gap: $spacing-sm;
min-width: 0;
flex-wrap: wrap;
code {
font-family: $font-mono;
font-size: 0.85rem;
color: $text-primary;
}
}
&__meta {
color: $text-muted;
font-size: 0.8rem;
}
&__item-actions {
flex-shrink: 0;
}
}
// Tunnels tab — create form, install command box, tunnel list with hostnames.
.cf-tunnels {
&__loading {
padding: $spacing-lg;
color: $text-muted;
}
&__section {
padding: $spacing-md 0;
border-bottom: 1px solid $border-color;
&:last-child { border-bottom: none; }
}
&__heading {
margin: 0 0 $spacing-sm;
font-size: 0.95rem;
}
&__hint {
margin: $spacing-xs 0 0;
color: $text-muted;
font-size: 0.82rem;
}
&__field {
margin-bottom: $spacing-sm;
max-width: 420px;
}
&__label {
display: block;
font-size: 0.82rem;
color: $text-secondary;
margin-bottom: 4px;
}
&__actions {
display: flex;
justify-content: flex-start;
}
&__install {
margin-top: $spacing-md;
padding: $spacing-md;
background: $bg-code;
border: 1px solid $border-color;
border-radius: $radius-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
align-items: flex-start;
}
&__cmd {
display: block;
width: 100%;
font-family: $font-mono;
font-size: 0.8rem;
color: $text-primary;
white-space: pre-wrap;
word-break: break-all;
}
&__list {
list-style: none;
margin: $spacing-sm 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
&__item {
padding: $spacing-md;
background: $bg-elevated;
border: 1px solid $border-color;
border-radius: $radius-md;
}
&__item-head {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
&__name {
font-weight: 600;
color: $text-primary;
}
&__meta {
color: $text-muted;
font-size: 0.8rem;
font-family: $font-mono;
}
&__item-actions {
display: flex;
gap: $spacing-sm;
margin-top: $spacing-sm;
flex-wrap: wrap;
}
&__hostnames {
margin-top: $spacing-md;
padding-top: $spacing-md;
border-top: 1px solid $border-color;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
&__host {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
code {
font-family: $font-mono;
font-size: 0.82rem;
color: $text-secondary;
}
}
&__host-add {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
align-items: center;
}
}
// Storage tab — R2 buckets, KV namespaces, D1 databases.
.cf-storage {
&__loading {
padding: $spacing-lg;
color: $text-muted;
}
&__section {
padding: $spacing-md 0;
border-bottom: 1px solid $border-color;
&:last-child { border-bottom: none; }
}
&__heading {
margin: 0 0 $spacing-sm;
font-size: 0.95rem;
}
&__hint {
margin: $spacing-xs 0;
color: $text-muted;
font-size: 0.82rem;
}
&__error {
margin: $spacing-xs 0;
color: $text-muted;
font-size: 0.82rem;
font-style: italic;
}
&__create {
display: flex;
gap: $spacing-sm;
align-items: center;
max-width: 420px;
margin-bottom: $spacing-sm;
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
&__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
padding: $spacing-sm $spacing-md;
background: $bg-elevated;
border: 1px solid $border-color;
border-radius: $radius-md;
}
&__name {
font-family: $font-mono;
font-size: 0.85rem;
color: $text-primary;
}
}
@@ -1,25 +0,0 @@
{
"name": "serverkit-cloudflare-ops",
"display_name": "Cloudflare Zone Ops",
"version": "1.0.0",
"description": "Cloudflare zone operations on top of your existing Cloudflare DNS connection, covering zone settings, cache purge, WAF, Workers, Tunnels, and R2/KV/D1 storage. DNS records and the connection itself stay in the core Domains surface; this adds the per-zone control panel reached from a Cloudflare-managed domain.",
"author": "ServerKit",
"license": "MIT",
"category": "integration",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": ["network"],
"min_panel_version": "1.7.0",
"featured": true,
"feature_score": 70,
"entry_point": "cloudflare:cloudflare_bp",
"url_prefix": "/api/v1/cloudflare",
"icon": "<path d=\"M20 17.5a3.5 3.5 0 00-.5-6.96A5.5 5.5 0 008.6 9.6 4 4 0 007 17.5z\"/>",
"contributions": {
"routes": [
{ "path": "cloudflare/zones/:zoneId", "component": "CloudflareZoneSettingsPage" }
],
"page_titles": {
"/cloudflare/zones": "Cloudflare Zone"
}
}
}
@@ -1 +0,0 @@
"""ServerKit Email extension backend package."""
@@ -1,271 +0,0 @@
"""OpenDKIM management service for DKIM signing."""
import os
import re
import subprocess
from typing import Dict
from app.utils.system import (PackageManager, ServiceControl, run_checked,
run_privileged)
class DKIMService:
"""Service for managing OpenDKIM (DKIM email signing)."""
OPENDKIM_CONF = '/etc/opendkim.conf'
OPENDKIM_DIR = '/etc/opendkim'
OPENDKIM_KEYS_DIR = '/etc/opendkim/keys'
KEY_TABLE = '/etc/opendkim/KeyTable'
SIGNING_TABLE = '/etc/opendkim/SigningTable'
TRUSTED_HOSTS = '/etc/opendkim/TrustedHosts'
OPENDKIM_CONF_CONTENT = """# OpenDKIM configuration - Managed by ServerKit
Syslog yes
SyslogSuccess yes
LogWhy yes
Canonicalization relaxed/simple
Mode sv
SubDomains no
AutoRestart yes
AutoRestartRate 10/1M
Background yes
DNSTimeout 5
SignatureAlgorithm rsa-sha256
KeyTable refile:/etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList /etc/opendkim/TrustedHosts
InternalHosts /etc/opendkim/TrustedHosts
Socket inet:8891@localhost
PidFile /run/opendkim/opendkim.pid
OversignHeaders From
UserID opendkim
UMask 007
"""
TRUSTED_HOSTS_DEFAULT = """# Trusted hosts - Managed by ServerKit
127.0.0.1
::1
localhost
"""
@classmethod
def get_status(cls) -> Dict:
"""Get OpenDKIM installation and running status."""
installed = False
running = False
enabled = False
version = None
try:
result = run_checked(['which', 'opendkim'], timeout=None)
installed = result['success']
if not installed:
installed = PackageManager.is_installed('opendkim')
if installed:
running = ServiceControl.is_active('opendkim')
enabled = ServiceControl.is_enabled('opendkim')
result = run_checked(['opendkim', '-V'], timeout=None, merge_stderr=True)
match = re.search(r'OpenDKIM\s+Filter\s+v(\S+)', result['output'])
if match:
version = match.group(1)
except (subprocess.SubprocessError, FileNotFoundError):
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
}
@classmethod
def install(cls) -> Dict:
"""Install OpenDKIM."""
try:
result = PackageManager.install(['opendkim', 'opendkim-tools'], timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install OpenDKIM'}
# Create directories
run_privileged(['mkdir', '-p', cls.OPENDKIM_KEYS_DIR])
run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR])
# Create config files
run_privileged(['tee', cls.OPENDKIM_CONF], input=cls.OPENDKIM_CONF_CONTENT)
run_privileged(['tee', cls.TRUSTED_HOSTS], input=cls.TRUSTED_HOSTS_DEFAULT)
run_privileged(['touch', cls.KEY_TABLE])
run_privileged(['touch', cls.SIGNING_TABLE])
# Set permissions
run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR])
run_privileged(['chmod', '700', cls.OPENDKIM_KEYS_DIR])
# Create PID directory
run_privileged(['mkdir', '-p', '/run/opendkim'])
run_privileged(['chown', 'opendkim:opendkim', '/run/opendkim'])
# Add postfix to opendkim group
run_privileged(['usermod', '-aG', 'opendkim', 'postfix'])
ServiceControl.enable('opendkim')
ServiceControl.start('opendkim', timeout=30)
return {'success': True, 'message': 'OpenDKIM installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def generate_key(cls, domain: str, selector: str = 'default') -> Dict:
"""Generate DKIM key pair for a domain."""
try:
key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain)
run_privileged(['mkdir', '-p', key_dir])
# Generate key
result = run_privileged([
'opendkim-genkey',
'-s', selector,
'-d', domain,
'-D', key_dir,
'-b', '2048',
])
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Key generation failed'}
# Set permissions
run_privileged(['chown', '-R', 'opendkim:opendkim', key_dir])
run_privileged(['chmod', '600', os.path.join(key_dir, f'{selector}.private')])
# Read the public key TXT record
txt_file = os.path.join(key_dir, f'{selector}.txt')
result = run_privileged(['cat', txt_file])
public_key_record = result.stdout.strip() if result.returncode == 0 else ''
# Extract just the key value from the TXT record
key_match = re.search(r'p=([A-Za-z0-9+/=\s]+)', public_key_record)
public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '') if key_match else ''
return {
'success': True,
'domain': domain,
'selector': selector,
'private_key_path': os.path.join(key_dir, f'{selector}.private'),
'public_key': public_key,
'dns_record': public_key_record,
'dns_name': f'{selector}._domainkey.{domain}',
'dns_value': f'v=DKIM1; k=rsa; p={public_key}',
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def add_domain(cls, domain: str, selector: str = 'default') -> Dict:
"""Add domain to KeyTable and SigningTable."""
try:
key_path = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.private')
# Add to KeyTable
key_entry = f'{selector}._domainkey.{domain} {domain}:{selector}:{key_path}\n'
result = run_privileged(['cat', cls.KEY_TABLE])
if domain not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.KEY_TABLE], input=key_entry)
# Add to SigningTable
signing_entry = f'*@{domain} {selector}._domainkey.{domain}\n'
result = run_privileged(['cat', cls.SIGNING_TABLE])
if domain not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.SIGNING_TABLE], input=signing_entry)
# Add to TrustedHosts
result = run_privileged(['cat', cls.TRUSTED_HOSTS])
if domain not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.TRUSTED_HOSTS], input=f'*.{domain}\n')
# Reload OpenDKIM
ServiceControl.restart('opendkim', timeout=30)
return {'success': True, 'message': f'Domain {domain} added to DKIM'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def remove_domain(cls, domain: str) -> Dict:
"""Remove domain from DKIM configuration."""
try:
# Remove from KeyTable
result = run_privileged(['cat', cls.KEY_TABLE])
lines = [l for l in (result.stdout or '').splitlines() if domain not in l]
run_privileged(['tee', cls.KEY_TABLE], input='\n'.join(lines) + '\n')
# Remove from SigningTable
result = run_privileged(['cat', cls.SIGNING_TABLE])
lines = [l for l in (result.stdout or '').splitlines() if domain not in l]
run_privileged(['tee', cls.SIGNING_TABLE], input='\n'.join(lines) + '\n')
# Remove from TrustedHosts
result = run_privileged(['cat', cls.TRUSTED_HOSTS])
lines = [l for l in (result.stdout or '').splitlines() if domain not in l]
run_privileged(['tee', cls.TRUSTED_HOSTS], input='\n'.join(lines) + '\n')
# Remove key files
key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain)
run_privileged(['rm', '-rf', key_dir])
ServiceControl.restart('opendkim', timeout=30)
return {'success': True, 'message': f'Domain {domain} removed from DKIM'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_dns_record(cls, domain: str, selector: str = 'default') -> Dict:
"""Get the DKIM DNS TXT record content for a domain."""
try:
txt_file = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.txt')
result = run_privileged(['cat', txt_file])
if result.returncode != 0:
return {'success': False, 'error': 'DKIM key not found'}
record = result.stdout.strip()
key_match = re.search(r'p=([A-Za-z0-9+/=\s"]+)', record)
public_key = ''
if key_match:
public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '')
return {
'success': True,
'dns_name': f'{selector}._domainkey.{domain}',
'dns_value': f'v=DKIM1; k=rsa; p={public_key}',
'raw_record': record,
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def verify_key(cls, domain: str, selector: str = 'default') -> Dict:
"""Verify DKIM key configuration."""
try:
result = run_privileged([
'opendkim-testkey',
'-d', domain,
'-s', selector,
'-vvv',
])
success = result.returncode == 0
output = (result.stdout or '') + (result.stderr or '')
return {
'success': success,
'verified': success and 'key OK' in output,
'output': output,
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload OpenDKIM."""
try:
result = ServiceControl.restart('opendkim', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'OpenDKIM restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -1,374 +0,0 @@
"""Dovecot IMAP/POP3 server management service."""
import os
import re
import subprocess
from typing import Dict, Optional
from app.utils.system import (PackageManager, ServiceControl, is_command_available,
run_privileged)
from app import paths
class DovecotService:
"""Service for managing Dovecot (IMAP/POP3 server)."""
DOVECOT_CONF_DIR = '/etc/dovecot'
DOVECOT_CONF = '/etc/dovecot/dovecot.conf'
DOVECOT_AUTH_CONF = '/etc/dovecot/conf.d/10-auth.conf'
DOVECOT_MAIL_CONF = '/etc/dovecot/conf.d/10-mail.conf'
DOVECOT_MASTER_CONF = '/etc/dovecot/conf.d/10-master.conf'
DOVECOT_SSL_CONF = '/etc/dovecot/conf.d/10-ssl.conf'
DOVECOT_PASSWD_FILE = '/etc/dovecot/users'
AUTH_PASSWDFILE_CONF = '/etc/dovecot/conf.d/auth-passwdfile.conf.ext'
MAIL_CONF_CONTENT = """# Dovecot mail configuration - Managed by ServerKit
mail_location = maildir:{vmail_dir}/%d/%n/Maildir
namespace inbox {{
inbox = yes
separator = /
}}
mail_uid = {vmail_uid}
mail_gid = {vmail_gid}
mail_privileged_group = vmail
first_valid_uid = {vmail_uid}
last_valid_uid = {vmail_uid}
"""
AUTH_CONF_CONTENT = """# Dovecot auth configuration - Managed by ServerKit
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include auth-passwdfile.conf.ext
"""
AUTH_PASSWDFILE_CONTENT = """# Password file auth - Managed by ServerKit
passdb {{
driver = passwd-file
args = scheme=SHA512-CRYPT /etc/dovecot/users
}}
userdb {{
driver = static
args = uid={vmail_uid} gid={vmail_gid} home={vmail_dir}/%d/%n
}}
"""
MASTER_CONF_CONTENT = """# Dovecot master configuration - Managed by ServerKit
service imap-login {
inet_listener imap {
port = 0
}
inet_listener imaps {
port = 993
ssl = yes
}
}
service pop3-login {
inet_listener pop3 {
port = 0
}
inet_listener pop3s {
port = 995
ssl = yes
}
}
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0666
user = postfix
group = postfix
}
unix_listener auth-userdb {
mode = 0600
user = vmail
group = vmail
}
user = dovecot
}
service auth-worker {
user = vmail
}
"""
SSL_CONF_CONTENT = """# Dovecot SSL configuration - Managed by ServerKit
ssl = required
ssl_cert = <{tls_cert}
ssl_key = <{tls_key}
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes
"""
@classmethod
def get_status(cls) -> Dict:
"""Get Dovecot installation and running status."""
installed = False
running = False
enabled = False
version = None
# `which` searches $PATH only, and dovecot lives in /usr/sbin — absent
# from the panel unit's PATH — so it reported a false negative on a host
# where dovecot was installed. is_command_available() also searches the
# sbin dirs.
try:
installed = is_command_available('dovecot')
if not installed:
installed = PackageManager.is_installed('dovecot-core') or PackageManager.is_installed('dovecot')
except Exception:
installed = False
if installed:
try:
running = ServiceControl.is_active('dovecot')
enabled = ServiceControl.is_enabled('dovecot')
except (subprocess.SubprocessError, FileNotFoundError):
# No systemctl (container, non-systemd init), or systemd not
# answering inside PROBE_TIMEOUT: "could not check", not "not
# running" — and never a 500. Wider than FileNotFoundError on
# purpose: is_enabled already swallows the missing binary, but
# the probe timeout in both wrappers still raises
# TimeoutExpired straight through get_status and out of
# /api/v1/email/status. The sibling services in this extension
# catch the same pair.
running = None
enabled = None
# Its own try: failing to read the version says nothing about
# whether dovecot is installed. Collapsing the two is how
# FirewallService._check_ufw reported a working firewall as absent.
try:
result = run_privileged(['dovecot', '--version'])
version_match = re.search(r'(\d+\.\d+\.\d+)', result.stdout or '')
if version_match:
version = version_match.group(1)
except Exception:
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
}
@classmethod
def install(cls) -> Dict:
"""Install Dovecot packages."""
try:
manager = PackageManager.detect()
if manager == 'apt':
packages = ['dovecot-core', 'dovecot-imapd', 'dovecot-pop3d', 'dovecot-lmtpd', 'dovecot-sieve']
else:
packages = ['dovecot']
result = PackageManager.install(packages, timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install Dovecot'}
# Create empty passwd file
run_privileged(['touch', cls.DOVECOT_PASSWD_FILE])
run_privileged(['chown', 'vmail:dovecot', cls.DOVECOT_PASSWD_FILE])
run_privileged(['chmod', '640', cls.DOVECOT_PASSWD_FILE])
ServiceControl.enable('dovecot')
return {'success': True, 'message': 'Dovecot installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure(cls, tls_cert: str = None, tls_key: str = None) -> Dict:
"""Write Dovecot configuration files."""
try:
cert = tls_cert or '/etc/ssl/certs/ssl-cert-snakeoil.pem'
key = tls_key or '/etc/ssl/private/ssl-cert-snakeoil.key'
# 10-mail.conf
mail_conf = cls.MAIL_CONF_CONTENT.format(
vmail_dir=paths.VMAIL_DIR,
vmail_uid=paths.VMAIL_UID,
vmail_gid=paths.VMAIL_GID,
)
run_privileged(['tee', cls.DOVECOT_MAIL_CONF], input=mail_conf)
# 10-auth.conf
run_privileged(['tee', cls.DOVECOT_AUTH_CONF], input=cls.AUTH_CONF_CONTENT)
# auth-passwdfile.conf.ext
auth_passwd = cls.AUTH_PASSWDFILE_CONTENT.format(
vmail_uid=paths.VMAIL_UID,
vmail_gid=paths.VMAIL_GID,
vmail_dir=paths.VMAIL_DIR,
)
run_privileged(['tee', cls.AUTH_PASSWDFILE_CONF], input=auth_passwd)
# 10-master.conf
run_privileged(['tee', cls.DOVECOT_MASTER_CONF], input=cls.MASTER_CONF_CONTENT)
# 10-ssl.conf
ssl_conf = cls.SSL_CONF_CONTENT.format(tls_cert=cert, tls_key=key)
run_privileged(['tee', cls.DOVECOT_SSL_CONF], input=ssl_conf)
# Restart to apply
ServiceControl.restart('dovecot', timeout=30)
return {'success': True, 'message': 'Dovecot configured successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def create_mailbox(cls, email: str, password: str, domain: str, username: str, quota_mb: int = 1024) -> Dict:
"""Create a virtual mailbox with password entry."""
try:
# Generate password hash using doveadm
result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', password])
if result.returncode != 0:
return {'success': False, 'error': 'Failed to hash password'}
password_hash = result.stdout.strip()
# Build passwd-file entry
# Format: user@domain:{scheme}hash:uid:gid:home::userdb_quota_rule=*:storage=NM
entry = f'{email}:{password_hash}:{paths.VMAIL_UID}:{paths.VMAIL_GID}:{paths.VMAIL_DIR}/{domain}/{username}::userdb_quota_rule=*:storage={quota_mb}M'
# Append to passwd file
run_privileged(['tee', '-a', cls.DOVECOT_PASSWD_FILE], input=entry + '\n')
# Create Maildir
maildir = os.path.join(paths.VMAIL_DIR, domain, username, 'Maildir')
run_privileged(['mkdir', '-p', f'{maildir}/cur', f'{maildir}/new', f'{maildir}/tmp'])
run_privileged(['chown', '-R', f'{paths.VMAIL_UID}:{paths.VMAIL_GID}',
os.path.join(paths.VMAIL_DIR, domain, username)])
return {'success': True, 'message': f'Mailbox {email} created'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def delete_mailbox(cls, email: str, domain: str, username: str, remove_files: bool = False) -> Dict:
"""Delete a virtual mailbox."""
try:
# Remove from passwd file
result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE])
lines = (result.stdout or '').splitlines()
new_lines = [l for l in lines if not l.startswith(f'{email}:')]
run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n')
# Optionally remove Maildir
if remove_files:
mailbox_path = os.path.join(paths.VMAIL_DIR, domain, username)
run_privileged(['rm', '-rf', mailbox_path])
return {'success': True, 'message': f'Mailbox {email} deleted'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def change_password(cls, email: str, new_password: str) -> Dict:
"""Change a mailbox password."""
try:
# Generate new hash
result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', new_password])
if result.returncode != 0:
return {'success': False, 'error': 'Failed to hash password'}
new_hash = result.stdout.strip()
# Read current file
result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE])
lines = (result.stdout or '').splitlines()
updated = False
new_lines = []
for line in lines:
if line.startswith(f'{email}:'):
parts = line.split(':')
parts[1] = new_hash
new_lines.append(':'.join(parts))
updated = True
else:
new_lines.append(line)
if not updated:
return {'success': False, 'error': f'Account {email} not found'}
run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n')
return {'success': True, 'message': 'Password changed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def set_quota(cls, email: str, quota_mb: int) -> Dict:
"""Update mailbox quota."""
try:
result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE])
lines = (result.stdout or '').splitlines()
updated = False
new_lines = []
for line in lines:
if line.startswith(f'{email}:'):
# Replace quota in the userdb_quota_rule field
line = re.sub(r'userdb_quota_rule=\*:storage=\d+M',
f'userdb_quota_rule=*:storage={quota_mb}M', line)
new_lines.append(line)
updated = True
else:
new_lines.append(line)
if not updated:
return {'success': False, 'error': f'Account {email} not found'}
run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n')
return {'success': True, 'message': f'Quota set to {quota_mb}MB'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_quota_usage(cls, email: str) -> Dict:
"""Get mailbox quota usage."""
try:
result = run_privileged(['doveadm', 'quota', 'get', '-u', email])
if result.returncode != 0:
return {'success': False, 'error': 'Failed to get quota'}
# Parse output
usage = {'storage_used': 0, 'storage_limit': 0, 'message_count': 0}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[0] == 'STORAGE':
usage['storage_used'] = int(parts[1]) // 1024 # KB to MB
usage['storage_limit'] = int(parts[2]) // 1024 if parts[2] != '-' else 0
elif len(parts) >= 4 and parts[0] == 'MESSAGE':
usage['message_count'] = int(parts[1])
return {'success': True, **usage}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload Dovecot configuration."""
try:
result = ServiceControl.reload('dovecot', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'Dovecot reloaded'}
return {'success': False, 'error': result.stderr or 'Reload failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def restart(cls) -> Dict:
"""Restart Dovecot."""
try:
result = ServiceControl.restart('dovecot', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'Dovecot restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -1,426 +0,0 @@
"""Email Server API endpoints for managing mail services, domains, accounts, and DNS.
Lives in the serverkit-email extension (app.plugins.serverkit-email.email after
install). Intra-extension modules are imported relatively; core services
(postfix relay, DNS, relay-config, RBAC) are imported absolutely — the mail
server extends the core, never the other way around.
"""
from flask import Blueprint, request, jsonify
from app.middleware.rbac import admin_required, viewer_required
from app.services.dns_provider_service import DNSProviderService
from app.services.postfix_service import PostfixService
from app.services.email_relay_service import EmailRelayService
from .email_service import EmailService
from .spamassassin_service import SpamAssassinService
from .roundcube_service import RoundcubeService
email_bp = Blueprint('email', __name__)
# ── Status & Installation ──
@email_bp.route('/status', methods=['GET'])
@viewer_required
def get_status():
"""Get aggregate email server status."""
roundcube = RoundcubeService.get_status()
status = EmailService.get_status()
status['roundcube'] = roundcube
return jsonify(status), 200
@email_bp.route('/install', methods=['POST'])
@admin_required
def install():
"""Install and configure all email components."""
data = request.get_json() or {}
hostname = data.get('hostname')
result = EmailService.install_all(hostname)
return jsonify(result), 200 if result.get('success') else 500
@email_bp.route('/service/<component>/<action>', methods=['POST'])
@admin_required
def control_service(component, action):
"""Start/stop/restart an email component."""
result = EmailService.control_service(component, action)
return jsonify(result), 200 if result.get('success') else 400
# ── Domains ──
@email_bp.route('/domains', methods=['GET'])
@viewer_required
def list_domains():
"""List all email domains."""
domains = EmailService.get_domains()
return jsonify({'domains': domains}), 200
@email_bp.route('/domains', methods=['POST'])
@admin_required
def add_domain():
"""Add an email domain."""
data = request.get_json()
if not data or not data.get('name'):
return jsonify({'success': False, 'error': 'Domain name is required'}), 400
result = EmailService.add_domain(
data['name'],
dns_provider_id=data.get('dns_provider_id'),
dns_zone_id=data.get('dns_zone_id'),
)
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/domains/<int:domain_id>', methods=['GET'])
@viewer_required
def get_domain(domain_id):
"""Get domain details."""
result = EmailService.get_domain(domain_id)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@email_bp.route('/domains/<int:domain_id>', methods=['DELETE'])
@admin_required
def remove_domain(domain_id):
"""Remove an email domain."""
result = EmailService.remove_domain(domain_id)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/domains/<int:domain_id>/verify-dns', methods=['POST'])
@viewer_required
def verify_dns(domain_id):
"""Verify DNS records for a domain."""
result = EmailService.verify_dns(domain_id)
return jsonify(result), 200
@email_bp.route('/domains/<int:domain_id>/deploy-dns', methods=['POST'])
@admin_required
def deploy_dns(domain_id):
"""Deploy DNS records via provider API."""
from app.models.email import EmailDomain
domain = EmailDomain.query.get(domain_id)
if not domain:
return jsonify({'success': False, 'error': 'Domain not found'}), 404
if not domain.dns_provider_id or not domain.dns_zone_id:
return jsonify({'success': False, 'error': 'No DNS provider configured for this domain'}), 400
result = DNSProviderService.deploy_email_records(
domain.dns_provider_id,
domain.dns_zone_id,
domain.name,
domain.dkim_selector or 'default',
domain.dkim_public_key or '',
)
return jsonify(result), 200 if result.get('success') else 400
# ── Accounts ──
@email_bp.route('/domains/<int:domain_id>/accounts', methods=['GET'])
@viewer_required
def list_accounts(domain_id):
"""List email accounts for a domain."""
accounts = EmailService.get_accounts(domain_id)
return jsonify({'accounts': accounts}), 200
@email_bp.route('/domains/<int:domain_id>/accounts', methods=['POST'])
@admin_required
def create_account(domain_id):
"""Create an email account."""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({'success': False, 'error': 'Username and password are required'}), 400
result = EmailService.add_account(
domain_id,
username,
password,
quota_mb=data.get('quota_mb', 1024),
)
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/accounts/<int:account_id>', methods=['GET'])
@viewer_required
def get_account(account_id):
"""Get account details."""
from app.models.email import EmailAccount
account = EmailAccount.query.get(account_id)
if not account:
return jsonify({'success': False, 'error': 'Account not found'}), 404
return jsonify({'success': True, 'account': account.to_dict()}), 200
@email_bp.route('/accounts/<int:account_id>', methods=['PUT'])
@admin_required
def update_account(account_id):
"""Update account settings."""
data = request.get_json() or {}
result = EmailService.update_account(
account_id,
quota_mb=data.get('quota_mb'),
is_active=data.get('is_active'),
)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/accounts/<int:account_id>', methods=['DELETE'])
@admin_required
def delete_account(account_id):
"""Delete an email account."""
result = EmailService.delete_account(account_id)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/accounts/<int:account_id>/password', methods=['POST'])
@admin_required
def change_password(account_id):
"""Change account password."""
data = request.get_json()
if not data or not data.get('password'):
return jsonify({'success': False, 'error': 'New password is required'}), 400
result = EmailService.change_password(account_id, data['password'])
return jsonify(result), 200 if result.get('success') else 400
# ── Aliases ──
@email_bp.route('/domains/<int:domain_id>/aliases', methods=['GET'])
@viewer_required
def list_aliases(domain_id):
"""List email aliases for a domain."""
aliases = EmailService.get_aliases(domain_id)
return jsonify({'aliases': aliases}), 200
@email_bp.route('/domains/<int:domain_id>/aliases', methods=['POST'])
@admin_required
def create_alias(domain_id):
"""Create an email alias."""
data = request.get_json()
if not data or not data.get('source') or not data.get('destination'):
return jsonify({'success': False, 'error': 'Source and destination are required'}), 400
result = EmailService.add_alias(domain_id, data['source'], data['destination'])
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/aliases/<int:alias_id>', methods=['DELETE'])
@admin_required
def delete_alias(alias_id):
"""Delete an email alias."""
result = EmailService.remove_alias(alias_id)
return jsonify(result), 200 if result.get('success') else 400
# ── Forwarding Rules ──
@email_bp.route('/accounts/<int:account_id>/forwarding', methods=['GET'])
@viewer_required
def list_forwarding(account_id):
"""List forwarding rules for an account."""
rules = EmailService.get_forwarding(account_id)
return jsonify({'rules': rules}), 200
@email_bp.route('/accounts/<int:account_id>/forwarding', methods=['POST'])
@admin_required
def create_forwarding(account_id):
"""Create a forwarding rule."""
data = request.get_json()
if not data or not data.get('destination'):
return jsonify({'success': False, 'error': 'Destination is required'}), 400
result = EmailService.add_forwarding(
account_id,
data['destination'],
keep_copy=data.get('keep_copy', True),
)
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/forwarding/<int:rule_id>', methods=['PUT'])
@admin_required
def update_forwarding(rule_id):
"""Update a forwarding rule."""
data = request.get_json() or {}
result = EmailService.update_forwarding(
rule_id,
destination=data.get('destination'),
keep_copy=data.get('keep_copy'),
is_active=data.get('is_active'),
)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/forwarding/<int:rule_id>', methods=['DELETE'])
@admin_required
def delete_forwarding(rule_id):
"""Delete a forwarding rule."""
result = EmailService.remove_forwarding(rule_id)
return jsonify(result), 200 if result.get('success') else 400
# ── DNS Providers ──
# Moved to core (app/api/dns_providers.py, same /api/v1/email/dns-providers
# paths): the Connections page and wildcard TLS need them without this
# extension installed.
# ── Outbound SMTP Relay (smarthost) ──
@email_bp.route('/relay', methods=['GET'])
@viewer_required
def get_relay():
"""Get the outbound SMTP relay configuration (password masked)."""
return jsonify(EmailRelayService.get_config()), 200
@email_bp.route('/relay', methods=['PUT'])
@admin_required
def save_relay():
"""Save the relay configuration and apply it to Postfix (if installed)."""
data = request.get_json() or {}
if not data.get('host'):
return jsonify({'success': False, 'error': 'A relay host is required'}), 400
result = EmailRelayService.save_config(data)
return jsonify(result), 200
@email_bp.route('/relay/test', methods=['POST'])
@admin_required
def test_relay():
"""Open a real SMTP connection to validate the relay credentials."""
data = request.get_json() or {}
result = EmailRelayService.test(data)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/relay', methods=['DELETE'])
@admin_required
def disable_relay():
"""Disable the outbound relay (revert Postfix to direct delivery)."""
result = EmailRelayService.disable()
return jsonify(result), 200
# ── SpamAssassin Config ──
@email_bp.route('/spam/config', methods=['GET'])
@viewer_required
def get_spam_config():
"""Get SpamAssassin configuration."""
result = SpamAssassinService.get_config()
return jsonify(result), 200
@email_bp.route('/spam/config', methods=['PUT'])
@admin_required
def update_spam_config():
"""Update SpamAssassin configuration."""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
result = SpamAssassinService.configure(data)
if result.get('success'):
SpamAssassinService.reload()
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/spam/update-rules', methods=['POST'])
@admin_required
def update_spam_rules():
"""Update SpamAssassin rules."""
result = SpamAssassinService.update_rules()
return jsonify(result), 200 if result.get('success') else 400
# ── Roundcube Webmail ──
@email_bp.route('/webmail/status', methods=['GET'])
@viewer_required
def webmail_status():
"""Get Roundcube webmail status."""
result = RoundcubeService.get_status()
return jsonify(result), 200
@email_bp.route('/webmail/install', methods=['POST'])
@admin_required
def webmail_install():
"""Install Roundcube webmail."""
data = request.get_json() or {}
result = RoundcubeService.install(
imap_host=data.get('imap_host', 'host.docker.internal'),
smtp_host=data.get('smtp_host', 'host.docker.internal'),
domain=(data.get('domain') or '').strip() or None,
)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/webmail/service/<action>', methods=['POST'])
@admin_required
def webmail_control(action):
"""Start/stop/restart Roundcube."""
actions = {
'start': RoundcubeService.start,
'stop': RoundcubeService.stop,
'restart': RoundcubeService.restart,
}
if action not in actions:
return jsonify({'success': False, 'error': 'Invalid action'}), 400
result = actions[action]()
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/webmail/configure-proxy', methods=['POST'])
@admin_required
def webmail_configure_proxy():
"""Configure Nginx reverse proxy for Roundcube."""
data = request.get_json()
if not data or not data.get('domain'):
return jsonify({'success': False, 'error': 'Domain is required'}), 400
result = RoundcubeService.configure_nginx_proxy(data['domain'])
return jsonify(result), 200 if result.get('success') else 400
# ── Mail Queue & Logs ──
@email_bp.route('/queue', methods=['GET'])
@viewer_required
def get_queue():
"""Get Postfix mail queue."""
result = PostfixService.get_queue()
return jsonify(result), 200
@email_bp.route('/queue/flush', methods=['POST'])
@admin_required
def flush_queue():
"""Flush the mail queue."""
result = PostfixService.flush_queue()
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/queue/<queue_id>', methods=['DELETE'])
@admin_required
def delete_queue_item(queue_id):
"""Delete a message from the queue."""
result = PostfixService.delete_from_queue(queue_id)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/logs', methods=['GET'])
@viewer_required
def get_logs():
"""Get mail logs."""
lines = request.args.get('lines', 100, type=int)
result = PostfixService.get_logs(lines)
return jsonify(result), 200
@@ -1,550 +0,0 @@
"""Email service orchestrator — delegates to sub-services for each component."""
import logging
import subprocess
from typing import Dict, List, Optional
from app import db
from app.models.email import EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule
from app.services.postfix_service import PostfixService
from app.utils.system import PackageManager, ServiceControl, run_checked
from .dovecot_service import DovecotService
from .dkim_service import DKIMService
from .spamassassin_service import SpamAssassinService
from .roundcube_service import RoundcubeService
logger = logging.getLogger(__name__)
class EmailService:
"""High-level email service that coordinates Postfix, Dovecot, DKIM, and SpamAssassin."""
COMPONENTS = {
'postfix': PostfixService,
'dovecot': DovecotService,
'opendkim': DKIMService,
'spamassassin': SpamAssassinService,
}
# ── Status ──
@classmethod
def get_status(cls) -> Dict:
"""Get aggregate email server status."""
postfix = PostfixService.get_status()
dovecot = DovecotService.get_status()
dkim = DKIMService.get_status()
spam = SpamAssassinService.get_status()
all_installed = postfix['installed'] and dovecot['installed']
# bool(): a component whose probe could not run reports running=None
# (see DovecotService.get_status), and `True and None` is None — the
# top-level flag is documented as a boolean, so keep it one. The
# honest per-component tri-state is still in the nested dicts.
all_running = bool(postfix.get('running', False) and dovecot.get('running', False))
return {
'installed': all_installed,
'running': all_running,
'postfix': postfix,
'dovecot': dovecot,
'dkim': dkim,
'spamassassin': spam,
}
# ── Installation ──
@classmethod
def install_all(cls, hostname: str = None) -> Dict:
"""Install and configure all email components."""
results = {}
# 1. Install Postfix
results['postfix'] = PostfixService.install(hostname=hostname)
if not results['postfix'].get('success'):
return {'success': False, 'error': 'Postfix installation failed', 'results': results}
# 2. Configure Postfix
postfix_config = PostfixService.configure(hostname=hostname)
if not postfix_config.get('success'):
logger.warning(f"Postfix configuration warning: {postfix_config.get('error')}")
# 3. Install Dovecot
results['dovecot'] = DovecotService.install()
if not results['dovecot'].get('success'):
return {'success': False, 'error': 'Dovecot installation failed', 'results': results}
# 4. Configure Dovecot
dovecot_config = DovecotService.configure()
if not dovecot_config.get('success'):
logger.warning(f"Dovecot configuration warning: {dovecot_config.get('error')}")
# 5. Install OpenDKIM
results['dkim'] = DKIMService.install()
if not results['dkim'].get('success'):
logger.warning(f"DKIM installation warning: {results['dkim'].get('error')}")
# 6. Install SpamAssassin
results['spamassassin'] = SpamAssassinService.install()
if not results['spamassassin'].get('success'):
logger.warning(f"SpamAssassin installation warning: {results['spamassassin'].get('error')}")
all_ok = all(r.get('success') for r in results.values())
return {
'success': all_ok,
'message': 'All email components installed' if all_ok else 'Some components had issues',
'results': results,
}
# ── Service Control ──
@classmethod
def control_service(cls, component: str, action: str) -> Dict:
"""Start/stop/restart an email component."""
if component not in cls.COMPONENTS and component != 'roundcube':
return {'success': False, 'error': f'Unknown component: {component}'}
if action not in ('start', 'stop', 'restart', 'reload'):
return {'success': False, 'error': f'Invalid action: {action}'}
try:
if component == 'roundcube':
actions = {
'start': RoundcubeService.start,
'stop': RoundcubeService.stop,
'restart': RoundcubeService.restart,
'reload': RoundcubeService.restart,
}
return actions[action]()
service_name = component
if action == 'start':
result = ServiceControl.start(service_name, timeout=30)
elif action == 'stop':
result = ServiceControl.stop(service_name, timeout=30)
elif action == 'restart':
result = ServiceControl.restart(service_name, timeout=30)
elif action == 'reload':
result = ServiceControl.reload(service_name, timeout=30)
if result.returncode == 0:
return {'success': True, 'message': f'{component} {action} successful'}
return {'success': False, 'error': result.stderr or f'{action} failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── Domains ──
@classmethod
def get_domains(cls) -> List[Dict]:
"""List all email domains."""
domains = EmailDomain.query.all()
return [d.to_dict() for d in domains]
@classmethod
def add_domain(cls, name: str, dns_provider_id: int = None, dns_zone_id: str = None) -> Dict:
"""Add an email domain."""
try:
existing = EmailDomain.query.filter_by(name=name).first()
if existing:
return {'success': False, 'error': f'Domain {name} already exists'}
domain = EmailDomain(
name=name,
dns_provider_id=dns_provider_id,
dns_zone_id=dns_zone_id,
)
db.session.add(domain)
db.session.commit()
# Add to Postfix virtual domains
PostfixService.add_domain(name)
# Generate DKIM key
dkim_result = DKIMService.generate_key(name)
if dkim_result.get('success'):
domain.dkim_selector = 'default'
domain.dkim_private_key_path = dkim_result.get('private_key_path')
domain.dkim_public_key = dkim_result.get('public_key')
domain.spf_record = f'v=spf1 mx a ~all'
domain.dmarc_record = f'v=DMARC1; p=quarantine; rua=mailto:dmarc@{name}; pct=100'
DKIMService.add_domain(name)
db.session.commit()
return {'success': True, 'domain': domain.to_dict(), 'message': f'Domain {name} added'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def get_domain(cls, domain_id: int) -> Dict:
"""Get domain details."""
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
return {'success': True, 'domain': domain.to_dict()}
@classmethod
def remove_domain(cls, domain_id: int) -> Dict:
"""Remove an email domain and all its accounts/aliases."""
try:
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
domain_name = domain.name
# Remove accounts from Dovecot/Postfix
for account in domain.accounts:
DovecotService.delete_mailbox(account.email, domain_name, account.username, remove_files=True)
PostfixService.remove_mailbox(account.email)
# Remove aliases from Postfix
for alias in domain.aliases:
PostfixService.remove_alias(alias.source)
# Remove from DKIM
DKIMService.remove_domain(domain_name)
# Remove from Postfix virtual domains
PostfixService.remove_domain(domain_name)
# Delete from database
db.session.delete(domain)
db.session.commit()
return {'success': True, 'message': f'Domain {domain_name} removed'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def verify_dns(cls, domain_id: int) -> Dict:
"""Verify DNS records for a domain."""
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
results = {}
name = domain.name
# Check MX record
try:
result = run_checked(['dig', '+short', 'MX', name], timeout=10)
results['mx'] = {
'found': bool(result['output'].strip()),
'value': result['output'].strip() or None,
}
except Exception:
results['mx'] = {'found': False, 'error': 'DNS lookup failed'}
# Check SPF record
try:
result = run_checked(['dig', '+short', 'TXT', name], timeout=10)
spf_found = 'v=spf1' in result['output']
results['spf'] = {
'found': spf_found,
'value': result['output'].strip() or None,
}
except Exception:
results['spf'] = {'found': False, 'error': 'DNS lookup failed'}
# Check DKIM record
selector = domain.dkim_selector or 'default'
try:
dkim_name = f'{selector}._domainkey.{name}'
result = run_checked(['dig', '+short', 'TXT', dkim_name], timeout=10)
dkim_found = 'v=DKIM1' in result['output']
results['dkim'] = {
'found': dkim_found,
'value': result['output'].strip() or None,
'name': dkim_name,
}
except Exception:
results['dkim'] = {'found': False, 'error': 'DNS lookup failed'}
# Check DMARC record
try:
dmarc_name = f'_dmarc.{name}'
result = run_checked(['dig', '+short', 'TXT', dmarc_name], timeout=10)
dmarc_found = 'v=DMARC1' in result['output']
results['dmarc'] = {
'found': dmarc_found,
'value': result['output'].strip() or None,
}
except Exception:
results['dmarc'] = {'found': False, 'error': 'DNS lookup failed'}
all_ok = all(r.get('found') for r in results.values())
return {
'success': True,
'domain': name,
'all_verified': all_ok,
'records': results,
}
# ── Accounts ──
@classmethod
def get_accounts(cls, domain_id: int) -> List[Dict]:
"""List email accounts for a domain."""
accounts = EmailAccount.query.filter_by(domain_id=domain_id).all()
return [a.to_dict() for a in accounts]
@classmethod
def add_account(cls, domain_id: int, username: str, password: str, quota_mb: int = 1024) -> Dict:
"""Create an email account."""
try:
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
email = f'{username}@{domain.name}'
existing = EmailAccount.query.filter_by(email=email).first()
if existing:
return {'success': False, 'error': f'Account {email} already exists'}
# Create mailbox in Dovecot
dovecot_result = DovecotService.create_mailbox(email, password, domain.name, username, quota_mb)
if not dovecot_result.get('success'):
return dovecot_result
# Add to Postfix virtual mailboxes
PostfixService.add_mailbox(email, domain.name, username)
# Get password hash for storage
password_hash = dovecot_result.get('password_hash', 'stored_in_dovecot')
account = EmailAccount(
email=email,
username=username,
password_hash=password_hash,
domain_id=domain_id,
quota_mb=quota_mb,
)
db.session.add(account)
db.session.commit()
return {'success': True, 'account': account.to_dict(), 'message': f'Account {email} created'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def update_account(cls, account_id: int, quota_mb: int = None, is_active: bool = None) -> Dict:
"""Update account settings."""
try:
account = EmailAccount.query.get(account_id)
if not account:
return {'success': False, 'error': 'Account not found'}
if quota_mb is not None:
account.quota_mb = quota_mb
DovecotService.set_quota(account.email, quota_mb)
if is_active is not None:
account.is_active = is_active
db.session.commit()
return {'success': True, 'account': account.to_dict(), 'message': 'Account updated'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def delete_account(cls, account_id: int) -> Dict:
"""Delete an email account."""
try:
account = EmailAccount.query.get(account_id)
if not account:
return {'success': False, 'error': 'Account not found'}
email = account.email
domain_name = account.domain.name if account.domain else ''
username = account.username
# Remove from Dovecot
DovecotService.delete_mailbox(email, domain_name, username, remove_files=True)
# Remove from Postfix
PostfixService.remove_mailbox(email)
db.session.delete(account)
db.session.commit()
return {'success': True, 'message': f'Account {email} deleted'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def change_password(cls, account_id: int, new_password: str) -> Dict:
"""Change an account password."""
try:
account = EmailAccount.query.get(account_id)
if not account:
return {'success': False, 'error': 'Account not found'}
result = DovecotService.change_password(account.email, new_password)
if not result.get('success'):
return result
account.password_hash = 'updated_in_dovecot'
db.session.commit()
return {'success': True, 'message': 'Password changed successfully'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
# ── Aliases ──
@classmethod
def get_aliases(cls, domain_id: int) -> List[Dict]:
"""List email aliases for a domain."""
aliases = EmailAlias.query.filter_by(domain_id=domain_id).all()
return [a.to_dict() for a in aliases]
@classmethod
def add_alias(cls, domain_id: int, source: str, destination: str) -> Dict:
"""Create an email alias."""
try:
domain = EmailDomain.query.get(domain_id)
if not domain:
return {'success': False, 'error': 'Domain not found'}
# Add @ domain if not already qualified
if '@' not in source:
source = f'{source}@{domain.name}'
alias = EmailAlias(
source=source,
destination=destination,
domain_id=domain_id,
)
db.session.add(alias)
db.session.commit()
# Add to Postfix virtual aliases
PostfixService.add_alias(source, destination)
return {'success': True, 'alias': alias.to_dict(), 'message': f'Alias {source} created'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def remove_alias(cls, alias_id: int) -> Dict:
"""Delete an email alias."""
try:
alias = EmailAlias.query.get(alias_id)
if not alias:
return {'success': False, 'error': 'Alias not found'}
PostfixService.remove_alias(alias.source)
db.session.delete(alias)
db.session.commit()
return {'success': True, 'message': 'Alias removed'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
# ── Forwarding Rules ──
@classmethod
def get_forwarding(cls, account_id: int) -> List[Dict]:
"""List forwarding rules for an account."""
rules = EmailForwardingRule.query.filter_by(account_id=account_id).all()
return [r.to_dict() for r in rules]
@classmethod
def add_forwarding(cls, account_id: int, destination: str, keep_copy: bool = True) -> Dict:
"""Create a forwarding rule."""
try:
account = EmailAccount.query.get(account_id)
if not account:
return {'success': False, 'error': 'Account not found'}
rule = EmailForwardingRule(
account_id=account_id,
destination=destination,
keep_copy=keep_copy,
)
db.session.add(rule)
db.session.commit()
# Update Postfix aliases for forwarding
cls._sync_forwarding_aliases(account)
return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule created'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def update_forwarding(cls, rule_id: int, destination: str = None,
keep_copy: bool = None, is_active: bool = None) -> Dict:
"""Update a forwarding rule."""
try:
rule = EmailForwardingRule.query.get(rule_id)
if not rule:
return {'success': False, 'error': 'Forwarding rule not found'}
if destination is not None:
rule.destination = destination
if keep_copy is not None:
rule.keep_copy = keep_copy
if is_active is not None:
rule.is_active = is_active
db.session.commit()
# Re-sync Postfix aliases
cls._sync_forwarding_aliases(rule.account)
return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule updated'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def remove_forwarding(cls, rule_id: int) -> Dict:
"""Delete a forwarding rule."""
try:
rule = EmailForwardingRule.query.get(rule_id)
if not rule:
return {'success': False, 'error': 'Forwarding rule not found'}
account = rule.account
db.session.delete(rule)
db.session.commit()
# Re-sync Postfix aliases
cls._sync_forwarding_aliases(account)
return {'success': True, 'message': 'Forwarding rule removed'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def _sync_forwarding_aliases(cls, account: EmailAccount):
"""Sync forwarding rules to Postfix virtual aliases for an account."""
active_rules = [r for r in account.forwarding_rules if r.is_active]
if not active_rules:
# Remove any forwarding alias
PostfixService.remove_alias(account.email)
return
# Build destination list
destinations = [r.destination for r in active_rules]
# If any rule wants to keep a copy, include the original mailbox
if any(r.keep_copy for r in active_rules):
destinations.append(account.email)
# Remove old alias and add new one
PostfixService.remove_alias(account.email)
PostfixService.add_alias(account.email, ', '.join(destinations))
@@ -1,198 +0,0 @@
"""Roundcube webmail management service (Docker-based)."""
import subprocess
from typing import Dict
from app.utils.system import run_checked, run_privileged, is_command_available
class RoundcubeService:
"""Service for managing Roundcube webmail via Docker."""
CONTAINER_NAME = 'serverkit-roundcube'
VOLUME_NAME = 'roundcube_data'
IMAGE = 'roundcube/roundcubemail:latest'
HOST_PORT = 9000
@classmethod
def get_status(cls) -> Dict:
"""Get Roundcube container status."""
if not is_command_available('docker'):
return {'installed': False, 'running': False, 'error': 'Docker not available'}
try:
result = run_checked(
['docker', 'inspect', '--format', '{{.State.Status}}', cls.CONTAINER_NAME],
timeout=None)
if not result['success']:
return {'installed': False, 'running': False}
status = result['output'].strip()
return {
'installed': True,
'running': status == 'running',
'status': status,
'port': cls.HOST_PORT,
}
except Exception as e:
return {'installed': False, 'running': False, 'error': str(e)}
@classmethod
def install(cls, imap_host: str = 'host.docker.internal',
smtp_host: str = 'host.docker.internal',
domain: str = None) -> Dict:
"""Install Roundcube via Docker container."""
if not is_command_available('docker'):
return {'success': False, 'error': 'Docker is not installed'}
try:
# Remove existing container if any
run_checked(['docker', 'rm', '-f', cls.CONTAINER_NAME], timeout=None)
# Create volume
run_checked(['docker', 'volume', 'create', cls.VOLUME_NAME], timeout=None)
# Run container
result = run_checked([
'docker', 'run', '-d',
'--name', cls.CONTAINER_NAME,
'--restart', 'unless-stopped',
'--add-host', 'host.docker.internal:host-gateway',
'-p', f'{cls.HOST_PORT}:80',
'-e', f'ROUNDCUBEMAIL_DEFAULT_HOST=ssl://{imap_host}',
'-e', f'ROUNDCUBEMAIL_SMTP_SERVER=tls://{smtp_host}',
'-e', 'ROUNDCUBEMAIL_DEFAULT_PORT=993',
'-e', 'ROUNDCUBEMAIL_SMTP_PORT=587',
'-e', 'ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=25M',
'-e', 'ROUNDCUBEMAIL_SKIN=elastic',
'-v', f'{cls.VOLUME_NAME}:/var/roundcube/db',
cls.IMAGE,
], timeout=None)
if not result['success']:
return {'success': False,
'error': result['error'] or 'Failed to start container'}
# Build a public URL: prefer a supplied domain, then auto-generate
# webmail.<panel_domain>, and only fall back to localhost:port.
from app.services.site_domain_service import SiteDomainService
public_url = None
warning = None
if domain:
public_url = f'http://{domain}'
proxy_res = cls.configure_nginx_proxy(domain)
if not proxy_res.get('success'):
warning = proxy_res.get('error')
else:
panel_origin = SiteDomainService.panel_origin()
panel_host = None
if panel_origin:
from urllib.parse import urlparse
panel_host = urlparse(panel_origin).hostname
if panel_host:
webmail_domain = f'webmail.{panel_host}'
public_url = f'http://{webmail_domain}'
proxy_res = cls.configure_nginx_proxy(webmail_domain)
if not proxy_res.get('success'):
warning = proxy_res.get('error')
if not public_url:
public_url = f'http://localhost:{cls.HOST_PORT}'
result = {
'success': True,
'message': 'Roundcube installed successfully',
'port': cls.HOST_PORT,
'url': public_url,
}
if warning:
result['warning'] = warning
return result
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def uninstall(cls) -> Dict:
"""Stop and remove Roundcube container."""
try:
run_checked(['docker', 'rm', '-f', cls.CONTAINER_NAME], timeout=None)
return {'success': True, 'message': 'Roundcube uninstalled'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def start(cls) -> Dict:
"""Start Roundcube container."""
try:
result = run_checked(['docker', 'start', cls.CONTAINER_NAME], timeout=None)
if result['success']:
return {'success': True, 'message': 'Roundcube started'}
return {'success': False, 'error': result['error'] or 'Start failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def stop(cls) -> Dict:
"""Stop Roundcube container."""
try:
result = run_checked(['docker', 'stop', cls.CONTAINER_NAME], timeout=None)
if result['success']:
return {'success': True, 'message': 'Roundcube stopped'}
return {'success': False, 'error': result['error'] or 'Stop failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def restart(cls) -> Dict:
"""Restart Roundcube container."""
try:
result = run_checked(['docker', 'restart', cls.CONTAINER_NAME], timeout=None)
if result['success']:
return {'success': True, 'message': 'Roundcube restarted'}
return {'success': False, 'error': result['error'] or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure_nginx_proxy(cls, domain: str) -> Dict:
"""Create Nginx reverse proxy config for Roundcube."""
try:
from app.services.nginx_service import NginxService
config = f"""# Roundcube Webmail - Managed by ServerKit
server {{
listen 80;
server_name {domain};
location / {{
proxy_pass http://127.0.0.1:{cls.HOST_PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}}
}}
"""
site_name = f'roundcube-{domain.replace(".", "-")}'
config_path = f'/etc/nginx/sites-available/{site_name}'
enabled_path = f'/etc/nginx/sites-enabled/{site_name}'
run_privileged(['tee', config_path], input=config)
run_privileged(['ln', '-sf', config_path, enabled_path])
# Test and reload
test = run_privileged(['nginx', '-t'])
if test.returncode != 0:
# Rollback
run_privileged(['rm', '-f', enabled_path])
return {'success': False, 'error': f'Nginx config test failed: {test.stderr}'}
run_privileged(['systemctl', 'reload', 'nginx'])
return {
'success': True,
'message': f'Nginx proxy configured for {domain}',
'url': f'http://{domain}',
}
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -1,254 +0,0 @@
"""SpamAssassin management service."""
import os
import re
import subprocess
from typing import Dict
from app.utils.system import (PackageManager, ServiceControl, run_checked,
run_privileged)
class SpamAssassinService:
"""Service for managing SpamAssassin spam filtering."""
SPAMASSASSIN_CONF = '/etc/spamassassin/local.cf'
SPAMASSASSIN_DEFAULT = '/etc/default/spamassassin'
SPAMASS_MILTER_DEFAULT = '/etc/default/spamass-milter'
LOCAL_CF_TEMPLATE = """# SpamAssassin local configuration - Managed by ServerKit
required_score {required_score}
report_safe {report_safe}
rewrite_header Subject {rewrite_subject}
use_bayes {use_bayes}
bayes_auto_learn {bayes_auto_learn}
bayes_auto_learn_threshold_nonspam 0.1
bayes_auto_learn_threshold_spam 12.0
skip_rbl_checks {skip_rbl_checks}
use_razor2 0
use_pyzor 0
# Network checks
dns_available yes
# Trusted networks
trusted_networks 127.0.0.0/8
internal_networks 127.0.0.0/8
"""
DEFAULT_CONFIG = {
'required_score': 5.0,
'report_safe': 0,
'rewrite_subject': '[SPAM]',
'use_bayes': 1,
'bayes_auto_learn': 1,
'skip_rbl_checks': 0,
}
@classmethod
def get_status(cls) -> Dict:
"""Get SpamAssassin installation and running status."""
installed = False
running = False
enabled = False
version = None
milter_installed = False
milter_running = False
try:
installed = PackageManager.is_installed('spamassassin')
if installed:
running = ServiceControl.is_active('spamassassin')
enabled = ServiceControl.is_enabled('spamassassin')
result = run_checked(['spamassassin', '--version'], timeout=None)
match = re.search(r'version\s+(\S+)', result['output'])
if match:
version = match.group(1)
# Check milter
milter_installed = PackageManager.is_installed('spamass-milter')
if milter_installed:
milter_running = ServiceControl.is_active('spamass-milter')
except (subprocess.SubprocessError, FileNotFoundError):
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
'milter_installed': milter_installed,
'milter_running': milter_running,
}
@classmethod
def install(cls) -> Dict:
"""Install SpamAssassin and spamass-milter."""
try:
manager = PackageManager.detect()
if manager == 'apt':
packages = ['spamassassin', 'spamass-milter', 'spamc']
else:
packages = ['spamassassin', 'spamass-milter-postfix']
result = PackageManager.install(packages, timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install SpamAssassin'}
# Enable spamd on Debian/Ubuntu
if manager == 'apt' and os.path.exists(cls.SPAMASSASSIN_DEFAULT):
result = run_privileged(['cat', cls.SPAMASSASSIN_DEFAULT])
content = result.stdout or ''
content = re.sub(r'ENABLED=0', 'ENABLED=1', content)
content = re.sub(r'CRON=0', 'CRON=1', content)
run_privileged(['tee', cls.SPAMASSASSIN_DEFAULT], input=content)
# Configure milter to listen on port 8893
milter_config = 'OPTIONS="-u spamass-milter -i 127.0.0.1 -p inet:8893@localhost -- --socket=/var/run/spamassassin/spamd.sock"\n'
if os.path.exists(cls.SPAMASS_MILTER_DEFAULT):
run_privileged(['tee', cls.SPAMASS_MILTER_DEFAULT], input=milter_config)
# Write default config
cls.configure(cls.DEFAULT_CONFIG)
# Update rules
run_privileged(['sa-update'], timeout=120)
ServiceControl.enable('spamassassin')
ServiceControl.start('spamassassin', timeout=30)
if PackageManager.is_installed('spamass-milter'):
ServiceControl.enable('spamass-milter')
ServiceControl.start('spamass-milter', timeout=30)
return {'success': True, 'message': 'SpamAssassin installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure(cls, settings: Dict = None) -> Dict:
"""Write SpamAssassin configuration."""
try:
config = dict(cls.DEFAULT_CONFIG)
if settings:
config.update(settings)
content = cls.LOCAL_CF_TEMPLATE.format(**config)
run_privileged(['tee', cls.SPAMASSASSIN_CONF], input=content)
return {'success': True, 'message': 'SpamAssassin configured'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_config(cls) -> Dict:
"""Get current SpamAssassin configuration."""
try:
if not os.path.exists(cls.SPAMASSASSIN_CONF):
return {'success': True, 'config': dict(cls.DEFAULT_CONFIG)}
result = run_privileged(['cat', cls.SPAMASSASSIN_CONF])
content = result.stdout or ''
config = dict(cls.DEFAULT_CONFIG)
# Parse values
score_match = re.search(r'required_score\s+(\S+)', content)
if score_match:
config['required_score'] = float(score_match.group(1))
report_match = re.search(r'report_safe\s+(\d+)', content)
if report_match:
config['report_safe'] = int(report_match.group(1))
subject_match = re.search(r'rewrite_header Subject\s+(.+)', content)
if subject_match:
config['rewrite_subject'] = subject_match.group(1).strip()
bayes_match = re.search(r'use_bayes\s+(\d+)', content)
if bayes_match:
config['use_bayes'] = int(bayes_match.group(1))
auto_learn_match = re.search(r'bayes_auto_learn\s+(\d+)', content)
if auto_learn_match:
config['bayes_auto_learn'] = int(auto_learn_match.group(1))
rbl_match = re.search(r'skip_rbl_checks\s+(\d+)', content)
if rbl_match:
config['skip_rbl_checks'] = int(rbl_match.group(1))
return {'success': True, 'config': config}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def update_rules(cls) -> Dict:
"""Update SpamAssassin rules."""
try:
result = run_privileged(['sa-update'], timeout=120)
# sa-update returns 0 for updates, 1 for no updates, 2+ for errors
if result.returncode <= 1:
ServiceControl.restart('spamassassin', timeout=30)
return {
'success': True,
'message': 'Rules updated' if result.returncode == 0 else 'Rules already up to date',
'updated': result.returncode == 0,
}
return {'success': False, 'error': result.stderr or 'Update failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def train_spam(cls, message_path: str) -> Dict:
"""Train SpamAssassin with a spam message."""
try:
result = run_privileged(['sa-learn', '--spam', message_path])
if result.returncode == 0:
return {'success': True, 'message': 'Message learned as spam'}
return {'success': False, 'error': result.stderr or 'Training failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def train_ham(cls, message_path: str) -> Dict:
"""Train SpamAssassin with a ham (non-spam) message."""
try:
result = run_privileged(['sa-learn', '--ham', message_path])
if result.returncode == 0:
return {'success': True, 'message': 'Message learned as ham'}
return {'success': False, 'error': result.stderr or 'Training failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_stats(cls) -> Dict:
"""Get SpamAssassin Bayes statistics."""
try:
result = run_privileged(['sa-learn', '--dump', 'magic'])
output = result.stdout or ''
stats = {
'nspam': 0,
'nham': 0,
'ntokens': 0,
}
for line in output.splitlines():
parts = line.split()
if len(parts) >= 4:
if parts[2] == 'nspam':
stats['nspam'] = int(parts[1])
elif parts[2] == 'nham':
stats['nham'] = int(parts[1])
elif parts[2] == 'ntokens':
stats['ntokens'] = int(parts[1])
return {'success': True, 'stats': stats}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload SpamAssassin."""
try:
result = ServiceControl.restart('spamassassin', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'SpamAssassin restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -1,18 +0,0 @@
// Email Server, contributed through the extension system. Both halves live in
// this extension: the mail-server backend (Postfix/Dovecot/DKIM/SpamAssassin/
// Roundcube orchestration + the /api/v1/email blueprint) under backend/, and
// the full page here (pages/Email.jsx + styles/email.scss + its own API
// client in services/email.js). Imports go through 'serverkit-sdk' so the
// same source works baked in-tree (the panel's build-time glob) and as a
// runtime-ESM bundle once the extension leaves the tree (plan 52 Phase 2).
//
// The SCSS side-effect import compiles the page styles through this
// extension's own module graph — the host's main.scss no longer carries an
// _email partial.
import './styles/email.scss';
import Email from './pages/Email';
export function EmailPage() {
return <Email />;
}
File diff suppressed because it is too large. Load diff
@@ -1,69 +0,0 @@
// The extension's own /api/v1/email client, built on the panel's ApiClient
// via the SDK singleton (JWT refresh, workspace headers, error normalization
// included) — moved out of the host's services/api/system.js with the page.
//
// Two email surfaces deliberately STAY core and are not duplicated here:
// the DNS-provider connections (/email/dns-providers — a core blueprint that
// keeps its historical prefix) and the Settings → Connections relay tile's
// calls. The relay methods below are this extension's own copies for the
// Relay tab; the page and the Settings tile hit the same endpoints.
import { api } from 'serverkit-sdk';
const emailApi = {
// Server
getEmailStatus: () => api.request('/email/status'),
installEmailServer: (data = {}) => api.request('/email/install', { method: 'POST', body: JSON.stringify(data) }),
controlEmailService: (component, action) => api.request(`/email/service/${component}/${action}`, { method: 'POST' }),
// Domains
getEmailDomains: () => api.request('/email/domains'),
addEmailDomain: (data) => api.request('/email/domains', { method: 'POST', body: JSON.stringify(data) }),
getEmailDomain: (domainId) => api.request(`/email/domains/${domainId}`),
deleteEmailDomain: (domainId) => api.request(`/email/domains/${domainId}`, { method: 'DELETE' }),
verifyEmailDNS: (domainId) => api.request(`/email/domains/${domainId}/verify-dns`, { method: 'POST' }),
deployEmailDNS: (domainId) => api.request(`/email/domains/${domainId}/deploy-dns`, { method: 'POST' }),
// Accounts
getEmailAccounts: (domainId) => api.request(`/email/domains/${domainId}/accounts`),
createEmailAccount: (domainId, data) => api.request(`/email/domains/${domainId}/accounts`, { method: 'POST', body: JSON.stringify(data) }),
getEmailAccount: (accountId) => api.request(`/email/accounts/${accountId}`),
updateEmailAccount: (accountId, data) => api.request(`/email/accounts/${accountId}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteEmailAccount: (accountId) => api.request(`/email/accounts/${accountId}`, { method: 'DELETE' }),
changeEmailPassword: (accountId, password) => api.request(`/email/accounts/${accountId}/password`, { method: 'POST', body: JSON.stringify({ password }) }),
// Aliases
getEmailAliases: (domainId) => api.request(`/email/domains/${domainId}/aliases`),
createEmailAlias: (domainId, data) => api.request(`/email/domains/${domainId}/aliases`, { method: 'POST', body: JSON.stringify(data) }),
deleteEmailAlias: (aliasId) => api.request(`/email/aliases/${aliasId}`, { method: 'DELETE' }),
// Forwarding
getEmailForwarding: (accountId) => api.request(`/email/accounts/${accountId}/forwarding`),
createEmailForwarding: (accountId, data) => api.request(`/email/accounts/${accountId}/forwarding`, { method: 'POST', body: JSON.stringify(data) }),
updateEmailForwarding: (ruleId, data) => api.request(`/email/forwarding/${ruleId}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteEmailForwarding: (ruleId) => api.request(`/email/forwarding/${ruleId}`, { method: 'DELETE' }),
// Outbound SMTP relay (smarthost)
getEmailRelay: () => api.request('/email/relay'),
updateEmailRelay: (data) => api.request('/email/relay', { method: 'PUT', body: JSON.stringify(data) }),
testEmailRelay: (data) => api.request('/email/relay/test', { method: 'POST', body: JSON.stringify(data) }),
disableEmailRelay: () => api.request('/email/relay', { method: 'DELETE' }),
// SpamAssassin
getSpamConfig: () => api.request('/email/spam/config'),
updateSpamConfig: (data) => api.request('/email/spam/config', { method: 'PUT', body: JSON.stringify(data) }),
updateSpamRules: () => api.request('/email/spam/update-rules', { method: 'POST' }),
// Roundcube webmail
getWebmailStatus: () => api.request('/email/webmail/status'),
installWebmail: (data = {}) => api.request('/email/webmail/install', { method: 'POST', body: JSON.stringify(data) }),
controlWebmail: (action) => api.request(`/email/webmail/service/${action}`, { method: 'POST' }),
configureWebmailProxy: (domain) => api.request('/email/webmail/configure-proxy', { method: 'POST', body: JSON.stringify({ domain }) }),
// Mail queue & logs
getMailQueue: () => api.request('/email/queue'),
flushMailQueue: () => api.request('/email/queue/flush', { method: 'POST' }),
deleteMailQueueItem: (queueId) => api.request(`/email/queue/${queueId}`, { method: 'DELETE' }),
getMailLogs: (lines = 100) => api.request(`/email/logs?lines=${lines}`),
};
export default emailApi;
@@ -1,277 +0,0 @@
// Compiled through the extension's module graph (imported by index.jsx).
// The host's design-token partial is imported via a sync-safe relative
// path — the baked copy lives at frontend/src/plugins/serverkit-email/,
// so this resolves to frontend/src/styles/_variables.scss.
@import '../../../styles/variables';
// ============================================
// EMAIL SERVER PAGE (redesign — infra console look, REDESIGN_MAP #18):
// status Pills on the Services list, MetricCard KPI strip (Overview),
// dense .sk-dtable lists (services / accounts / queue), mono-uppercase
// eyebrow section headers, token surfaces. Domains stay as a designed
// card list. Class namespace = `sk-email` (the rebuilt Email.jsx emits it).
//
// Mail-volume chart, reputation/RBL ring, structured activity feed,
// per-mailbox storage gauges and per-message retry are intentionally
// absent — no backend yet (REDESIGN_MAP §5).
// ============================================
.sk-email {
padding: $space-6;
// ── Sections stack with breathing room ──────────────────
&__section {
display: flex;
flex-direction: column;
gap: $space-5;
}
// ── Mono-uppercase eyebrow section headers ──────────────
&__section-title {
display: flex;
align-items: center;
gap: $space-2;
margin: 0;
font-family: var(--mono);
font-size: 10.5px;
font-weight: $font-weight-semibold;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
svg { color: var(--text-dim); }
}
// ── Empty / loading placeholders (shared look) ──────────
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $space-2;
padding: $space-6 $space-5;
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
text-align: center;
color: var(--text-dim);
font-size: $font-size-sm;
svg { color: var(--text-faint); }
p { margin: 0; }
}
// ── Not-installed hero (EmptyState + preflight note) ─────
&__hero {
max-width: 560px;
margin: $space-6 auto 0;
.empty-state {
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: $space-8 $space-6;
}
}
&__hero-note {
margin: $space-4 0 0;
padding: $space-3 $space-4;
background: var(--accent-bg);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.55;
color: var(--text-dim);
text-align: center;
}
// Inline install failure (component + stderr from the API) — same card
// chrome as the hero note, in the danger tone. pre-line keeps one failing
// component per line; anywhere stops a long unbroken stderr token from
// widening the hero.
&__hero-error {
margin: $space-3 0 0;
padding: $space-3 $space-4;
background: var(--red-bg);
border: 1px solid var(--red);
border-radius: var(--radius);
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.55;
color: var(--red);
white-space: pre-line;
overflow-wrap: anywhere;
text-align: left;
}
// ── Action clusters (row actions, form buttons, toolbars) ─
&__actions {
display: flex;
align-items: center;
gap: $space-2;
flex-wrap: wrap;
}
// Right-align the DataTable actions column
&__actions-col { text-align: right; }
&__actions-cell {
text-align: right;
.sk-email__actions { justify-content: flex-end; }
}
// ── Filters toolbar (domain select + search) ────────────
// The row layout is the shared ListToolbar (.sk-listhead); this class
// rides along on it to keep the eyebrow labels, select chrome and
// search-box sizing below.
&__filters {
> label {
display: flex;
align-items: center;
gap: $space-2;
font-family: var(--mono);
font-size: 10px;
font-weight: $font-weight-semibold;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
select {
padding: 6px 10px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: $radius-md;
color: var(--text);
font-family: $font-mono;
font-size: $font-size-sm;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--accent);
}
}
// The global .search-box keeps its own chrome; just let it grow.
.search-box {
flex: 1;
min-width: 220px;
max-width: 360px;
}
}
// ── Inline create/add forms (domain + account) ──────────
&__add,
&__form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: $space-3;
padding: $space-4 $space-5;
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
label {
display: flex;
flex-direction: column;
gap: $space-1;
font-family: var(--mono);
font-size: 10px;
font-weight: $font-weight-semibold;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
}
&__form { flex-direction: column; align-items: stretch; }
// Checkbox rows keep sentence case (not eyebrow uppercase)
&__check {
flex-direction: row;
align-items: center;
gap: $space-2;
font-family: inherit;
font-size: $font-size-sm;
font-weight: $font-weight-normal;
letter-spacing: normal;
text-transform: none;
color: var(--text-dim);
cursor: pointer;
input[type='checkbox'] {
width: 15px;
height: 15px;
accent-color: var(--accent);
}
}
// ── Domains card list (kept as designed cards) ──────────
&__domains {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: $space-4;
}
&__domain {
display: flex;
flex-direction: column;
gap: $space-3;
padding: 15px 16px;
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
transition: border-color $transition-fast;
&:hover { border-color: var(--border-strong); }
}
&__domain-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
}
&__domain-name {
font-family: var(--mono);
font-size: 13.5px;
font-weight: $font-weight-semibold;
color: var(--text);
overflow-wrap: anywhere;
}
&__dns {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__domain-meta {
display: flex;
flex-wrap: wrap;
gap: $space-3;
padding-top: $space-3;
border-top: 1px solid var(--border-soft);
font-family: var(--mono);
font-size: 11.5px;
color: var(--text-faint);
}
// ── Mono cell emphasis (queue ids, addresses) ───────────
&__mono {
font-family: var(--mono);
font-size: 12.5px;
color: var(--text);
overflow-wrap: anywhere;
}
}
@@ -1,41 +0,0 @@
{
"name": "serverkit-email",
"display_name": "Email Server",
"version": "1.0.0",
"description": "Full mail-server stack with Postfix/Dovecot, domains, mailboxes, aliases, DKIM/SPF/DMARC, SpamAssassin filtering, and Roundcube webmail. Most panels never run a mail server, so it ships as an opt-in extension.",
"author": "ServerKit",
"license": "MIT",
"category": "integration",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": ["shell", "filesystem", "network"],
"min_panel_version": "1.7.0",
"entry_point": "email:email_bp",
"url_prefix": "/api/v1/email",
"icon": "<path d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\"/><polyline points=\"22,6 12,13 2,6\"/>",
"contributions": {
"nav": [
{
"id": "email",
"label": "Email Server",
"route": "/email",
"category": "operations",
"icon": "<path d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\"/><polyline points=\"22,6 12,13 2,6\"/>"
}
],
"routes": [
{ "path": "email", "component": "EmailPage" },
{ "path": "email/:tab", "component": "EmailPage" }
],
"page_titles": {
"/email": "Email Server"
},
"command_palette": [
{
"label": "Email",
"path": "/email",
"category": "Pages",
"keywords": "smtp postfix dovecot mail imap dkim webmail"
}
]
}
}
Whitespace-only changes.
@@ -1,256 +0,0 @@
"""Gitea self-host routes — the serverkit-git extension's blueprint.
Moved verbatim out of core ``app/api/git.py`` (plan 52 Phase 6) and mounted
back at the same ``/api/v1/git`` prefix when the extension is installed, so
route shapes never change. The webhook receiver and deployment routes STAY
core on ``git_bp`` — they are the deploy pipeline.
"""
from flask import Blueprint, request, jsonify
from app.middleware.rbac import admin_required, viewer_required
from .gitea_service import GiteaServerService
from .gitea_api_service import GiteaAPIService
gitea_bp = Blueprint('git_gitea', __name__)
@gitea_bp.route('/status', methods=['GET'])
@viewer_required
def get_status():
"""Get Gitea installation status."""
result = GiteaServerService.get_gitea_status()
return jsonify(result), 200
@gitea_bp.route('/requirements', methods=['GET'])
@viewer_required
def get_requirements():
"""Get resource requirements for Gitea installation."""
result = GiteaServerService.get_gitea_resource_requirements()
return jsonify(result), 200
@gitea_bp.route('/install', methods=['POST'])
@admin_required
def install():
"""Install Gitea with PostgreSQL."""
data = request.get_json() or {}
result = GiteaServerService.install_gitea(
admin_user=data.get('adminUser', 'admin'),
admin_email=data.get('adminEmail'),
admin_password=data.get('adminPassword')
)
if result.get('success'):
return jsonify(result), 201
return jsonify(result), 400
@gitea_bp.route('/uninstall', methods=['POST'])
@admin_required
def uninstall():
"""Uninstall Gitea and optionally remove data."""
data = request.get_json() or {}
result = GiteaServerService.uninstall_gitea(
remove_data=data.get('removeData', False)
)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@gitea_bp.route('/start', methods=['POST'])
@admin_required
def start():
"""Start Gitea server."""
result = GiteaServerService.start_gitea()
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@gitea_bp.route('/stop', methods=['POST'])
@admin_required
def stop():
"""Stop Gitea server."""
result = GiteaServerService.stop_gitea()
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@gitea_bp.route('/restart', methods=['POST'])
@admin_required
def restart():
"""Restart Gitea server."""
result = GiteaServerService.restart_gitea()
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
# ==================== WEBHOOK ENDPOINTS ====================
@gitea_bp.route('/repos', methods=['GET'])
@viewer_required
def list_repositories():
"""List all repositories in Gitea."""
token = request.args.get('token')
limit = request.args.get('limit', 50, type=int)
result = GiteaAPIService.list_repositories(token=token, limit=limit)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@gitea_bp.route('/repos/<owner>/<repo>', methods=['GET'])
@viewer_required
def get_repository(owner, repo):
"""Get repository details."""
token = request.args.get('token')
result = GiteaAPIService.get_repository(owner, repo, token=token)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@gitea_bp.route('/repos/<owner>/<repo>/stats', methods=['GET'])
@viewer_required
def get_repo_stats(owner, repo):
"""Get repository statistics."""
token = request.args.get('token')
result = GiteaAPIService.get_repo_stats(owner, repo, token=token)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@gitea_bp.route('/repos/<owner>/<repo>/branches', methods=['GET'])
@viewer_required
def list_branches(owner, repo):
"""List repository branches."""
token = request.args.get('token')
result = GiteaAPIService.list_branches(owner, repo, token=token)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@gitea_bp.route('/repos/<owner>/<repo>/branches/<branch>', methods=['GET'])
@viewer_required
def get_branch(owner, repo, branch):
"""Get branch details."""
token = request.args.get('token')
result = GiteaAPIService.get_branch(owner, repo, branch, token=token)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@gitea_bp.route('/repos/<owner>/<repo>/commits', methods=['GET'])
@viewer_required
def list_commits(owner, repo):
"""List repository commits."""
token = request.args.get('token')
branch = request.args.get('branch')
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 30, type=int)
result = GiteaAPIService.list_commits(
owner, repo,
branch=branch,
page=page,
limit=limit,
token=token
)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@gitea_bp.route('/repos/<owner>/<repo>/commits/<sha>', methods=['GET'])
@viewer_required
def get_commit(owner, repo, sha):
"""Get commit details."""
token = request.args.get('token')
result = GiteaAPIService.get_commit(owner, repo, sha, token=token)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@gitea_bp.route('/repos/<owner>/<repo>/contents', methods=['GET'])
@viewer_required
def list_files(owner, repo):
"""List files in repository directory."""
token = request.args.get('token')
ref = request.args.get('ref', 'main')
path = request.args.get('path', '')
result = GiteaAPIService.list_files(owner, repo, ref=ref, path=path, token=token)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@gitea_bp.route('/repos/<owner>/<repo>/contents/<path:filepath>', methods=['GET'])
@viewer_required
def get_file_content(owner, repo, filepath):
"""Get file content."""
token = request.args.get('token')
ref = request.args.get('ref', 'main')
result = GiteaAPIService.get_file_content(owner, repo, filepath, ref=ref, token=token)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@gitea_bp.route('/repos/<owner>/<repo>/readme', methods=['GET'])
@viewer_required
def get_readme(owner, repo):
"""Get repository README."""
token = request.args.get('token')
ref = request.args.get('ref')
result = GiteaAPIService.get_readme(owner, repo, ref=ref, token=token)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@gitea_bp.route('/version', methods=['GET'])
@viewer_required
def get_gitea_version():
"""Get Gitea server version."""
result = GiteaAPIService.get_server_version()
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
# ==================== DEPLOYMENT ENDPOINTS ====================
@@ -1,484 +0,0 @@
"""Gitea API Service - Communicates with the local Gitea instance."""
import requests
from typing import Dict, List, Optional
from datetime import datetime
class GiteaAPIService:
"""Service for interacting with the Gitea API."""
@classmethod
def _get_base_url(cls) -> Optional[str]:
"""Get the Gitea base URL from config."""
from .gitea_service import GiteaServerService
status = GiteaServerService.get_gitea_status()
if not status.get('installed') or not status.get('running'):
return None
port = status.get('http_port')
if not port:
return None
return f"http://127.0.0.1:{port}/api/v1"
@classmethod
def _get_headers(cls, token: str = None) -> Dict:
"""Get request headers."""
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
if token:
headers['Authorization'] = f'token {token}'
return headers
@classmethod
def get_server_version(cls) -> Dict:
"""Get Gitea server version."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
response = requests.get(f"{base_url}/version", timeout=5)
if response.status_code == 200:
return {'success': True, 'version': response.json()}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def list_users(cls, token: str = None) -> Dict:
"""List all users (requires admin token)."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
response = requests.get(
f"{base_url}/admin/users",
headers=cls._get_headers(token),
timeout=10
)
if response.status_code == 200:
return {'success': True, 'users': response.json()}
elif response.status_code == 401:
return {'success': False, 'error': 'Authentication required'}
elif response.status_code == 403:
return {'success': False, 'error': 'Admin access required'}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def list_repositories(cls, token: str = None, limit: int = 50) -> Dict:
"""List all repositories."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
# Try to get all repos (requires token for private repos)
params = {'limit': limit}
response = requests.get(
f"{base_url}/repos/search",
headers=cls._get_headers(token),
params=params,
timeout=10
)
if response.status_code == 200:
data = response.json()
repos = data.get('data', [])
return {
'success': True,
'repositories': [cls._format_repo(r) for r in repos],
'count': len(repos)
}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_repository(cls, owner: str, repo: str, token: str = None) -> Dict:
"""Get repository details."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
response = requests.get(
f"{base_url}/repos/{owner}/{repo}",
headers=cls._get_headers(token),
timeout=10
)
if response.status_code == 200:
return {
'success': True,
'repository': cls._format_repo(response.json())
}
elif response.status_code == 404:
return {'success': False, 'error': 'Repository not found'}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def list_branches(cls, owner: str, repo: str, token: str = None) -> Dict:
"""List repository branches."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
response = requests.get(
f"{base_url}/repos/{owner}/{repo}/branches",
headers=cls._get_headers(token),
timeout=10
)
if response.status_code == 200:
branches = response.json()
return {
'success': True,
'branches': [cls._format_branch(b) for b in branches],
'count': len(branches)
}
elif response.status_code == 404:
return {'success': False, 'error': 'Repository not found'}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_branch(cls, owner: str, repo: str, branch: str, token: str = None) -> Dict:
"""Get branch details."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
response = requests.get(
f"{base_url}/repos/{owner}/{repo}/branches/{branch}",
headers=cls._get_headers(token),
timeout=10
)
if response.status_code == 200:
return {
'success': True,
'branch': cls._format_branch(response.json())
}
elif response.status_code == 404:
return {'success': False, 'error': 'Branch not found'}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def list_commits(cls, owner: str, repo: str, branch: str = None,
page: int = 1, limit: int = 30, token: str = None) -> Dict:
"""List repository commits."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
params = {'page': page, 'limit': limit}
if branch:
params['sha'] = branch
response = requests.get(
f"{base_url}/repos/{owner}/{repo}/commits",
headers=cls._get_headers(token),
params=params,
timeout=10
)
if response.status_code == 200:
commits = response.json()
return {
'success': True,
'commits': [cls._format_commit(c) for c in commits],
'count': len(commits),
'page': page
}
elif response.status_code == 404:
return {'success': False, 'error': 'Repository not found'}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_commit(cls, owner: str, repo: str, sha: str, token: str = None) -> Dict:
"""Get commit details."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
response = requests.get(
f"{base_url}/repos/{owner}/{repo}/git/commits/{sha}",
headers=cls._get_headers(token),
timeout=10
)
if response.status_code == 200:
return {
'success': True,
'commit': cls._format_commit(response.json())
}
elif response.status_code == 404:
return {'success': False, 'error': 'Commit not found'}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def list_files(cls, owner: str, repo: str, ref: str = 'main',
path: str = '', token: str = None) -> Dict:
"""List files in a repository directory."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
url = f"{base_url}/repos/{owner}/{repo}/contents"
if path:
url += f"/{path}"
params = {'ref': ref}
response = requests.get(
url,
headers=cls._get_headers(token),
params=params,
timeout=10
)
if response.status_code == 200:
contents = response.json()
# Handle both file and directory responses
if isinstance(contents, list):
files = [cls._format_file(f) for f in contents]
else:
files = [cls._format_file(contents)]
return {
'success': True,
'files': files,
'path': path,
'ref': ref
}
elif response.status_code == 404:
return {'success': False, 'error': 'Path not found'}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_file_content(cls, owner: str, repo: str, filepath: str,
ref: str = 'main', token: str = None) -> Dict:
"""Get file content."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
params = {'ref': ref}
response = requests.get(
f"{base_url}/repos/{owner}/{repo}/contents/{filepath}",
headers=cls._get_headers(token),
params=params,
timeout=10
)
if response.status_code == 200:
data = response.json()
return {
'success': True,
'file': cls._format_file(data),
'content': data.get('content'),
'encoding': data.get('encoding', 'base64')
}
elif response.status_code == 404:
return {'success': False, 'error': 'File not found'}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_readme(cls, owner: str, repo: str, ref: str = None, token: str = None) -> Dict:
"""Get repository README."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
url = f"{base_url}/repos/{owner}/{repo}/readme"
params = {}
if ref:
params['ref'] = ref
response = requests.get(
url,
headers=cls._get_headers(token),
params=params,
timeout=10
)
if response.status_code == 200:
data = response.json()
return {
'success': True,
'readme': {
'name': data.get('name'),
'path': data.get('path'),
'content': data.get('content'),
'encoding': data.get('encoding', 'base64')
}
}
elif response.status_code == 404:
return {'success': False, 'error': 'README not found'}
return {'success': False, 'error': f'Status {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_repo_stats(cls, owner: str, repo: str, token: str = None) -> Dict:
"""Get repository statistics."""
base_url = cls._get_base_url()
if not base_url:
return {'success': False, 'error': 'Gitea is not running'}
try:
# Get repo info
repo_response = requests.get(
f"{base_url}/repos/{owner}/{repo}",
headers=cls._get_headers(token),
timeout=10
)
if repo_response.status_code != 200:
return {'success': False, 'error': 'Repository not found'}
repo_data = repo_response.json()
# Get branch count
branches_response = requests.get(
f"{base_url}/repos/{owner}/{repo}/branches",
headers=cls._get_headers(token),
timeout=10
)
branch_count = len(branches_response.json()) if branches_response.status_code == 200 else 0
# Get recent commits count
commits_response = requests.get(
f"{base_url}/repos/{owner}/{repo}/commits",
headers=cls._get_headers(token),
params={'limit': 1},
timeout=10
)
return {
'success': True,
'stats': {
'stars': repo_data.get('stars_count', 0),
'forks': repo_data.get('forks_count', 0),
'watchers': repo_data.get('watchers_count', 0),
'branches': branch_count,
'size': repo_data.get('size', 0),
'open_issues': repo_data.get('open_issues_count', 0),
'default_branch': repo_data.get('default_branch', 'main'),
'has_wiki': repo_data.get('has_wiki', False),
'has_issues': repo_data.get('has_issues', False)
}
}
except Exception as e:
return {'success': False, 'error': str(e)}
# ==================== FORMATTING HELPERS ====================
@classmethod
def _format_repo(cls, repo: Dict) -> Dict:
"""Format repository data."""
return {
'id': repo.get('id'),
'name': repo.get('name'),
'full_name': repo.get('full_name'),
'description': repo.get('description'),
'private': repo.get('private', False),
'fork': repo.get('fork', False),
'empty': repo.get('empty', False),
'mirror': repo.get('mirror', False),
'size': repo.get('size', 0),
'default_branch': repo.get('default_branch', 'main'),
'stars': repo.get('stars_count', 0),
'forks': repo.get('forks_count', 0),
'watchers': repo.get('watchers_count', 0),
'open_issues': repo.get('open_issues_count', 0),
'owner': {
'login': repo.get('owner', {}).get('login'),
'avatar_url': repo.get('owner', {}).get('avatar_url')
},
'html_url': repo.get('html_url'),
'clone_url': repo.get('clone_url'),
'ssh_url': repo.get('ssh_url'),
'created_at': repo.get('created_at'),
'updated_at': repo.get('updated_at'),
'pushed_at': repo.get('pushed_at')
}
@classmethod
def _format_branch(cls, branch: Dict) -> Dict:
"""Format branch data."""
commit = branch.get('commit', {})
return {
'name': branch.get('name'),
'protected': branch.get('protected', False),
'commit': {
'sha': commit.get('id') or commit.get('sha'),
'message': commit.get('message'),
'author': commit.get('author', {}).get('name') if commit.get('author') else None,
'date': commit.get('timestamp') or commit.get('committer', {}).get('date')
}
}
@classmethod
def _format_commit(cls, commit: Dict) -> Dict:
"""Format commit data."""
commit_data = commit.get('commit', commit)
author = commit_data.get('author', {})
committer = commit_data.get('committer', {})
return {
'sha': commit.get('sha') or commit.get('id'),
'short_sha': (commit.get('sha') or commit.get('id', ''))[:7],
'message': commit_data.get('message', ''),
'author': {
'name': author.get('name'),
'email': author.get('email'),
'date': author.get('date')
},
'committer': {
'name': committer.get('name'),
'email': committer.get('email'),
'date': committer.get('date')
},
'html_url': commit.get('html_url'),
'parents': [p.get('sha') for p in commit.get('parents', [])]
}
@classmethod
def _format_file(cls, file: Dict) -> Dict:
"""Format file data."""
return {
'name': file.get('name'),
'path': file.get('path'),
'type': file.get('type'), # 'file' or 'dir'
'size': file.get('size', 0),
'sha': file.get('sha'),
'html_url': file.get('html_url'),
'download_url': file.get('download_url')
}
@@ -1,292 +0,0 @@
"""Gitea self-host management — the serverkit-git extension's backend half.
Moved verbatim out of core ``GitService`` (plan 52 Phase 6): install/lifecycle
for the panel-managed Gitea container plus its config file. The deploy-side
git CLI operations (clone/pull/deploy/webhooks) STAY core in
``app.services.git_service`` — they are the deploy pipeline and must never
depend on this extension.
Core seams kept on purpose: ``NginxService.create/remove/get_gitea_config``
(the /gitea location blocks) remain core helpers this service calls, the same
two-speed shape as the WP fail2ban filter text.
"""
import os
import json
import logging
from typing import Dict
from app import paths
from app.utils.system import run_checked
logger = logging.getLogger(__name__)
class GiteaServerService:
CONFIG_DIR = paths.SERVERKIT_CONFIG_DIR
GITEA_APP_NAME = 'serverkit-gitea'
GITEA_CONFIG_FILE = os.path.join(CONFIG_DIR, 'gitea.json')
@classmethod
def get_gitea_status(cls) -> Dict:
"""Check if Gitea is installed and running."""
from app.models import Application
app = Application.query.filter_by(name=cls.GITEA_APP_NAME).first()
if not app:
return {
'installed': False,
'running': False,
'http_port': None,
'ssh_port': None,
'url': None,
'url_path': None
}
# Check container status
running = cls._is_gitea_running()
# Load config for ports
config = cls._load_gitea_config()
# Prefer the panel's canonical origin so the link works through a domain
# and Cloudflare; fall back to the local port only when no domain is set.
from app.services.site_domain_service import SiteDomainService
panel_origin = SiteDomainService.panel_origin()
if panel_origin:
public_url = f"{panel_origin}/gitea"
elif app.port:
public_url = f"http://localhost:{app.port}"
else:
public_url = None
return {
'installed': True,
'running': running,
'http_port': app.port or config.get('http_port'),
'ssh_port': config.get('ssh_port'),
'url_path': '/gitea',
'url': public_url,
'app_id': app.id,
'version': config.get('version', '1.21')
}
@classmethod
def _is_gitea_running(cls) -> bool:
"""Check if Gitea container is running."""
try:
result = run_checked(
['docker', 'ps', '--filter', f'name={cls.GITEA_APP_NAME}',
'--format', '{{.Names}}'], timeout=10)
return cls.GITEA_APP_NAME in result['output']
except Exception:
return False
@classmethod
def _load_gitea_config(cls) -> Dict:
"""Load Gitea configuration."""
if os.path.exists(cls.GITEA_CONFIG_FILE):
try:
with open(cls.GITEA_CONFIG_FILE, 'r') as f:
return json.load(f)
except Exception:
pass
return {}
@classmethod
def _save_gitea_config(cls, config: Dict) -> bool:
"""Save Gitea configuration."""
try:
os.makedirs(cls.CONFIG_DIR, exist_ok=True)
with open(cls.GITEA_CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
return True
except Exception:
return False
@classmethod
def get_gitea_resource_requirements(cls) -> Dict:
"""Get resource requirements for Gitea installation."""
return {
'memory_min': '512MB',
'memory_recommended': '1GB',
'storage_min': '5GB',
'storage_recommended': '20GB',
'components': [
{'name': 'Gitea', 'memory': '~300MB', 'storage': '~100MB + repos'},
{'name': 'PostgreSQL', 'memory': '~200MB', 'storage': '~1GB'}
],
'warning': 'Installation will spin up a PostgreSQL database container'
}
@classmethod
def install_gitea(cls, admin_user: str = 'admin',
admin_email: str = None,
admin_password: str = None) -> Dict:
"""Install Gitea as integrated ServerKit service."""
from app.services.template_service import TemplateService
from app.services.nginx_service import NginxService
# Check if already installed
status = cls.get_gitea_status()
if status['installed']:
return {'success': False, 'error': 'Gitea is already installed'}
# Generate secure password if not provided
generated_password = False
if not admin_password:
admin_password = secrets.token_urlsafe(16)
generated_password = True
try:
# Install using template service
result = TemplateService.install_template(
template_id='gitea',
app_name=cls.GITEA_APP_NAME,
user_variables={},
user_id=1 # System user
)
if not result.get('success'):
return result
# Get the variables that were generated
variables = result.get('variables', {})
http_port = variables.get('HTTP_PORT')
ssh_port = variables.get('SSH_PORT')
# Create nginx config for /gitea path
nginx_result = NginxService.create_gitea_config(int(http_port))
if not nginx_result.get('success'):
# Log warning but don't fail - Gitea still works via port
print(f"Warning: Failed to create Gitea nginx config: {nginx_result.get('error')}")
# Save config with admin credentials and ports
config = {
'admin_user': admin_user,
'admin_email': admin_email,
'http_port': http_port,
'ssh_port': ssh_port,
'db_password': variables.get('DB_PASSWORD'),
'installed_at': datetime.now().isoformat(),
'version': '1.21',
'url_path': '/gitea'
}
cls._save_gitea_config(config)
response = {
'success': True,
'message': 'Gitea installed successfully',
'http_port': http_port,
'ssh_port': ssh_port,
'url_path': '/gitea',
'admin_user': admin_user
}
# Only include password in response if it was generated
if generated_password:
response['admin_password'] = admin_password
response['warning'] = 'Save these credentials - password shown only once!'
return response
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def uninstall_gitea(cls, remove_data: bool = False) -> Dict:
"""Uninstall Gitea."""
from app import db
from app.models import Application
from app.services.docker_service import DockerService
from app.services.nginx_service import NginxService
app = Application.query.filter_by(name=cls.GITEA_APP_NAME).first()
if not app:
return {'success': False, 'error': 'Gitea is not installed'}
try:
# Remove nginx config
NginxService.remove_gitea_config()
# Stop and remove containers
if app.root_path and os.path.exists(app.root_path):
DockerService.compose_down(app.root_path, remove_volumes=remove_data)
if remove_data:
import shutil
shutil.rmtree(app.root_path, ignore_errors=True)
# Remove from database
db.session.delete(app)
db.session.commit()
# Remove config
if os.path.exists(cls.GITEA_CONFIG_FILE):
os.remove(cls.GITEA_CONFIG_FILE)
return {
'success': True,
'message': 'Gitea uninstalled successfully',
'data_removed': remove_data
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def start_gitea(cls) -> Dict:
"""Start Gitea containers."""
from app import db
from app.models import Application
from app.services.docker_service import DockerService
app = Application.query.filter_by(name=cls.GITEA_APP_NAME).first()
if not app:
return {'success': False, 'error': 'Gitea is not installed'}
if not app.root_path or not os.path.exists(app.root_path):
return {'success': False, 'error': 'Gitea installation path not found'}
try:
result = DockerService.compose_up(app.root_path, detach=True)
if result.get('success'):
app.status = 'running'
db.session.commit()
return {'success': True, 'message': 'Gitea started'}
return result
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def stop_gitea(cls) -> Dict:
"""Stop Gitea containers."""
from app import db
from app.models import Application
from app.services.docker_service import DockerService
app = Application.query.filter_by(name=cls.GITEA_APP_NAME).first()
if not app:
return {'success': False, 'error': 'Gitea is not installed'}
if not app.root_path or not os.path.exists(app.root_path):
return {'success': False, 'error': 'Gitea installation path not found'}
try:
result = DockerService.compose_stop(app.root_path)
if result.get('success'):
app.status = 'stopped'
db.session.commit()
return {'success': True, 'message': 'Gitea stopped'}
return result
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def restart_gitea(cls) -> Dict:
"""Restart Gitea containers."""
stop_result = cls.stop_gitea()
if not stop_result.get('success'):
return stop_result
return cls.start_gitea()
@@ -1,26 +0,0 @@
// Git UI, contributed through the extension system. The full page lives in
// this extension now (pages/Git.jsx + styles/git.scss + its own Gitea API
// client) — plan 52 Phase 6. Imports go through 'serverkit-sdk' so the same
// source works baked in-tree (the panel's build-time glob) and as a
// runtime-ESM bundle once the extension leaves the tree.
//
// Two halves, one page: the Gitea self-host surface calls this extension's
// backend (/api/v1/git status/lifecycle/repos, mounted when installed); the
// webhooks + deployments tabs call the CORE deploy-pipeline endpoints, which
// exist on every panel.
//
// The SCSS side-effect import compiles the page styles through this
// extension's own module graph — the host's main.scss no longer carries a
// _git partial.
import './styles/git.scss';
import GitPage from './pages/Git';
export function GitExtensionPage() {
return <GitPage basePath="/git" />;
}
// Backward compatibility for older installed manifests that still point
// at GitExtPage. Contribution normalization rewrites those manifests, but
// keeping the export avoids a blank route if stale metadata slips through.
export const GitExtPage = GitExtensionPage;
File diff suppressed because it is too large. Load diff
@@ -1,45 +0,0 @@
// The extension's own Gitea self-host client (/api/v1/git/* — server
// lifecycle + repo browsing), built on the panel's ApiClient via the SDK
// singleton — moved out of the host's services/api/files.js with the Git
// page (plan 52 Phase 6). The webhook + deployment endpoints stay CORE
// (they are the deploy pipeline); the page calls those through the core
// `api` object from serverkit-sdk.
import { api } from 'serverkit-sdk';
const giteaApi = {
// Server lifecycle
getGitServerStatus: () => api.request('/git/status'),
getGitRequirements: () => api.request('/git/requirements'),
installGit: (data) => api.request('/git/install', { method: 'POST', body: data }),
uninstallGit: (removeData = false) => api.request('/git/uninstall', { method: 'POST', body: { removeData } }),
startGit: () => api.request('/git/start', { method: 'POST' }),
stopGit: () => api.request('/git/stop', { method: 'POST' }),
restartGit: () => api.request('/git/restart', { method: 'POST' }),
// Repositories
getRepositories: (limit = 50) => api.request(`/git/repos?limit=${limit}`),
getRepository: (owner, repo) => api.request(`/git/repos/${owner}/${repo}`),
getRepoStats: (owner, repo) => api.request(`/git/repos/${owner}/${repo}/stats`),
getBranches: (owner, repo) => api.request(`/git/repos/${owner}/${repo}/branches`),
getBranch: (owner, repo, branch) => api.request(`/git/repos/${owner}/${repo}/branches/${branch}`),
getCommits: (owner, repo, branch = null, page = 1, limit = 30) => {
let url = `/git/repos/${owner}/${repo}/commits?page=${page}&limit=${limit}`;
if (branch) url += `&branch=${branch}`;
return api.request(url);
},
getCommit: (owner, repo, sha) => api.request(`/git/repos/${owner}/${repo}/commits/${sha}`),
getRepoFiles: (owner, repo, ref = 'main', path = '') => {
let url = `/git/repos/${owner}/${repo}/contents?ref=${ref}`;
if (path) url += `&path=${path}`;
return api.request(url);
},
getFileContent: (owner, repo, filepath, ref = 'main') => api.request(`/git/repos/${owner}/${repo}/contents/${filepath}?ref=${ref}`),
getRepoReadme: (owner, repo, ref = null) => {
let url = `/git/repos/${owner}/${repo}/readme`;
if (ref) url += `?ref=${ref}`;
return api.request(url);
},
getGiteaVersion: () => api.request('/git/version'),
};
export default giteaApi;
File diff suppressed because it is too large. Load diff
@@ -1,50 +0,0 @@
{
"name": "serverkit-git",
"display_name": "Git Server",
"version": "1.0.0",
"description": "Self-hosted Git server (Gitea) exposed through the ServerKit extension system.",
"author": "ServerKit",
"license": "MIT",
"category": "deployment",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": [
"docker",
"filesystem",
"shell"
],
"contributions": {
"nav": [
{
"id": "git",
"label": "Git",
"route": "/git",
"category": "infrastructure",
"icon": "<circle cx=\"18\" cy=\"18\" r=\"3\"/><circle cx=\"6\" cy=\"6\" r=\"3\"/><path d=\"M6 21V9a9 9 0 0 0 9 9\"/>"
}
],
"routes": [
{
"path": "git",
"component": "GitExtensionPage"
},
{
"path": "git/:tab",
"component": "GitExtensionPage"
}
],
"page_titles": {
"/git": "Git Repositories"
},
"command_palette": [
{
"label": "Git",
"path": "/git",
"category": "Pages",
"keywords": "git repos deploy extension plugin"
}
]
},
"entry_point": "gitea:gitea_bp",
"url_prefix": "/api/v1/git",
"min_panel_version": "1.9.11"
}
Whitespace-only changes.
@@ -1,928 +0,0 @@
"""
LocalKit Bridge API — endpoints for the LocalKit desktop app.
Mounted at ``/api/v1/localkit`` (see plugin.json). Every route accepts
``X-API-Key`` auth because it is guarded by the RBAC decorators
(``auth_required`` / ``admin_required``) that honor the API-key middleware
(``g.api_key_user``) — bare flask ``@jwt_required()`` would reject API keys
(see docs/WORDPRESS_ROADMAP.md; this blueprint is the API-key-friendly
surface for WordPress automation).
Endpoints (all JSON unless noted):
GET /pair — validate the API key + report panel info + `features` +
`kinds` (site kinds this extension can sync, plan 26)
GET /sites — list WordPress sites (delegates to serverkit-wordpress);
each carries a `kind` (plan 26)
POST /sites — provision a new WordPress site to push into
POST /push/code — multipart: site_id + wp-content tar.gz -> docker cp into the site
POST /push/db — multipart: site_id + SQL dump + local_url -> import + search-replace
GET /pull/db — ?site_id= -> gzipped SQL dump of the site's database
GET /pull/code — ?site_id= -> tar.gz of the site's wp-content directory
Sync v2 (chunked, resumable — plan 19), for `<kind>` in {code, db}:
POST /push/<kind>/init — describe the transfer -> {transfer_id, received}
PUT /push/<kind>/chunk — ?transfer_id&offset&sha256 + raw body -> {received}
POST /push/<kind>/finish — verify the whole-file sha256, then run the *same*
v1 processing path on the assembled file
v1 push runs inline and is bounded by the panel's MAX_CONTENT_LENGTH (100MB).
v2 lifts that: every request is one 8 MiB chunk, so the limit stops applying to
the payload. Both directions end in the same `_install_code` / `_import_db`
helper — there is exactly one processing path, and v2 only changes how the
bytes arrive. Processing still happens inline in `finish` (no job queue yet),
but only *after* the hash verifies, so an abandoned transfer can never leave
half-applied state: it is just a temp directory the next `init` reaps.
`FEATURES` below is the capability contract LocalKit gates its UI on: an older
copy of this extension simply omits a name, and the client disables the
matching button instead of failing halfway through an operation.
"""
import hashlib
import importlib
import json
import os
import re
import shutil
import subprocess
import tarfile
import tempfile
import time
import uuid
from flask import Blueprint, after_this_request, jsonify, request, send_file
from app.middleware.rbac import admin_required, auth_required, get_current_user
from app.services.db_sync_service import DatabaseSyncService
from app.services.settings_service import SettingsService
from app.utils.domain import canonical_origin
from app.utils.version import get_panel_version
localkit_bp = Blueprint('localkit', __name__)
# What this build of the extension can do — reported by GET /pair so LocalKit
# can gate its UI on capability instead of discovering a 404 mid-operation.
# Append only; never rename an entry (clients match on the literal string).
FEATURES = ['sites', 'push-code', 'push-db', 'pull-db', 'pull-code', 'sync-v2']
# Site kinds this extension can sync (plan 26). LocalKit gates its per-kind
# sync/import UI on this list, so a new client never starts a php sync a server
# can't finish. Only WordPress is backed today (serverkit-wordpress is the only
# site backend); add 'php' here in lockstep with a php-stack backend that
# `_resolve_site` / `_install_code` / `_import_db` can dispatch to.
KINDS = ['wordpress']
# A transfer with no chunk activity for this long is abandoned; the next
# ``init`` sweeps it. Long enough that a user pausing on a slow link keeps
# their resume point, short enough that a dead client's partial upload does
# not sit on the panel's disk overnight.
TRANSFER_TTL_SECONDS = 30 * 60
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _wp_service():
"""Import the serverkit-wordpress service layer (cross-extension), or
return None when that extension is not installed."""
try:
return importlib.import_module(
'app.plugins.serverkit-wordpress.wordpress_service').WordPressService
except Exception:
pass
try:
from app.services.plugin_service import _ensure_importable
if _ensure_importable('serverkit-wordpress'):
return importlib.import_module(
'app.plugins.serverkit-wordpress.wordpress_service').WordPressService
except Exception:
pass
return None
def _require_wp_service():
svc = _wp_service()
if svc is None:
return None, (jsonify({
'error': 'The serverkit-wordpress extension is not installed on this server'
}), 409)
return svc, None
def _resolve_site(site_id):
from app.models import WordPressSite
try:
site_id = int(site_id)
except (TypeError, ValueError):
return None, (jsonify({'error': 'site_id is required'}), 400)
site = WordPressSite.query.get(site_id)
if not site or not site.application:
return None, (jsonify({'error': 'Site not found'}), 404)
return site, None
def _site_url(site):
"""Public URL of a site: primary domain if set, else localhost:port —
mirrors WordPressService._enrich_site_data."""
app = site.application
domains = app.domains
primary = next((d for d in domains if d.is_primary), None)
if primary is None and domains:
primary = domains[0]
if primary is not None:
scheme = 'https' if primary.ssl_enabled else 'http'
return f'{scheme}://{primary.name}'
if app.port:
return f'http://localhost:{app.port}'
return None
def _read_env_value(root_path, key):
try:
with open(os.path.join(root_path, '.env')) as f:
for line in f:
if '=' in line:
k, v = line.split('=', 1)
if k.strip() == key:
return v.strip()
except OSError:
pass
return None
def _compose_file(site):
root_path = site.application.root_path
if not root_path:
return None
compose_file = os.path.join(root_path, 'docker-compose.yml')
return compose_file if os.path.exists(compose_file) else None
def _php_version(site):
"""PHP version of a Docker-stack site, read from the compose image tag
(``wordpress:<core>-php<ver>-apache`` — the same tag
``WordPressService.set_php_version`` rewrites).
Deliberately a file read rather than ``get_php_info``: that shells into the
container, and this runs once per site in a listing.
"""
compose_file = _compose_file(site)
if not compose_file:
return None
try:
with open(compose_file) as f:
content = f.read()
except OSError:
return None
m = re.search(r'image:\s*wordpress:\S*?-php(\d+\.\d+)', content)
return m.group(1) if m else None
def _safe_extract_tar_gz(archive_path, dest_dir):
"""Extract a .tar.gz into dest_dir, rejecting path traversal and links
(same policy as WordPressService._safe_extract_zip)."""
dest_abs = os.path.abspath(dest_dir)
try:
with tarfile.open(archive_path, 'r:gz') as tf:
for member in tf.getmembers():
if member.issym() or member.islnk():
return {'success': False,
'error': f'Links are not allowed in archive: {member.name}'}
target = os.path.abspath(os.path.join(dest_abs, member.name))
if target != dest_abs and not target.startswith(dest_abs + os.sep):
return {'success': False, 'error': f'Unsafe path in archive: {member.name}'}
tf.extractall(dest_abs)
return {'success': True}
except (tarfile.TarError, OSError, EOFError) as e:
return {'success': False, 'error': f'Not a valid tar.gz archive: {e}'}
# ---------------------------------------------------------------------------
# Processing (shared by the v1 one-shot uploads and the v2 chunked `finish`)
# ---------------------------------------------------------------------------
def _install_code(svc, site, archive_path):
"""Extract a wp-content tar.gz and install it into the site's container.
Returns ``(payload, status)``. This is the single code-push processing
path: v1 hands it the multipart upload, v2 hands it the file its chunks
were assembled into.
"""
tmp = tempfile.mkdtemp(prefix='localkit_extract_')
try:
extract_dir = os.path.join(tmp, 'x')
os.makedirs(extract_dir, exist_ok=True)
# The archive is hash-verified by the time v2 gets here, which proves
# it arrived intact — not that it is friendly. The safe-extract policy
# stays mandatory in both paths.
ext = _safe_extract_tar_gz(archive_path, extract_dir)
if not ext.get('success'):
return ext, 400
wpc = svc._resolve_wp_content_dir(extract_dir)
if not wpc:
return {'error': 'No wp-content found in the archive'}, 400
container = site.application.name
cp = subprocess.run(
['docker', 'cp', f'{wpc}/.', f'{container}:/var/www/html/wp-content'],
capture_output=True, text=True, timeout=600,
)
if cp.returncode != 0:
return {'error': f'docker cp failed: {cp.stderr}'}, 500
# Imported files land root-owned; hand them to the web user.
subprocess.run(
['docker', 'exec', container, 'chown', '-R', 'www-data:www-data',
'/var/www/html/wp-content'],
capture_output=True, text=True, timeout=300,
)
if site.application.root_path:
svc.wp_cli(site.application.root_path, ['cache', 'flush'])
return {'success': True, 'message': 'wp-content pushed to the site'}, 200
finally:
shutil.rmtree(tmp, ignore_errors=True)
def _import_db(svc, site, dump_path, local_url):
"""Import a SQL dump over the site's database and rewrite local -> remote
URLs. Returns ``(payload, status)``. Shared by v1 and v2, as above."""
compose_file = _compose_file(site)
if not compose_file:
return {'error': 'Not a Docker-stack site (no docker-compose.yml)'}, 409
result = DatabaseSyncService.import_to_container(
compose_path=compose_file,
snapshot_path=dump_path,
db_name='wordpress',
db_user='root',
db_password=_read_env_value(site.application.root_path, 'DB_PASSWORD'),
)
if not result.get('success'):
return {'error': f"Database import failed: {result.get('error')}"}, 500
remote_url = _site_url(site)
search_replaced = False
local_url = (local_url or '').strip().rstrip('/')
if local_url and remote_url and local_url != remote_url:
root_path = site.application.root_path
svc.wp_cli(root_path, ['option', 'update', 'home', remote_url])
svc.wp_cli(root_path, ['option', 'update', 'siteurl', remote_url])
sr = svc.search_replace(root_path, local_url, remote_url)
search_replaced = bool(sr.get('success'))
svc.wp_cli(root_path, ['cache', 'flush'])
svc.wp_cli(root_path, ['rewrite', 'flush'])
return {
'success': True,
'message': 'Database imported',
'remote_url': remote_url,
'search_replace': search_replaced,
}, 200
# ---------------------------------------------------------------------------
# Pairing
# ---------------------------------------------------------------------------
@localkit_bp.route('/pair', methods=['GET'])
@auth_required()
def pair():
"""Validate the API key and return panel info for LocalKit's settings UI."""
user = get_current_user()
canonical_domain = SettingsService.get('canonical_domain', '') or ''
https_enabled = bool(SettingsService.get('canonical_https_enabled', False))
return jsonify({
'status': 'ok',
'service': 'serverkit-localkit',
'panel': 'ServerKit',
'version': get_panel_version(),
'user': (getattr(user, 'username', None) or getattr(user, 'email', None)
or getattr(user, 'name', None)),
'canonical_domain': canonical_domain,
'canonical_origin': canonical_origin(canonical_domain, https_enabled) if canonical_domain else None,
'features': FEATURES,
'kinds': KINDS,
}), 200
# ---------------------------------------------------------------------------
# Sites
# ---------------------------------------------------------------------------
@localkit_bp.route('/sites', methods=['GET'])
@admin_required
def list_sites():
"""List WordPress sites — the API-key-friendly equivalent of
GET /api/v1/wordpress/sites (which is JWT-only).
Enriched with the fields LocalKit's import flow needs and the hub payload
does not carry: ``php_version`` (to pick the closest local image) and
``site_url`` (an explicit alias of ``url``, which is what search-replace
rewrites from). ``multisite`` already comes from the model — LocalKit
refuses to import those.
"""
svc, err = _require_wp_service()
if err:
return err
payload = svc.get_sites()
from app.models import WordPressSite
for entry in payload.get('sites') or []:
site = WordPressSite.query.get(entry.get('id')) if entry.get('id') else None
if site is None or not site.application:
continue
entry['php_version'] = _php_version(site)
entry['site_url'] = entry.get('url') or _site_url(site)
# Stack kind (plan 26). Every site here is WordPress today; the client
# defaults an absent kind to 'wordpress', so this is also the safe value
# for a php-stack backend to override once one exists.
entry.setdefault('kind', 'wordpress')
return jsonify(payload), 200
@localkit_bp.route('/sites', methods=['POST'])
@admin_required
def create_site():
"""Provision a fresh WordPress site to push into."""
svc, err = _require_wp_service()
if err:
return err
data = request.get_json(silent=True) or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'Site name is required'}), 400
user = get_current_user()
admin_email = (data.get('admin_email') or getattr(user, 'email', None)
or 'admin@example.com')
result = svc.create_site(
name,
admin_email,
user.id,
php_version=data.get('php_version') or None,
)
return jsonify(result), (201 if result.get('success') else 400)
# ---------------------------------------------------------------------------
# Push / pull
# ---------------------------------------------------------------------------
@localkit_bp.route('/push/code', methods=['POST'])
@admin_required
def push_code():
"""Receive a tar.gz of wp-content and install it into the site's container."""
svc, err = _require_wp_service()
if err:
return err
site, err = _resolve_site(request.form.get('site_id'))
if err:
return err
upload = request.files.get('file')
if upload is None:
return jsonify({'error': 'file is required (multipart tar.gz of wp-content)'}), 400
tmp = tempfile.mkdtemp(prefix='localkit_push_')
try:
archive_path = os.path.join(tmp, 'wp-content.tar.gz')
upload.save(archive_path)
payload, status = _install_code(svc, site, archive_path)
return jsonify(payload), status
finally:
shutil.rmtree(tmp, ignore_errors=True)
@localkit_bp.route('/push/db', methods=['POST'])
@admin_required
def push_db():
"""Receive a SQL dump, import it over the site's database and rewrite the
site URL local -> remote (serialization-safe wp-cli search-replace)."""
svc, err = _require_wp_service()
if err:
return err
site, err = _resolve_site(request.form.get('site_id'))
if err:
return err
upload = request.files.get('file')
if upload is None:
return jsonify({'error': 'file is required (multipart .sql or .sql.gz dump)'}), 400
local_url = request.form.get('local_url')
suffix = '.sql.gz' if (upload.filename or '').endswith('.gz') else '.sql'
fd, dump_path = tempfile.mkstemp(prefix='localkit_push_', suffix=suffix)
os.close(fd)
try:
upload.save(dump_path)
payload, status = _import_db(svc, site, dump_path, local_url)
return jsonify(payload), status
finally:
try:
os.remove(dump_path)
except OSError:
pass
# ---------------------------------------------------------------------------
# Chunked push (sync v2, plan 19)
# ---------------------------------------------------------------------------
#
# A transfer is a directory, not a database row — the panel owns no schema for
# something this short-lived, and a directory is trivially reapable:
#
# <tmp>/localkit_transfers/<transfer_id>/
# meta.json written once at init, read-only afterwards
# payload the assembled file (written at offsets, sparse until full)
# chunks/<offset> marker per confirmed chunk, containing its sha256
#
# Confirmed chunks are marker *files* rather than a list inside meta.json on
# purpose: two chunk requests can then never race each other rewriting shared
# state, without the extension having to take a lock.
_HEX64 = re.compile(r'^[0-9a-f]{64}$')
_TRANSFER_ID = re.compile(r'^[0-9a-f]{32}$')
# Bounds ``request.get_data()`` on a chunk PUT. The panel's own
# MAX_CONTENT_LENGTH (100MB) is the hard stop; this keeps a client from
# negotiating a chunk size that makes each request pathologically large.
MAX_CHUNK_SIZE = 64 * 1024 * 1024
def _transfer_root():
root = os.path.join(tempfile.gettempdir(), 'localkit_transfers')
os.makedirs(root, exist_ok=True)
return root
def _transfer_dir(transfer_id):
"""Directory for a transfer id, or None if the id is not one we minted.
The id lands in a filesystem path, so it is validated against the exact
shape ``uuid4().hex`` produces rather than sanitized — anything else is a
client sending something it was never given.
"""
if not transfer_id or not _TRANSFER_ID.match(transfer_id):
return None
return os.path.join(_transfer_root(), transfer_id)
def _load_meta(transfer_id):
tdir = _transfer_dir(transfer_id)
if not tdir:
return None, None
try:
with open(os.path.join(tdir, 'meta.json')) as f:
return json.load(f), tdir
except (OSError, ValueError):
return None, None
def _received_offsets(tdir):
try:
names = os.listdir(os.path.join(tdir, 'chunks'))
except OSError:
return []
out = []
for name in names:
try:
out.append(int(name))
except ValueError:
continue
return sorted(out)
def _touched_at(tdir):
"""Last sign of life for a transfer directory: the newest mtime among the
directory itself and everything immediately inside it. Covers both shapes
living under the transfer root — an upload's ``chunks/`` subdirectory and a
download session's cached export file."""
try:
children = os.listdir(tdir)
except OSError:
children = []
stamps = []
for path in (tdir, *(os.path.join(tdir, n) for n in children)):
try:
stamps.append(os.path.getmtime(path))
except OSError:
pass
return max(stamps) if stamps else 0
def _discard_transfer(tdir):
shutil.rmtree(tdir, ignore_errors=True)
def _reap_stale_transfers():
"""Lazy sweep, run on every ``init``. No cron, no background thread: an
abandoned transfer costs disk, and the next person to start one pays the
(cheap) cost of cleaning up after them."""
root = _transfer_root()
cutoff = time.time() - TRANSFER_TTL_SECONDS
try:
entries = os.listdir(root)
except OSError:
return
for name in entries:
tdir = os.path.join(root, name)
if os.path.isdir(tdir) and _touched_at(tdir) < cutoff:
_discard_transfer(tdir)
def _find_resumable(site_id, kind, sha256, total_bytes, chunk_size):
"""An existing transfer of the exact same payload, if one survived.
Every field must match, chunk_size included: a different chunking of the
same bytes puts the confirmed offsets in the wrong places, and resuming
onto it would assemble a file that fails the final hash check for reasons
nobody could debug.
"""
root = _transfer_root()
try:
entries = sorted(os.listdir(root))
except OSError:
return None
for name in entries:
meta, tdir = _load_meta(name)
if not meta or not os.path.isdir(tdir or ''):
continue
if (meta.get('site_id') == site_id and meta.get('kind') == kind
and meta.get('sha256') == sha256
and meta.get('total_bytes') == total_bytes
and meta.get('chunk_size') == chunk_size):
return name
return None
def _download_cache(session, kind, site_id):
"""Path where a resumable download session caches its materialized export,
or None when the client did not ask for one.
Why a session id rather than plain ``Range`` against a freshly built
export: pull/db and pull/code *materialize* their payload per request
(mysqldump, ``tar czf``). Ranges taken from two different exports would
assemble into a file that is neither. Pinning an export to a client-chosen
session id makes the bytes stable for the life of that download, while a
new pull still gets a brand-new export — no staleness, no cross-request
corruption. Clients that send no session (v1) keep the old
export-and-delete behavior exactly.
"""
if not session or not _TRANSFER_ID.match(session):
return None
ddir = os.path.join(_transfer_root(), f'dl{session}')
os.makedirs(ddir, exist_ok=True)
return os.path.join(ddir, f'{kind}-{site_id}')
def _serve_export(path, download_name, session):
"""Send a materialized export, with Range support when it is session-cached.
Without a session the file is deleted after the response (v1 behavior).
With one it survives for the transfer TTL, and ``conditional=True`` gives
Range/If-Range for free — a client resuming a dropped download re-requests
only the tail, and a cache that has been reaped simply serves a 200 with
the whole body, which the client handles by starting over.
"""
if session:
# Keep an actively-downloading session from being reaped mid-transfer.
try:
os.utime(path, None)
except OSError:
pass
else:
@after_this_request
def _cleanup(response):
try:
os.remove(path)
except OSError:
pass
return response
return send_file(
path,
mimetype='application/gzip',
as_attachment=True,
download_name=download_name,
conditional=True,
)
def _expected_chunk_len(meta, offset):
"""Length the chunk at `offset` must have, or None if `offset` is not a
chunk boundary of this transfer."""
total, chunk_size = meta['total_bytes'], meta['chunk_size']
if offset < 0 or offset >= total or offset % chunk_size != 0:
return None
return min(chunk_size, total - offset)
@localkit_bp.route('/push/<kind>/init', methods=['POST'])
@admin_required
def push_init(kind):
"""Open (or re-open) a chunked transfer and report what is already here.
Resume lives entirely in the ``received`` array of this response: the
client subtracts it from its own chunk plan, so a retry after a dropped
connection re-sends only what was actually lost.
"""
if kind not in ('code', 'db'):
return jsonify({'error': 'Unknown transfer kind'}), 404
svc, err = _require_wp_service()
if err:
return err
data = request.get_json(silent=True) or {}
site, err = _resolve_site(data.get('site_id'))
if err:
return err
sha256 = (data.get('sha256') or '').lower()
if not _HEX64.match(sha256):
return jsonify({'error': 'sha256 must be a hex-encoded SHA-256 digest'}), 400
try:
total_bytes = int(data.get('total_bytes'))
chunk_size = int(data.get('chunk_size'))
except (TypeError, ValueError):
return jsonify({'error': 'total_bytes and chunk_size are required'}), 400
if total_bytes < 0:
return jsonify({'error': 'total_bytes must not be negative'}), 400
if not 0 < chunk_size <= MAX_CHUNK_SIZE:
return jsonify({'error': f'chunk_size must be between 1 and {MAX_CHUNK_SIZE}'}), 400
_reap_stale_transfers()
existing = _find_resumable(site.id, kind, sha256, total_bytes, chunk_size)
if existing:
tdir = _transfer_dir(existing)
return jsonify({
'transfer_id': existing,
'chunk_size': chunk_size,
'received': _received_offsets(tdir),
'resumed': True,
}), 200
transfer_id = uuid.uuid4().hex
tdir = _transfer_dir(transfer_id)
os.makedirs(os.path.join(tdir, 'chunks'), exist_ok=True)
meta = {
'transfer_id': transfer_id,
'site_id': site.id,
'kind': kind,
'total_bytes': total_bytes,
'chunk_size': chunk_size,
'sha256': sha256,
'local_url': (data.get('local_url') or '').strip(),
'filename': data.get('filename') or ('wp-content.tar.gz' if kind == 'code' else 'dump.sql'),
'created': time.time(),
}
with open(os.path.join(tdir, 'meta.json'), 'w') as f:
json.dump(meta, f)
# Create the payload so chunk writes only ever have to seek into it.
with open(os.path.join(tdir, 'payload'), 'wb'):
pass
return jsonify({
'transfer_id': transfer_id,
'chunk_size': chunk_size,
'received': [],
'resumed': False,
}), 201
@localkit_bp.route('/push/<kind>/chunk', methods=['PUT'])
@admin_required
def push_chunk(kind):
"""Write one chunk into the transfer's payload file.
Idempotent by design: re-sending a chunk that is already confirmed with the
same hash is a 200 no-op, so a client that loses the response to a chunk it
actually delivered can simply send it again.
"""
transfer_id = request.args.get('transfer_id')
meta, tdir = _load_meta(transfer_id)
if not meta or meta.get('kind') != kind:
return jsonify({'error': 'Unknown or expired transfer'}), 404
try:
offset = int(request.args.get('offset'))
except (TypeError, ValueError):
return jsonify({'error': 'offset is required'}), 400
expected_len = _expected_chunk_len(meta, offset)
if expected_len is None:
return jsonify({'error': f'offset {offset} is not a chunk boundary of this transfer'}), 400
sha256 = (request.args.get('sha256') or '').lower()
if not _HEX64.match(sha256):
return jsonify({'error': 'sha256 must be a hex-encoded SHA-256 digest'}), 400
marker = os.path.join(tdir, 'chunks', str(offset))
if os.path.exists(marker):
try:
with open(marker) as f:
if f.read().strip() == sha256:
return jsonify({
'received': _received_offsets(tdir),
'duplicate': True,
}), 200
except OSError:
pass
body = request.get_data()
if len(body) != expected_len:
return jsonify({
'error': f'chunk at offset {offset} must be {expected_len} bytes, got {len(body)}'
}), 400
if hashlib.sha256(body).hexdigest() != sha256:
return jsonify({'error': f'chunk at offset {offset} failed its checksum'}), 400
payload = os.path.join(tdir, 'payload')
try:
# r+b keeps the bytes already written; the file is created at init.
with open(payload, 'r+b') as f:
f.seek(offset)
f.write(body)
except OSError as e:
return jsonify({'error': f'failed to store chunk: {e}'}), 500
# Marker last: a chunk is only "received" once its bytes are on disk, so a
# crash mid-write costs one re-sent chunk rather than a corrupt payload.
with open(marker, 'w') as f:
f.write(sha256)
return jsonify({'received': _received_offsets(tdir), 'duplicate': False}), 200
@localkit_bp.route('/push/<kind>/finish', methods=['POST'])
@admin_required
def push_finish(kind):
"""Verify the assembled payload and run the v1 processing path on it."""
if kind not in ('code', 'db'):
return jsonify({'error': 'Unknown transfer kind'}), 404
svc, err = _require_wp_service()
if err:
return err
data = request.get_json(silent=True) or {}
meta, tdir = _load_meta(data.get('transfer_id'))
if not meta or meta.get('kind') != kind:
return jsonify({'error': 'Unknown or expired transfer'}), 404
# Missing chunks are a resumable condition, not a failure: report what is
# here so the client can send the rest and call finish again.
expected = list(range(0, meta['total_bytes'], meta['chunk_size']))
received = _received_offsets(tdir)
missing = [o for o in expected if o not in received]
if missing:
return jsonify({
'error': f'{len(missing)} chunk(s) are still missing',
'received': received,
'missing': missing,
}), 409
payload = os.path.join(tdir, 'payload')
digest = hashlib.sha256()
try:
with open(payload, 'rb') as f:
for block in iter(lambda: f.read(1024 * 1024), b''):
digest.update(block)
size = os.path.getsize(payload)
except OSError as e:
_discard_transfer(tdir)
return jsonify({'error': f'failed to read the assembled upload: {e}'}), 500
if size != meta['total_bytes'] or digest.hexdigest() != meta['sha256']:
# Unrecoverable: the bytes on disk are not the bytes the client meant
# to send, and no amount of resuming fixes that. Start over.
_discard_transfer(tdir)
return jsonify({
'error': 'The assembled upload failed its checksum — nothing was applied.'
}), 400
site, err = _resolve_site(meta.get('site_id'))
if err:
_discard_transfer(tdir)
return err
# Processing only ever runs here, on verified bytes — which is why an
# abandoned transfer can never leave the site half-updated.
if kind == 'code':
result, status = _install_code(svc, site, payload)
else:
result, status = _import_db(svc, site, payload, meta.get('local_url'))
# A processing failure keeps the transfer: the upload was fine, so a retry
# should resume straight to finish instead of re-sending the whole payload.
if status == 200:
_discard_transfer(tdir)
return jsonify(result), status
@localkit_bp.route('/pull/db', methods=['GET'])
@admin_required
def pull_db():
"""Stream a gzipped SQL dump of the site's database.
``?session=`` (plan 19) pins the export for the life of a resumable
download; without it the behavior is exactly v1's export-and-delete.
"""
site, err = _resolve_site(request.args.get('site_id'))
if err:
return err
compose_file = _compose_file(site)
if not compose_file:
return jsonify({'error': 'Not a Docker-stack site (no docker-compose.yml)'}), 409
session = request.args.get('session')
download_name = f'{site.application.name}-db.sql.gz'
cached = _download_cache(session, 'db', site.id)
if cached and os.path.exists(cached) and os.path.getsize(cached) > 0:
# A resumed range request: serve the same bytes the client started on.
return _serve_export(cached, download_name, session)
if cached:
dump_path = cached
else:
fd, dump_path = tempfile.mkstemp(prefix='localkit_pull_', suffix='.sql.gz')
os.close(fd)
result = DatabaseSyncService.export_from_container(
compose_path=compose_file,
db_name='wordpress',
db_user='root',
db_password=_read_env_value(site.application.root_path, 'DB_PASSWORD'),
output_path=dump_path,
compress=True,
)
if not result.get('success'):
try:
os.remove(dump_path)
except OSError:
pass
return jsonify({'error': f"Database export failed: {result.get('error')}"}), 500
return _serve_export(dump_path, download_name, session)
@localkit_bp.route('/pull/code', methods=['GET'])
@admin_required
def pull_code():
"""Stream a tar.gz of the site's ``wp-content`` directory.
The mirror image of ``POST /push/code``, and the missing half LocalKit's
"import this site as a new local site" flow needs. The archive is built
*inside* the container (``docker exec ... tar czf -``) rather than from the
host: the site's files are container-owned, and tarring in place keeps
permissions and directory structure intact. Entries are prefixed
``wp-content/``, exactly like the archive push/code accepts, so the two
directions share one format.
``?session=`` (plan 19) pins the archive for the life of a resumable
download — ranges taken from two different ``tar czf`` runs of a live site
would not assemble into anything valid.
"""
site, err = _resolve_site(request.args.get('site_id'))
if err:
return err
container = site.application.name
if not container:
return jsonify({'error': 'Site has no container'}), 409
session = request.args.get('session')
download_name = f'{container}-wp-content.tar.gz'
cached = _download_cache(session, 'code', site.id)
if cached and os.path.exists(cached) and os.path.getsize(cached) > 0:
return _serve_export(cached, download_name, session)
if cached:
archive_path = cached
fd = os.open(archive_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
else:
fd, archive_path = tempfile.mkstemp(prefix='localkit_pullcode_', suffix='.tar.gz')
def _discard():
try:
os.remove(archive_path)
except OSError:
pass
try:
with os.fdopen(fd, 'wb') as out:
proc = subprocess.run(
['docker', 'exec', container, 'tar', 'czf', '-',
'-C', '/var/www/html', 'wp-content'],
stdout=out, stderr=subprocess.PIPE, timeout=900,
)
except (subprocess.SubprocessError, OSError) as e:
_discard()
return jsonify({'error': f'wp-content export failed: {e}'}), 500
# GNU tar exits 1 for "file changed as we read it" — a live site writing
# cache/uploads mid-archive is normal and the archive is still usable.
stderr = (proc.stderr or b'').decode('utf-8', 'replace').strip()
if proc.returncode not in (0, 1) or os.path.getsize(archive_path) == 0:
_discard()
return jsonify({
'error': f'wp-content export failed: {stderr or "is the site running?"}'
}), 500
return _serve_export(archive_path, download_name, session)
@@ -1,14 +0,0 @@
{
"name": "serverkit-localkit",
"display_name": "LocalKit Bridge",
"version": "0.2.0",
"description": "API surface for the LocalKit desktop app: pair with an API key, list/create WordPress sites (API-key friendly), and push/pull wp-content code and database dumps between local and server sites.",
"author": "ServerKit",
"license": "MIT",
"category": "integration",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": ["filesystem", "network", "docker", "database"],
"min_panel_version": "1.7.0",
"entry_point": "localkit:localkit_bp",
"url_prefix": "/api/v1/localkit"
}
@@ -64,7 +64,7 @@ def update_page(page_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = request.get_json() or {}
page = StatusPageService.update_page(page_id, data)
if not page:
return jsonify({'error': 'Not found'}), 404
@@ -99,7 +99,7 @@ def create_component(page_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = request.get_json() or {}
try:
comp = StatusPageService.create_component(page_id, data)
return jsonify(comp.to_dict()), 201
@@ -113,7 +113,7 @@ def update_component(comp_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = request.get_json() or {}
comp = StatusPageService.update_component(comp_id, data)
if not comp:
return jsonify({'error': 'Not found'}), 404
@@ -182,7 +182,7 @@ def update_incident(incident_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = request.get_json() or {}
incident = StatusPageService.update_incident(incident_id, data)
if not incident:
return jsonify({'error': 'Not found'}), 404
@@ -2,7 +2,7 @@
"name": "serverkit-status",
"display_name": "Status Pages",
"version": "1.0.0",
"description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. Installing the extension serves the public /status/<slug> and /status/badge/<slug> pages too.",
"description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. The public /status/<slug> page itself stays core.",
"author": "ServerKit",
"license": "MIT",
"category": "monitoring",
+41 -4
View File
@@ -611,20 +611,57 @@ activate_backend_venv() {
fi
}
# Hash of requirements.txt, or empty if it cannot be read. Uses the venv's own
# python so there is no dependency on sha256sum/shasum being installed.
backend_requirements_hash() {
local py="$1"
"$py" - "$BACKEND_DIR/requirements.txt" <<'PY' 2>/dev/null
import hashlib, sys
try:
with open(sys.argv[1], 'rb') as fh:
print(hashlib.sha256(fh.read()).hexdigest())
except OSError:
pass
PY
}
ensure_backend_packages() {
if python_command_works "$(backend_python)" && "$(backend_python)" - <<'PY' >/dev/null 2>&1
local py marker want have
py="$(backend_python)"
marker="$BACKEND_VENV_DIR/.requirements-sha256"
# Two independent reasons to install. The import probe catches a venv that
# is missing or broken; the hash catches the case it cannot see -- a NEW
# requirement added since the last install, where all four probe imports
# still succeed and the app dies on the fifth. That was a recurring
# session-derailer: a dependency lands, dev.sh reports nothing, and the
# backend boots straight into ModuleNotFoundError.
if python_command_works "$py" && "$py" - <<'PY' >/dev/null 2>&1
import dotenv
import flask
import flask_socketio
import sqlalchemy
PY
then
return 0
want="$(backend_requirements_hash "$py")"
have=""
[ -f "$marker" ] && have="$(cat "$marker" 2>/dev/null)"
# An unreadable requirements.txt yields an empty hash; treat that as
# "cannot tell" and leave a working venv alone rather than reinstalling
# on every start.
if [ -z "$want" ] || [ "$want" = "$have" ]; then
return 0
fi
echo -e "${YELLOW}requirements.txt changed since the last install; syncing...${NC}"
fi
echo -e "${YELLOW}Installing backend Python dependencies in WSL venv...${NC}"
"$(backend_python)" -m pip install --upgrade pip
"$(backend_python)" -m pip install -r "$BACKEND_DIR/requirements.txt"
"$py" -m pip install --upgrade pip
"$py" -m pip install -r "$BACKEND_DIR/requirements.txt"
# Only stamp after a successful install, so a failed one retries next time.
want="$(backend_requirements_hash "$py")"
[ -n "$want" ] && printf '%s\n' "$want" > "$marker"
}
start_backend_process() {
-41
View File
@@ -27,16 +27,6 @@ Regenerate (backend/):
- `DELETE /backups/policies/{target_type}/{target_id}/runs/{run_id}`
- `DELETE /backups/schedules/{schedule_id}`
- `DELETE /builds/apps/{app_id}/build-config`
- `DELETE /cloudflare/zones/{zone_id}/origin-certificates/{certificate_id}`
- `DELETE /cloudflare/zones/{zone_id}/rules/{slug}/rulesets/{ruleset_id}/rules/{rule_id}`
- `DELETE /cloudflare/zones/{zone_id}/storage/d1/{database_id}`
- `DELETE /cloudflare/zones/{zone_id}/storage/kv/{namespace_id}`
- `DELETE /cloudflare/zones/{zone_id}/storage/r2/{bucket}`
- `DELETE /cloudflare/zones/{zone_id}/tunnels/{tunnel_id}`
- `DELETE /cloudflare/zones/{zone_id}/tunnels/{tunnel_id}/hostnames`
- `DELETE /cloudflare/zones/{zone_id}/waf/rulesets/{ruleset_id}/rules/{rule_id}`
- `DELETE /cloudflare/zones/{zone_id}/workers/routes/{route_id}`
- `DELETE /cloudflare/zones/{zone_id}/workers/{name}`
- `DELETE /connections/providers/{kind}/{connection_id}`
- `DELETE /connections/registries/{registry_id}`
- `DELETE /cron/jobs/{job_id}`
@@ -225,19 +215,6 @@ Regenerate (backend/):
- `GET /builds/apps/{app_id}/nixpacks-plan`
- `GET /builds/deployments/{deployment_id}`
- `GET /builds/deployments/{deployment_id}/diff`
- `GET /cloudflare/zones/{zone_id}/activity`
- `GET /cloudflare/zones/{zone_id}/dnssec`
- `GET /cloudflare/zones/{zone_id}/origin-certificates`
- `GET /cloudflare/zones/{zone_id}/rules/{slug}`
- `GET /cloudflare/zones/{zone_id}/scope-check`
- `GET /cloudflare/zones/{zone_id}/settings`
- `GET /cloudflare/zones/{zone_id}/settings/{setting_id}`
- `GET /cloudflare/zones/{zone_id}/storage`
- `GET /cloudflare/zones/{zone_id}/tunnels`
- `GET /cloudflare/zones/{zone_id}/tunnels/{tunnel_id}/hostnames`
- `GET /cloudflare/zones/{zone_id}/tunnels/{tunnel_id}/install`
- `GET /cloudflare/zones/{zone_id}/waf/rules`
- `GET /cloudflare/zones/{zone_id}/workers`
- `GET /connections`
- `GET /connections/`
- `GET /connections/providers`
@@ -739,10 +716,6 @@ Regenerate (backend/):
- `GET /workspaces/{workspace_id}/members`
- `PATCH /admin/sites-https/base-domains/<path:domain>`
- `PATCH /ai/conversations/{conversation_id}`
- `PATCH /cloudflare/zones/{zone_id}/dnssec`
- `PATCH /cloudflare/zones/{zone_id}/rules/{slug}/rulesets/{ruleset_id}/rules/{rule_id}`
- `PATCH /cloudflare/zones/{zone_id}/settings/{setting_id}`
- `PATCH /cloudflare/zones/{zone_id}/waf/rulesets/{ruleset_id}/rules/{rule_id}`
- `PATCH /monitors/incidents/{incident_id}`
- `PATCH /monitors/{monitor_id}`
- `PATCH /queue/groups/{group_slug}`
@@ -847,20 +820,6 @@ Regenerate (backend/):
- `POST /builds/apps/{app_id}/clear-cache`
- `POST /builds/apps/{app_id}/deploy`
- `POST /builds/apps/{app_id}/rollback`
- `POST /cloudflare/zones/{zone_id}/origin-certificates`
- `POST /cloudflare/zones/{zone_id}/purge-cache`
- `POST /cloudflare/zones/{zone_id}/rules/{slug}`
- `POST /cloudflare/zones/{zone_id}/rules/{slug}/presets/{preset_key}`
- `POST /cloudflare/zones/{zone_id}/settings/apply-preset`
- `POST /cloudflare/zones/{zone_id}/storage/d1`
- `POST /cloudflare/zones/{zone_id}/storage/kv`
- `POST /cloudflare/zones/{zone_id}/storage/r2`
- `POST /cloudflare/zones/{zone_id}/tunnels`
- `POST /cloudflare/zones/{zone_id}/tunnels/{tunnel_id}/hostnames`
- `POST /cloudflare/zones/{zone_id}/waf/presets/{preset_key}`
- `POST /cloudflare/zones/{zone_id}/waf/rules`
- `POST /cloudflare/zones/{zone_id}/workers`
- `POST /cloudflare/zones/{zone_id}/workers/routes`
- `POST /connections/providers/{kind}/validate`
- `POST /connections/providers/{kind}/{connection_id}/rotate`
- `POST /connections/providers/{kind}/{connection_id}/test`
+1 -1
View File
@@ -13,7 +13,7 @@ debt. Regenerate with `python scripts/generate-migration-inventory.py`.
| Concern | Door | Remaining | Ceiling | Policy |
|---|---|---:|---:|---|
| Identity lookups outside rbac.get_current_user() | rbac.get_current_user() | 69 | 69 | JWT-only routes; the API-key-capable population is held at 0 by a companion assertion |
| Routes on bare @jwt_required() | auth_required() / role decorators | 573 | 577 | REGISTERED EXCEPTION: JWT-only is the deliberate default; conversion grants API-key access and happens per route, on decision |
| Routes on bare @jwt_required() | auth_required() / role decorators | 608 | 608 | REGISTERED EXCEPTION: JWT-only is the deliberate default; conversion grants API-key access and happens per route, on decision |
| HTTP statuses chosen by sniffing error text | typed errors from app.exceptions | 0 | 0 | INVARIANT at 0 - migration completed 2026-08-19 |
| API crashes swallowed without recording | app.error_reporting | 0 | 0 | INVARIANT at 0 |
| Hand-shaped {'error': ...} bodies in app/api | typed errors + the global handler | 1139 | 1150 | migrate when touched; new endpoints raise |
+1 -1
View File
@@ -1 +1 @@
123
115
@@ -83,10 +83,14 @@ const SetupStepSummary = ({ accountInfo, useCases, twoFactorEnabled, onFinish })
// with per-item progress. Fail-soft: an install failure is surfaced
// but never blocks completing onboarding.
const postureExts = (postures[securityPosture] || []);
const seen = new Set(recommendations.map((r) => r.slug));
// Posture extensions install regardless of the recommendation
// checkboxes — the posture choice is the authorization. Overlapping
// slugs are dropped from the recommendations half, not the posture
// half, so unchecking a recommendation can't veto the posture.
const postureSlugs = new Set(postureExts.map((r) => r.slug));
const queue = recommendations
.filter((r) => checked.has(r.slug) && !r.installed)
.concat(postureExts.filter((r) => !r.installed && !seen.has(r.slug)));
.filter((r) => checked.has(r.slug) && !r.installed && !postureSlugs.has(r.slug))
.concat(postureExts.filter((r) => !r.installed));
const installedSlugs = recommendations
.filter((r) => r.installed && checked.has(r.slug))
.map((r) => r.slug);
-375
View File
@@ -586,72 +586,6 @@
"serverName": "اسم الخادم",
"serverProvisioningInitiated": "بدأ توفير الخادم"
},
"cloudflareWafPanel": {
"activeRules": "القواعد النشطة (",
"addAQuickRuleOrA": "أضف قاعدة سريعة أو قاعدة مخصصة أعلاه.",
"addCustomRule": "إضافة قاعدة مخصصة",
"addRule": "أضف القاعدة",
"addedQuickRule": "تمت إضافة قاعدة سريعة: {{label}}",
"applyACuratedCloudflareRuleWithout": "قم بتطبيق قاعدة Cloudflare منسقة دون كتابة تعبير يدويًا.",
"block": "حاجز",
"blockAdminFromOutsideTheOffice": "منع المشرف من خارج المكتب",
"chooseAQuickRule": "اختر قاعدة سريعة...",
"customRule": "قاعدة مخصصة",
"customRuleAdded": "تمت إضافة القاعدة المخصصة",
"expression": "تعبير",
"firewallRulesUnavailable": "قواعد جدار الحماية غير متوفرة",
"jsChallenge": "تحدي شبيبة",
"loadingFirewallRules": "جارٍ تحميل قواعد جدار الحماية…",
"log": "سجل",
"managedChallenge": "التحدي المدار",
"noCustomFirewallRules": "لا توجد قواعد جدار الحماية المخصصة",
"quickRules": "قواعد سريعة",
"ruleDeleted": "تم حذف القاعدة",
"writeYourOwnCloudflareFirewallExpression": "اكتب تعبير جدار الحماية Cloudflare الخاص بك للتحكم الكامل."
},
"cloudflareZoneSettings": {
"1Day": "يوم واحد",
"1Month": "شهر واحد",
"1Week": "1 أسبوع",
"1Year": "1 سنة",
"2Years": "سنتان",
"6Months": "6 أشهر",
"appliedNeedAHigherCloudflarePlan": "تطبيق {{applied}}/{{total}}. يحتاج {{length}} إلى خطة Cloudflare أعلى.",
"appliedRecommendedHardeningSettings": "التصلب المطبق الموصى به (إعدادات {{applied}})",
"applyRecommendedHardening": "تطبيق تصلب الموصى بها",
"backToDnsZones": "العودة إلى مناطق DNS",
"changingCloudflareSettingsRequiresAnAdmin": "يتطلب تغيير إعدادات Cloudflare حساب مسؤول.",
"clearCloudflareSCachedCopyOf": "قم بمسح النسخة المخزنة مؤقتًا لموقعك Cloudflare حتى يحصل الزائرون على محتوى جديد. قم بإزالة كل شيء، أو قم بإدراج URLs المحددة أدناه.",
"clearCloudflareSEntireCacheFor": "هل تريد مسح ذاكرة التخزين المؤقت الكاملة لـ Cloudflare لـ {{value}}؟",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "إعدادات Cloudflare غير متاحة",
"dnsZones": "مناطق DNS",
"enabled": "ممكّن",
"enterAtLeastOneUrlTo": "أدخل URL واحدًا على الأقل للتطهير",
"failedToApplyPreset": "فشل في تطبيق الإعداد المسبق",
"failedToPurgeCache": "فشل في مسح ذاكرة التخزين المؤقت",
"failedToPurgeUrls": "فشل في إزالة URLs",
"failedToSaveSetting": "فشل حفظ الإعداد",
"includeSubdomains": "تضمين النطاقات الفرعية",
"itReCachesThisIsSafe": "يقوم بإعادة التخزين المؤقت. هذا آمن ولكن يمكن أن يزيد الحمل الأصلي لفترة وجيزة.",
"lockedByYourCloudflarePlan": "مقفل بخطة Cloudflare الخاصة بك",
"maxAge": "الحد الأقصى للعمر",
"notAvailableOnThisZone": "غير متوفر في هذه المنطقة",
"off": "عن",
"preload": "التحميل المسبق",
"purgeCache": "تطهير ذاكرة التخزين المؤقت",
"purgeEntireCache": "تطهير ذاكرة التخزين المؤقت بأكملها",
"purgeEverything": "تطهير كل شيء",
"purgeSpecificUrlsOnePerLine": "تطهير URLs محددة (واحدة لكل سطر، حتى 30)",
"purgeUrls": "تطهير URLs",
"purgedTheEntireCache": "مسح ذاكرة التخزين المؤقت بأكملها",
"purgedUrlFromCache": "تمت إزالة {{n}} URL{{value}} من ذاكرة التخزين المؤقت",
"setsFullStrictSslAlwaysUse": "يضبط SSL بالكامل (صارم)، استخدم دائمًا HTTPS، HSTS (6 أشهر)، طابق TLS 1.2 مع TLS 1.3، Brotli، HTTP/3، وذاكرة تخزين مؤقت للمتصفح لمدة 4 ساعات. يتم تخطي الإعدادات التي لا تسمح بها خطتك.",
"settingSaved": "تم حفظ الإعداد",
"theNextVisitToEachPage": "سيتم تقديم الزيارة التالية لكل صفحة من أصلك حتى",
"tunnels": "الأنفاق",
"workers": "العمال"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "سيتم إنشاء CNAME لاسم المضيف هذا في Cloudflare DNS، مع الإشارة إلى النفق.",
"askingTheAgentToStartThe": "مطالبة الوكيل ببدء تدفق تسجيل الدخول إلى Cloudflare...",
@@ -1785,76 +1719,6 @@
"aiSettings": "إعدادات الذكاء الاصطناعي",
"serverkitAi": "ServerKit منظمة العفو الدولية"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "يقوم الاختبار المبدئي لإمكانية التسليم (PTR، المنفذ 25، RBL) ببوابات الإرسال للخارج قبل أن يتمكن البريد من مغادرة هذا المضيف.",
"accountCreated": "تم إنشاء الحساب",
"accountDeleted": "تم حذف الحساب",
"accounts": "الحسابات",
"addAMailDomainFirst": "قم بإضافة مجال بريد أولاً.",
"addDomain": "أضف المجال",
"address": "عنوان",
"component": "عنصر",
"couldNotAddDomain": "لا يمكن إضافة المجال",
"createFailed": "فشل الإنشاء",
"delete3": "هل تريد حذف {{email}}؟",
"deleteAccount": "حذف الحساب",
"deleteAndAllOfItsAccounts": "هل تريد حذف {{name}} وجميع حساباته وأسماءه المستعارة؟",
"deleteDomain": "حذف المجال",
"deleteFailed": "فشل الحذف",
"deployDns": "نشر DNS",
"deployFailedNoLinkedDnsProvider": "فشل النشر (لا يوجد موفر DNS مرتبط؟)",
"dkimSelector": "محدد DKIM:",
"dnsCheckFailed": "فشل التحقق من DNS",
"dnsChecked": "تم فحص DNS",
"dnsRecordsDeployedToProvider": "سجلات DNS التي تم نشرها للموفر",
"domainAdded": "تمت إضافة المجال",
"domainDeleted": "تم حذف المجال",
"emailServerInstallStarted": "بدأ تثبيت خادم البريد الإلكتروني",
"enableRelay": "تمكين التتابع",
"enableSpamFiltering": "تمكين تصفية البريد العشوائي",
"failedTo": "فشل في {{action}} {{component}}",
"failedToDisableRelay": "فشل في تعطيل التتابع",
"failedToWebmail": "فشل في بريد الويب {{action}}",
"flushFailed": "فشل التدفق",
"flushQueue": "طابور دافق",
"host": "يستضيف",
"installFailed": "فشل التثبيت",
"installMailServer": "تثبيت خادم البريد",
"installWebmail": "تثبيت بريد الويب",
"mailServerNotInstalled": "خادم البريد غير مثبت",
"mailboxPassword": "كلمة مرور صندوق البريد",
"mailboxUsername": "اسم مستخدم صندوق البريد",
"mostPanelsNeverRunAMail": "لا تقوم معظم اللوحات مطلقًا بتشغيل خادم بريد، بل قم بتثبيته عندما تحتاج إليه.",
"newMailDomain": "مجال البريد الجديد",
"noMailDomainsYet": "لا توجد مجالات البريد حتى الآن.",
"outboundRelaySmarthost": "التتابع الصادر (المضيف الذكي)",
"queue": "طابور",
"queueFlushed": "مسح قائمة الانتظار",
"quota": "الحصة النسبية",
"quotaInMb": "الحصة بالميجابايت",
"quotaMb": "الحصة (ميجابايت)",
"recipient": "متلقي",
"relayDisabled": "تم تعطيل التتابع",
"relayReachable": "يمكن الوصول إلى التتابع",
"relaySaved": "تم حفظ التتابع",
"relayTestFailed": "فشل اختبار التتابع",
"requiredScore": "النتيجة المطلوبة",
"roundcubeWebmail": "بريد الويب Roundcube",
"ruleUpdateFailed": "فشل تحديث القاعدة",
"ruleUpdateStarted": "بدأ تحديث القاعدة",
"saveFailed": "فشل الحفظ",
"searchAccounts": "حسابات البحث",
"searchAccountsByAddress": "البحث عن الحسابات حسب العنوان...",
"searchQueueBySenderRecipientOr": "قائمة انتظار البحث حسب المرسل أو المستلم أو المعرف...",
"sender": "مرسل",
"spamConfigSaved": "تم حفظ تكوين البريد العشوائي",
"spamassassin": "قاتل البريد العشوائي",
"updateRules": "تحديث القواعد",
"used": "مستخدم",
"webmail": "بريد الويب: {{action}}",
"webmailInstallStarted": "بدأ تثبيت بريد الويب",
"webmailIsNotInstalled": "لم يتم تثبيت بريد الويب."
},
"emailProviders": {
"addedButTestFailed": "تمت الإضافة ولكن فشل الاختبار: {{value}}",
"confirm": "يتأكد",
@@ -2404,193 +2268,6 @@
"empty": "فارغ",
"expand": "يوسع"
},
"git": {
"512mbMinimum1gbRecommended": "~512 ميجا بايت كحد أدنى (يوصى بـ 1 جيجا بايت)",
"5gbForDatabaseRepositories": "~ 5 جيجابايت لقاعدة البيانات + المستودعات",
"access": "وصول",
"accessInformation": "الوصول إلى المعلومات",
"accessRepositories": "الوصول إلى المستودعات",
"active": "نشيط",
"addAWebhookToSyncRepositories": "قم بإضافة خطاف ويب لمزامنة المستودعات من مصادر خارجية.",
"addWebhook": "إضافة خطاف ويب",
"addYourPublicKeyInGitea": "أضف مفتاحك العام في Gitea",
"adminEmail": "البريد الإلكتروني المشرف",
"adminEmailIsRequired": "البريد الإلكتروني المشرف مطلوب",
"adminPasswordLeaveEmptyToAuto": "كلمة مرور المسؤول (اتركها فارغة للإنشاء التلقائي)",
"adminPasswordSaveThis": "كلمة مرور المسؤول: {{adminpassword}} (احفظ هذا!)",
"adminUsername": "اسم المستخدم المشرف",
"application": "طلب",
"areYouSureYouWantTo": "هل أنت متأكد أنك تريد حذف خطاف الويب هذا؟ لا يمكن التراجع عن هذا.",
"autoGenerateSecurePassword": "إنشاء كلمة مرور آمنة تلقائيًا",
"autoSyncOnPushEvents": "المزامنة التلقائية لأحداث الدفع",
"automaticallyDeployWhenCodeIsPushed": "يتم النشر تلقائيًا عند دفع الكود إلى الفرع",
"bidirectional": "ثنائي الاتجاه",
"bitbucket": "بيتبوكيت",
"branch7": "فرع:",
"branches": "الفروع",
"cloneRepositoriesViaSsh": "مستودعات النسخ عبر SSH",
"commit": "يقترف",
"commit2": "يقترف:",
"commitMessage": "رسالة الالتزام",
"commits": "يرتكب",
"components": "عناصر:",
"configureAWebhookWithDeployOn": "قم بتكوين خطاف ويب مع تمكين \"النشر عند الدفع\" لرؤية عمليات النشر هنا.",
"configureWebhooks": "تكوين خطافات الويب",
"copySshUrl": "انسخ SSH URL",
"copyUrl": "انسخ URL",
"createYourFirstRepositoryInGitea": "قم بإنشاء مستودعك الأول في Gitea.",
"dangerZone": "منطقة الخطر",
"defaultBranch": "الفرع الافتراضي",
"deleteWebhook": "حذف خطاف الويب",
"deployOnPush": "نشر على الدفع",
"deployToApplication": "نشر إلى التطبيق",
"deployment": "النشر",
"deploymentHistory": "تاريخ النشر",
"deploymentOutput": "إخراج النشر",
"deploymentSettings": "إعدادات النشر",
"deploymentStartedV": "بدأ النشر: v{{version}}",
"deploymentV": "نشر v{{version}}",
"deploymentVLogs": "نشر سجلات v{{version}}",
"deployments": "عمليات النشر",
"duration": "مدة:",
"error": "خطأ",
"error2": "خطأ:",
"failedToCreateWebhook": "فشل إنشاء الرد التلقائي على الويب",
"failedToCreateWebhook2": "فشل إنشاء خطاف الويب: {{message}}",
"failedToDelete": "فشل الحذف: {{message}}",
"failedToDeploy": "فشل النشر: {{message}}",
"failedToInstall": "فشل التثبيت: {{message}}",
"failedToLoadDeploymentDetails": "فشل تحميل تفاصيل النشر",
"failedToLoadDirectory": "فشل تحميل الدليل",
"failedToLoadLogs": "فشل تحميل السجلات: {{message}}",
"failedToLoadMoreCommits": "فشل في تحميل المزيد من الالتزامات",
"failedToLoadRepositoryDetails": "فشل في تحميل تفاصيل المستودع",
"failedToStart": "فشل البدء: {{message}}",
"failedToStop": "فشل في التوقف: {{message}}",
"failedToSwitchBranch": "فشل في تبديل الفرع",
"failedToToggleWebhook": "فشل تبديل خطاف الويب: {{message}}",
"failedToTriggerDeployment": "فشل في تشغيل النشر",
"failedToUninstall": "فشل في إلغاء التثبيت: {{message}}",
"files": "ملفات",
"forks": "شوك",
"gitServerInstalledSuccessfully": "تم تثبيت خادم Git بنجاح",
"gitServerStarted": "بدأ خادم Git",
"gitServerStopped": "توقف خادم Git",
"gitServerUninstalled": "تم إلغاء تثبيت خادم Git",
"giteaGitServer300mbRam": "Gitea (خادم Git) - ذاكرة وصول عشوائي سعة 300 ميجابايت تقريبًا",
"giteaPostgresqlDatabase": "قاعدة بيانات Gitea + PostgreSQL",
"giteaServerIsNotRunning": "خادم Gitea لا يعمل",
"giteaVersion": "نسخة Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "الوصول إلى HTTP",
"httpUrl": "HTTP URL",
"iVeSavedIt": "لقد حفظته",
"inactive": "غير نشط",
"installGitServer": "قم بتثبيت خادم Git",
"installGiteaToHostAndManage": "قم بتثبيت Gitea لاستضافة وإدارة مستودعات Git محليًا.",
"installationFailed": "فشل التثبيت",
"lastResult": "النتيجة الأخيرة",
"lastSync": "آخر مزامنة",
"leaveEmptyToUseSameName": "اتركه فارغا لاستخدام نفس الاسم",
"loadMore": "تحميل المزيد",
"loadingDeployments": "جارٍ تحميل عمليات النشر",
"loadingGit": "جاري تحميل Git",
"loadingRepositories": "تحميل المستودعات",
"loadingWebhooks": "جارٍ تحميل خطافات الويب",
"localRepositoryNameOptional": "اسم المستودع المحلي (اختياري)",
"manual": "يدوي",
"memory": "ذاكرة:",
"myGithubRepo": "بلدي GitHub الريبو",
"nameAndRepositoryUrlAreRequired": "الاسم والمستودع URL مطلوبان",
"noDeploymentLogsAvailable": "لا توجد سجلات نشر متاحة.",
"noDeploymentsForThisWebhook": "لا توجد عمليات نشر لخطاف الويب هذا.",
"noDeploymentsMatchThisFilter": "لا توجد عمليات نشر تتطابق مع عامل التصفية هذا.",
"noDeploymentsYet": "لا توجد عمليات نشر حتى الآن",
"noDescription": "لا يوجد وصف",
"noGitServerInstalled": "لم يتم تثبيت خادم Git",
"noRepositoriesYet": "لا توجد مستودعات حتى الآن",
"noWebhooksConfigured": "لم يتم تكوين خطافات الويب",
"noneSyncOnly": "لا شيء (المزامنة فقط)",
"openGitea": "افتح Gitea",
"openInGitea": "مفتوح في Gitea",
"optionallyDeployAnApplicationWhenCode": "يمكنك اختياريًا نشر تطبيق عند دفع التعليمات البرمجية",
"postDeployScriptOptional": "البرنامج النصي بعد النشر (اختياري)",
"postDeploymentScriptOutput": "إخراج البرنامج النصي بعد النشر",
"postgresqlDatabase200mbRam": "قاعدة بيانات PostgreSQL - ذاكرة وصول عشوائي بسعة 200 ميجابايت تقريبًا",
"preDeployScriptOptional": "البرنامج النصي للنشر المسبق (اختياري)",
"preDeploymentScriptOutput": "إخراج البرنامج النصي قبل النشر",
"private": "خاص",
"pullExternalLocal": "سحب (خارجي → محلي)",
"pushLocalExternal": "الدفع (محلي → خارجي)",
"quickActions": "إجراءات سريعة",
"recentDeployments": "عمليات النشر الأخيرة",
"repositories": "المستودعات",
"repository": "مستودع",
"repositoryUrl": "مستودع URL",
"repositoryUrlCopied": "تم نسخ المستودع URL",
"requiredForSshClonePush": "مطلوب للاستنساخ/الدفع SSH",
"resourceRequirements": "متطلبات الموارد",
"rollback": "التراجع",
"rollbackCompleted": "اكتمل التراجع",
"rollbackDeployment": "نشر التراجع",
"rollbackFailed": "فشل التراجع",
"rollbackFailed2": "فشل الاستعادة: {{message}}",
"running": "جري",
"saveThisSecretItWillNot": "احفظ هذا السر! لن يتم عرضه مرة أخرى.",
"secretCopied": "منسوخ سري",
"serverNotRunning": "الخادم لا يعمل",
"serverOverview": "نظرة عامة على الخادم",
"serverPower": "قوة الخادم",
"serverSettings": "إعدادات الخادم",
"serverStatus": "حالة الخادم",
"sshAccess": "الوصول إلى SSH",
"sshCloneUrl": "SSH استنساخ URL",
"sshKeyRequirement": "الشرط الرئيسي SSH",
"sshPort": "منفذ SSH",
"sshUrlCopied": "SSH URL منقول",
"stars": "النجوم",
"startOrStopTheGiteaContainer": "ابدأ أو أوقف حاوية Gitea",
"startServer": "بدء تشغيل الخادم",
"startTheGitServerToBrowse": "قم بتشغيل خادم Git لتصفح المستودعات.",
"status5": "حالة:",
"stopGitServer": "إيقاف خادم Git",
"stopServer": "إيقاف الخادم",
"stopTheGitServer": "هل تريد إيقاف خادم Git؟",
"stopsGiteaDataIsPreservedUnless": "توقف Gitea؛ يتم الاحتفاظ بالبيانات ما لم تتم إزالتها يدويًا",
"storage": "تخزين:",
"success": "نجاح",
"switch": "يُحوّل",
"syncDirection": "اتجاه المزامنة",
"syncs": "المزامنات",
"testEventLogged": "تم تسجيل حدث الاختبار",
"testFailed": "فشل الاختبار",
"testFailed2": "فشل الاختبار: {{message}}",
"thisWillInstall": "سيتم تثبيت هذا:",
"triggeredBy": "تم التشغيل بواسطة:",
"uninstall": "إلغاء التثبيت",
"uninstallGitServer": "قم بإلغاء تثبيت خادم Git",
"uninstallTheGiteaServerThisWill": "إلغاء تثبيت خادم Gitea؟ سيؤدي هذا إلى إيقاف الحاوية مع الحفاظ على بياناتك.",
"uninstallingWillStopTheGiteaContainer": "ستؤدي عملية إلغاء التثبيت إلى إيقاف حاوية Gitea. سيتم الاحتفاظ ببياناتك ما لم تختر إزالتها.",
"updated": "تم التحديث",
"updated7d": "تم التحديث ≥7d",
"urlCopied": "تم نسخ URL",
"urlPath": "مسار URL",
"useThisSecretWhenConfiguringThe": "استخدم هذا السر عند تكوين خطاف الويب في إعدادات المستودع الخاص بك.",
"useYourSshKey": "استخدم مفتاح SSH الخاص بك",
"viewLogs": "عرض السجلات",
"visibility": "الرؤية",
"webBrowserAccess": "الوصول إلى متصفح الويب",
"webInterface": "واجهة الويب",
"webhook": "خطاف ويب",
"webhookCreatedSuccessfully": "تم إنشاء Webhook بنجاح",
"webhookDeleted": "تم حذف الرد التلقائي على الويب",
"webhookSecret": "سر الويب هوك",
"webhookUrlCopied": "تم نسخ Webhook URL",
"webhooks": "خطافات الويب",
"zeroDowntime": "صفر التوقف",
"zeroDowntimeDeployment": "نشر بدون توقف"
},
"gitProviders": {
"anyGitRemote": "أي جهاز تحكم عن بعد Git",
"bitbucket": "بيتبوكيت",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "لقطات"
},
"storagePanel": {
"createdBucket": "تم إنشاء الجرافة \"{{name}}\"",
"createdDatabase": "تم إنشاء قاعدة البيانات \"{{name}}\"",
"createdNamespace": "مساحة الاسم التي تم إنشاؤها \"{{title}}\"",
"d1Databases": "قواعد البيانات D1",
"deleted": "تم الحذف",
"kvNamespaces": "مساحات الأسماء KV",
"loadingStorage": "جارٍ تحميل وحدة التخزين…",
"r2Buckets": "دلاء R2",
"r2IsS3CompatibleSoA": "R2 متوافق مع S3، لذلك يمكن للحاوية الموجودة هنا دعم النسخ الاحتياطية ServerKit لاحقًا.",
"storageUnavailable": "مساحة التخزين غير متاحة"
},
"styleGuide": {
"2Found": "تم العثور على 2",
"aNewVersionIsAvailableFor": "إصدار جديد متاح للتحديث.",
@@ -6267,28 +5932,6 @@
"arguments": "الحجج",
"result": "نتيجة"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "يعرض نفق Cloudflare خدمة محلية أو خاصة من خلال حافة Cloudflare - لا يلزم وجود IP عام أو منافذ مفتوحة.",
"cloudflareTunnels": "أنفاق Cloudflare (",
"cloudflareTunnelsUnavailable": "Cloudflare الأنفاق غير متاحة",
"copied": "منقول",
"couldNotCopyTheInstallCommand": "لا يمكن نسخ أمر التثبيت",
"createACloudflareTunnel": "قم بإنشاء نفق Cloudflare",
"createOneAboveToExposeA": "قم بإنشاء واحدة أعلاه لكشف خدمة محلية بدون عنوان IP عام.",
"createdTunnel": "تم إنشاء النفق \"{{name}}\"",
"deletedCloudflareTunnel": "تم حذف نفق Cloudflare \"{{name}}\"",
"hostnameRemoved": "تمت إزالة اسم المضيف",
"hostnameRouted": "تم توجيه اسم المضيف",
"hostnames": "أسماء المضيفين",
"installCommand": "أمر التثبيت",
"loadingCloudflareTunnels": "جارٍ تحميل أنفاق Cloudflare...",
"noCloudflareTunnels": "لا توجد أنفاق Cloudflare",
"noInstallCommandAvailable": "لا يوجد أمر التثبيت متاح.",
"noPublicHostnamesRoutedYet": "لم يتم توجيه أي أسماء مضيفين عامة حتى الآن.",
"routeSetButTheDnsRecord": "تم تعيين المسار، ولكن فشل سجل DNS:",
"runThisOnceOnTheMachine": "قم بتشغيل هذا مرة واحدة على الجهاز الذي يستضيف الخدمة المحلية الخاصة بك. يتم عرض الرمز المميز للموصل الآن فقط.",
"theHostnameShouldBeInsideThis": "يجب أن يكون اسم المضيف داخل نطاق هذه المنطقة؛ يتم إنشاء سجل DNS الوكيل تلقائيًا."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "بعد أيام، قد يستمر الحساب المغطى حديثًا في تسجيل الدخول باستخدام كلمة مرور فقط قبل فرض التسجيل.",
"gracePeriodDays": "فترة السماح (أيام)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "أدوات البحث…",
"typesDragToRepositionAfterAdding": "أنواع {{length}} · اسحب لتغيير الموضع بعد الإضافة"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "قم بإرفاق العامل بهذا المجال حتى تقوم حركة المرور المطابقة بتشغيل البرنامج النصي.",
"code": "شفرة",
"deletedWorker": "العامل المحذوف \"{{name}}\"",
"deploy": "نشر",
"deployAWorker": "نشر عامل",
"deployYourFirstEdgeScriptAbove": "انشر برنامج الحافة الأول الخاص بك أعلاه.",
"deployedWorker": "العامل المنشور \"{{name}}\"",
"loadingWorkers": "جارٍ تحميل العمال…",
"noRoutesYetAddARoute": "لا توجد طرق حتى الآن. أضف نمط مسار عند النشر لإرسال حركة المرور إلى العامل.",
"noWorkersDeployed": "لم يتم نشر أي عمال",
"routePatternOptional": "نمط الطريق (اختياري)",
"routeRemoved": "تمت إزالة المسار",
"routes": "الطرق (",
"workerDeployedButTheRouteFailed": "تم نشر العامل، لكن فشل المسار:",
"workers": "العمال (",
"workersUnavailable": "العمال غير متوفرين"
},
"workspaceDetail": {
"accessGranted": "تم منح الوصول",
"accessRevoked": "تم إبطال الوصول",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "সার্ভারের নাম",
"serverProvisioningInitiated": "সার্ভার বিধান শুরু হয়েছে"
},
"cloudflareWafPanel": {
"activeRules": "সক্রিয় নিয়ম (",
"addAQuickRuleOrA": "উপরে একটি দ্রুত নিয়ম বা একটি কাস্টম নিয়ম যোগ করুন।",
"addCustomRule": "কাস্টম নিয়ম যোগ করুন",
"addRule": "নিয়ম যোগ করুন",
"addedQuickRule": "দ্রুত নিয়ম যোগ করা হয়েছে: {{label}}",
"applyACuratedCloudflareRuleWithout": "হাত দ্বারা একটি অভিব্যক্তি না লিখে একটি কিউরেটেড Cloudflare নিয়ম প্রয়োগ করুন।",
"block": "ব্লক",
"blockAdminFromOutsideTheOffice": "অফিসের বাইরে থেকে অ্যাডমিনকে ব্লক করুন",
"chooseAQuickRule": "একটি দ্রুত নিয়ম বেছে নিন...",
"customRule": "কাস্টম নিয়ম",
"customRuleAdded": "কাস্টম নিয়ম যোগ করা হয়েছে",
"expression": "অভিব্যক্তি",
"firewallRulesUnavailable": "ফায়ারওয়াল নিয়ম অনুপলব্ধ",
"jsChallenge": "জেএস চ্যালেঞ্জ",
"loadingFirewallRules": "ফায়ারওয়াল নিয়ম লোড হচ্ছে...",
"log": "লগ",
"managedChallenge": "ম্যানেজড চ্যালেঞ্জ",
"noCustomFirewallRules": "কোনো কাস্টম ফায়ারওয়াল নিয়ম নেই",
"quickRules": "দ্রুত নিয়ম",
"ruleDeleted": "নিয়ম মুছে ফেলা হয়েছে",
"writeYourOwnCloudflareFirewallExpression": "সম্পূর্ণ নিয়ন্ত্রণের জন্য আপনার নিজের Cloudflare ফায়ারওয়াল এক্সপ্রেশন লিখুন।"
},
"cloudflareZoneSettings": {
"1Day": "1 দিন",
"1Month": "1 মাস",
"1Week": "১ সপ্তাহ",
"1Year": "1 বছর",
"2Years": "2 বছর",
"6Months": "6 মাস",
"appliedNeedAHigherCloudflarePlan": "{{applied}}/{{total}} প্রয়োগ করা হয়েছে। {{length}} একটি উচ্চতর Cloudflare পরিকল্পনা প্রয়োজন৷",
"appliedRecommendedHardeningSettings": "প্রয়োগ করা সুপারিশকৃত শক্তকরণ ({{applied}} সেটিংস)",
"applyRecommendedHardening": "প্রস্তাবিত শক্তকরণ প্রয়োগ করুন",
"backToDnsZones": "DNS জোনে ফিরে যান",
"changingCloudflareSettingsRequiresAnAdmin": "Cloudflare সেটিংস পরিবর্তন করার জন্য একটি অ্যাডমিন অ্যাকাউন্ট প্রয়োজন৷",
"clearCloudflareSCachedCopyOf": "আপনার সাইটের Cloudflare এর ক্যাশড কপি সাফ করুন যাতে দর্শকরা নতুন সামগ্রী পান৷ সবকিছু পরিষ্কার করুন, অথবা নিচে নির্দিষ্ট URL তালিকাভুক্ত করুন।",
"clearCloudflareSEntireCacheFor": "{{value}}-এর জন্য Cloudflare-এর সম্পূর্ণ ক্যাশে সাফ করবেন?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Cloudflare সেটিংস অনুপলব্ধ৷",
"dnsZones": "DNS জোন",
"enabled": "সক্রিয়",
"enterAtLeastOneUrlTo": "পরিষ্কার করতে কমপক্ষে একটি URL লিখুন৷",
"failedToApplyPreset": "প্রিসেট প্রয়োগ করতে ব্যর্থ হয়েছে৷",
"failedToPurgeCache": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে৷",
"failedToPurgeUrls": "URLs শুদ্ধ করতে ব্যর্থ হয়েছে৷",
"failedToSaveSetting": "সেটিং সংরক্ষণ করতে ব্যর্থ হয়েছে",
"includeSubdomains": "সাবডোমেন অন্তর্ভুক্ত করুন",
"itReCachesThisIsSafe": "এটা পুনরায় ক্যাশ. এটি নিরাপদ কিন্তু সংক্ষেপে অরিজিন লোড বাড়াতে পারে।",
"lockedByYourCloudflarePlan": "আপনার Cloudflare প্ল্যান দ্বারা লক করা হয়েছে",
"maxAge": "সর্বোচ্চ বয়স",
"notAvailableOnThisZone": "এই জোনে উপলব্ধ নয়",
"off": "বন্ধ",
"preload": "প্রিলোড",
"purgeCache": "ক্যাশে পরিষ্কার করুন",
"purgeEntireCache": "সম্পূর্ণ ক্যাশে পরিষ্কার করুন",
"purgeEverything": "সবকিছু পরিষ্কার করুন",
"purgeSpecificUrlsOnePerLine": "নির্দিষ্ট URLs শুদ্ধ করুন (প্রতি লাইনে একটি, 30 পর্যন্ত)",
"purgeUrls": "URLs শুদ্ধ করুন",
"purgedTheEntireCache": "সম্পূর্ণ ক্যাশে মুছে ফেলা হয়েছে",
"purgedUrlFromCache": "ক্যাশে থেকে {{n}} URL{{value}} মুছে ফেলা হয়েছে",
"setsFullStrictSslAlwaysUse": "সম্পূর্ণ (কঠোর) SSL সেট করে, সর্বদা HTTPS, HSTS (6 মাস), একটি TLS 1.2 ফ্লোর TLS 1.3, Brotli, HTTP/3, ব্রাউজার ব্যবহার করুন। আপনার প্ল্যান যে সেটিংস অনুমোদন করে না সেগুলি বাদ দেওয়া হয়েছে৷",
"settingSaved": "সেটিং সংরক্ষিত",
"theNextVisitToEachPage": "প্রতিটি পৃষ্ঠায় পরবর্তী ভিজিট আপনার মূল থেকে পর্যন্ত পরিবেশিত হবে",
"tunnels": "টানেল",
"workers": "শ্রমিকরা"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "এই হোস্টনামের জন্য একটি CNAME তৈরি করা হবে Cloudflare DNS, টানেলের দিকে নির্দেশ করে৷",
"askingTheAgentToStartThe": "এজেন্টকে Cloudflare লগইন ফ্লো শুরু করতে বলা হচ্ছে...",
@@ -1785,76 +1719,6 @@
"aiSettings": "এআই সেটিংস",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "একটি ডেলিভারিবিলিটি প্রিফ্লাইট (PTR, পোর্ট 25, RBL) গেট আউটবাউন্ড পাঠানোর আগে মেল এই হোস্টটি ছেড়ে যেতে পারে।",
"accountCreated": "অ্যাকাউন্ট তৈরি করা হয়েছে",
"accountDeleted": "অ্যাকাউন্ট মুছে ফেলা হয়েছে",
"accounts": "হিসাব",
"addAMailDomainFirst": "প্রথমে একটি মেইল ডোমেইন যোগ করুন।",
"addDomain": "ডোমেইন যোগ করুন",
"address": "ঠিকানা",
"component": "কম্পোনেন্ট",
"couldNotAddDomain": "ডোমেন যোগ করা যায়নি",
"createFailed": "তৈরি করা ব্যর্থ হয়েছে",
"delete3": "{{email}} মুছবেন?",
"deleteAccount": "অ্যাকাউন্ট মুছুন",
"deleteAndAllOfItsAccounts": "{{name}} এবং এর সমস্ত অ্যাকাউন্ট এবং উপনাম মুছবেন?",
"deleteDomain": "ডোমেইন মুছুন",
"deleteFailed": "মুছে ফেলতে ব্যর্থ হয়েছে",
"deployDns": "DNS স্থাপন করুন",
"deployFailedNoLinkedDnsProvider": "স্থাপন করা ব্যর্থ হয়েছে (কোন লিঙ্ক নেই DNS প্রদানকারী?)",
"dkimSelector": "DKIM নির্বাচক:",
"dnsCheckFailed": "DNS চেক ব্যর্থ হয়েছে৷",
"dnsChecked": "DNS চেক করা হয়েছে৷",
"dnsRecordsDeployedToProvider": "DNS রেকর্ড প্রদানকারীর কাছে স্থাপন করা হয়েছে",
"domainAdded": "ডোমেন যোগ করা হয়েছে",
"domainDeleted": "ডোমেন মুছে ফেলা হয়েছে",
"emailServerInstallStarted": "ইমেল সার্ভার ইন্সটল শুরু হয়েছে",
"enableRelay": "রিলে সক্ষম করুন",
"enableSpamFiltering": "স্প্যাম ফিল্টারিং সক্ষম করুন",
"failedTo": "{{action}} {{component}} করতে ব্যর্থ হয়েছে",
"failedToDisableRelay": "রিলে নিষ্ক্রিয় করতে ব্যর্থ হয়েছে",
"failedToWebmail": "{{action}} ওয়েবমেইলে ব্যর্থ হয়েছে৷",
"flushFailed": "ফ্লাশ ব্যর্থ হয়েছে",
"flushQueue": "ফ্লাশ সারি",
"host": "হোস্ট",
"installFailed": "ইনস্টল ব্যর্থ হয়েছে",
"installMailServer": "মেইল সার্ভার ইনস্টল করুন",
"installWebmail": "ওয়েবমেইল ইনস্টল করুন",
"mailServerNotInstalled": "মেইল সার্ভার ইনস্টল করা হয়নি",
"mailboxPassword": "মেইলবক্স পাসওয়ার্ড",
"mailboxUsername": "মেইলবক্স ব্যবহারকারীর নাম",
"mostPanelsNeverRunAMail": "বেশিরভাগ প্যানেল কখনই একটি মেল সার্ভার চালায় না, যখন আপনার প্রয়োজন হয় তখন এটি ইনস্টল করুন।",
"newMailDomain": "নতুন মেইল ডোমেইন",
"noMailDomainsYet": "এখনও কোন মেল ডোমেন নেই.",
"outboundRelaySmarthost": "আউটবাউন্ড রিলে (স্মার্টহোস্ট)",
"queue": "সারি",
"queueFlushed": "সারি ফ্লাশ",
"quota": "কোটা",
"quotaInMb": "এমবি কোটা",
"quotaMb": "কোটা (MB)",
"recipient": "প্রাপক",
"relayDisabled": "রিলে নিষ্ক্রিয়",
"relayReachable": "রিলে পৌঁছানো যায়",
"relaySaved": "রিলে সংরক্ষিত",
"relayTestFailed": "রিলে পরীক্ষা ব্যর্থ হয়েছে",
"requiredScore": "প্রয়োজনীয় স্কোর",
"roundcubeWebmail": "রাউন্ডকিউব ওয়েবমেইল",
"ruleUpdateFailed": "নিয়ম আপডেট ব্যর্থ হয়েছে",
"ruleUpdateStarted": "নিয়ম আপডেট শুরু হয়েছে",
"saveFailed": "সংরক্ষণ ব্যর্থ হয়েছে",
"searchAccounts": "অনুসন্ধান অ্যাকাউন্ট",
"searchAccountsByAddress": "ঠিকানা দ্বারা অ্যাকাউন্ট অনুসন্ধান করুন...",
"searchQueueBySenderRecipientOr": "প্রেরক, প্রাপক বা আইডি দ্বারা সারি অনুসন্ধান করুন...",
"sender": "প্রেরক",
"spamConfigSaved": "স্প্যাম কনফিগারেশন সংরক্ষিত",
"spamassassin": "স্প্যাম অ্যাসাসিন",
"updateRules": "আপডেট নিয়ম",
"used": "ব্যবহৃত",
"webmail": "ওয়েবমেইল: {{action}}",
"webmailInstallStarted": "ওয়েবমেইল ইন্সটল শুরু হয়েছে",
"webmailIsNotInstalled": "ওয়েবমেইল ইনস্টল করা নেই."
},
"emailProviders": {
"addedButTestFailed": "যোগ করা হয়েছে, কিন্তু পরীক্ষা ব্যর্থ হয়েছে: {{value}}",
"confirm": "নিশ্চিত করুন",
@@ -2404,193 +2268,6 @@
"empty": "খালি",
"expand": "প্রসারিত করুন"
},
"git": {
"512mbMinimum1gbRecommended": "~512MB সর্বনিম্ন (1GB প্রস্তাবিত)",
"5gbForDatabaseRepositories": "ডাটাবেস + সংগ্রহস্থলের জন্য ~5GB",
"access": "অ্যাক্সেস",
"accessInformation": "তথ্য অ্যাক্সেস",
"accessRepositories": "অ্যাক্সেস সংগ্রহস্থল",
"active": "সক্রিয়",
"addAWebhookToSyncRepositories": "বাহ্যিক উত্স থেকে সংগ্রহস্থল সিঙ্ক করতে একটি ওয়েবহুক যুক্ত করুন৷",
"addWebhook": "ওয়েবহুক যোগ করুন",
"addYourPublicKeyInGitea": "Gitea-এ আপনার সর্বজনীন কী যোগ করুন",
"adminEmail": "অ্যাডমিন ইমেইল",
"adminEmailIsRequired": "অ্যাডমিন ইমেল প্রয়োজন",
"adminPasswordLeaveEmptyToAuto": "অ্যাডমিন পাসওয়ার্ড (স্বয়ংক্রিয়ভাবে তৈরি করতে খালি রাখুন)",
"adminPasswordSaveThis": "অ্যাডমিন পাসওয়ার্ড: {{adminpassword}} (এটি সংরক্ষণ করুন!)",
"adminUsername": "অ্যাডমিন ব্যবহারকারীর নাম",
"application": "আবেদন",
"areYouSureYouWantTo": "আপনি কি এই ওয়েবহুক মুছে ফেলার বিষয়ে নিশ্চিত? এটি পূর্বাবস্থায় ফেরানো যাবে না।",
"autoGenerateSecurePassword": "সুরক্ষিত পাসওয়ার্ড স্বয়ংক্রিয়ভাবে তৈরি করুন",
"autoSyncOnPushEvents": "পুশ ইভেন্টে অটো-সিঙ্ক",
"automaticallyDeployWhenCodeIsPushed": "কোডটি শাখায় পুশ করা হলে স্বয়ংক্রিয়ভাবে স্থাপন করা হয়",
"bidirectional": "দ্বিমুখী",
"bitbucket": "বিটবাকেট",
"branch7": "শাখা:",
"branches": "শাখা",
"cloneRepositoriesViaSsh": "SSH এর মাধ্যমে সংগ্রহস্থল ক্লোন করুন",
"commit": "কমিট",
"commit2": "প্রতিশ্রুতি:",
"commitMessage": "প্রতিশ্রুতি বার্তা",
"commits": "প্রতিশ্রুতি দেয়",
"components": "উপাদান:",
"configureAWebhookWithDeployOn": "এখানে ডিপ্লয়মেন্ট দেখতে \"Deploy on Push\" সক্ষম করে একটি ওয়েবহুক কনফিগার করুন।",
"configureWebhooks": "ওয়েবহুক কনফিগার করুন",
"copySshUrl": "SSH URL কপি করুন",
"copyUrl": "URL কপি করুন",
"createYourFirstRepositoryInGitea": "Gitea এ আপনার প্রথম সংগ্রহস্থল তৈরি করুন।",
"dangerZone": "ডেঞ্জার জোন",
"defaultBranch": "ডিফল্ট শাখা",
"deleteWebhook": "ওয়েবহুক মুছুন",
"deployOnPush": "ধাক্কা উপর স্থাপন",
"deployToApplication": "অ্যাপ্লিকেশন স্থাপন",
"deployment": "স্থাপনা",
"deploymentHistory": "স্থাপনার ইতিহাস",
"deploymentOutput": "স্থাপনার আউটপুট",
"deploymentSettings": "স্থাপনার সেটিংস",
"deploymentStartedV": "স্থাপনা শুরু হয়েছে: v{{version}}",
"deploymentV": "স্থাপনা v{{version}}",
"deploymentVLogs": "স্থাপনা v{{version}} লগ",
"deployments": "স্থাপনা",
"duration": "সময়কাল:",
"error": "ত্রুটি",
"error2": "ত্রুটি:",
"failedToCreateWebhook": "ওয়েবহুক তৈরি করতে ব্যর্থ হয়েছে৷",
"failedToCreateWebhook2": "ওয়েবহুক তৈরি করতে ব্যর্থ হয়েছে: {{message}}",
"failedToDelete": "মুছে ফেলতে ব্যর্থ হয়েছে: {{message}}",
"failedToDeploy": "স্থাপন করা ব্যর্থ হয়েছে: {{message}}",
"failedToInstall": "ইনস্টল করতে ব্যর্থ হয়েছে: {{message}}",
"failedToLoadDeploymentDetails": "স্থাপনার বিবরণ লোড করতে ব্যর্থ হয়েছে৷",
"failedToLoadDirectory": "ডিরেক্টরি লোড করতে ব্যর্থ হয়েছে৷",
"failedToLoadLogs": "লগগুলি লোড করতে ব্যর্থ হয়েছে: {{message}}৷",
"failedToLoadMoreCommits": "আরো কমিট লোড করতে ব্যর্থ হয়েছে",
"failedToLoadRepositoryDetails": "সংগ্রহস্থলের বিবরণ লোড করতে ব্যর্থ হয়েছে৷",
"failedToStart": "শুরু করতে ব্যর্থ হয়েছে: {{message}}",
"failedToStop": "থামাতে ব্যর্থ হয়েছে: {{message}}",
"failedToSwitchBranch": "শাখা পরিবর্তন করতে ব্যর্থ হয়েছে",
"failedToToggleWebhook": "ওয়েবহুক টগল করতে ব্যর্থ হয়েছে: {{message}}",
"failedToTriggerDeployment": "স্থাপনা ট্রিগার করতে ব্যর্থ হয়েছে৷",
"failedToUninstall": "আনইনস্টল করা যায়নি: {{message}}",
"files": "ফাইল",
"forks": "কাঁটা",
"gitServerInstalledSuccessfully": "Git সার্ভার সফলভাবে ইনস্টল করা হয়েছে৷",
"gitServerStarted": "Git সার্ভার শুরু হয়েছে৷",
"gitServerStopped": "Git সার্ভার বন্ধ হয়েছে৷",
"gitServerUninstalled": "Git সার্ভার আনইনস্টল করা হয়েছে৷",
"giteaGitServer300mbRam": "Gitea (Git সার্ভার) - ~300MB RAM",
"giteaPostgresqlDatabase": "Gitea + PostgreSQL ডাটাবেস",
"giteaServerIsNotRunning": "Gitea সার্ভার চলছে না",
"giteaVersion": "Gitea সংস্করণ",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "HTTP অ্যাক্সেস",
"httpUrl": "HTTP URL",
"iVeSavedIt": "আমি এটা সংরক্ষণ করেছি",
"inactive": "নিষ্ক্রিয়",
"installGitServer": "Git সার্ভার ইনস্টল করুন",
"installGiteaToHostAndManage": "স্থানীয়ভাবে আপনার Git সংগ্রহস্থলগুলি হোস্ট এবং পরিচালনা করতে Gitea ইনস্টল করুন৷",
"installationFailed": "ইনস্টলেশন ব্যর্থ হয়েছে",
"lastResult": "শেষ ফলাফল",
"lastSync": "শেষ সিঙ্ক",
"leaveEmptyToUseSameName": "একই নাম ব্যবহার করতে খালি ছেড়ে দিন",
"loadMore": "আরো লোড",
"loadingDeployments": "স্থাপনা লোড হচ্ছে",
"loadingGit": "Git লোড হচ্ছে",
"loadingRepositories": "সংগ্রহস্থল লোড হচ্ছে",
"loadingWebhooks": "ওয়েবহুক লোড হচ্ছে",
"localRepositoryNameOptional": "স্থানীয় সংগ্রহস্থলের নাম (ঐচ্ছিক)",
"manual": "ম্যানুয়াল",
"memory": "স্মৃতি:",
"myGithubRepo": "আমার GitHub রেপো",
"nameAndRepositoryUrlAreRequired": "নাম এবং সংগ্রহস্থল URL প্রয়োজন",
"noDeploymentLogsAvailable": "কোনো স্থাপনার লগ উপলব্ধ নেই৷",
"noDeploymentsForThisWebhook": "এই ওয়েবহুকের জন্য কোনো স্থাপনা নেই।",
"noDeploymentsMatchThisFilter": "কোন স্থাপনা এই ফিল্টার মেলে না.",
"noDeploymentsYet": "এখনও কোন স্থাপনা",
"noDescription": "কোন বর্ণনা নেই",
"noGitServerInstalled": "কোন Git সার্ভার ইনস্টল করা নেই৷",
"noRepositoriesYet": "এখনও কোন সংগ্রহস্থল",
"noWebhooksConfigured": "কোনো ওয়েবহুক কনফিগার করা নেই",
"noneSyncOnly": "কোনটিই নয় (শুধুমাত্র সিঙ্ক)",
"openGitea": "Gitea খুলুন",
"openInGitea": "Gitea এ খুলুন",
"optionallyDeployAnApplicationWhenCode": "কোড পুশ করা হলে ঐচ্ছিকভাবে একটি অ্যাপ্লিকেশন স্থাপন করুন",
"postDeployScriptOptional": "পোস্ট-ডিপ্লোয় স্ক্রিপ্ট (ঐচ্ছিক)",
"postDeploymentScriptOutput": "পোস্ট-ডিপ্লয়মেন্ট স্ক্রিপ্ট আউটপুট",
"postgresqlDatabase200mbRam": "PostgreSQL ডাটাবেস - ~200MB RAM",
"preDeployScriptOptional": "প্রি-ডিপ্লোয় স্ক্রিপ্ট (ঐচ্ছিক)",
"preDeploymentScriptOutput": "প্রি-ডিপ্লয়মেন্ট স্ক্রিপ্ট আউটপুট",
"private": "ব্যক্তিগত",
"pullExternalLocal": "টানুন (বাহ্যিক → স্থানীয়)",
"pushLocalExternal": "পুশ (স্থানীয় → বাহ্যিক)",
"quickActions": "দ্রুত কর্ম",
"recentDeployments": "সাম্প্রতিক স্থাপনা",
"repositories": "ভান্ডার",
"repository": "ভান্ডার",
"repositoryUrl": "সংগ্রহস্থল URL",
"repositoryUrlCopied": "সংগ্রহস্থল URL কপি করা হয়েছে৷",
"requiredForSshClonePush": "SSH ক্লোন/পুশের জন্য প্রয়োজনীয়",
"resourceRequirements": "সম্পদের প্রয়োজনীয়তা",
"rollback": "রোলব্যাক",
"rollbackCompleted": "রোলব্যাক সম্পন্ন হয়েছে",
"rollbackDeployment": "রোলব্যাক স্থাপনা",
"rollbackFailed": "রোলব্যাক ব্যর্থ হয়েছে৷",
"rollbackFailed2": "রোলব্যাক ব্যর্থ হয়েছে: {{message}}৷",
"running": "চলমান",
"saveThisSecretItWillNot": "এই গোপন সংরক্ষণ করুন! এটা আর দেখানো হবে না।",
"secretCopied": "গোপন কপি করা হয়েছে",
"serverNotRunning": "সার্ভার চলছে না",
"serverOverview": "সার্ভার ওভারভিউ",
"serverPower": "সার্ভার শক্তি",
"serverSettings": "সার্ভার সেটিংস",
"serverStatus": "সার্ভারের অবস্থা",
"sshAccess": "SSH অ্যাক্সেস",
"sshCloneUrl": "SSH ক্লোন URL",
"sshKeyRequirement": "SSH কী প্রয়োজনীয়তা",
"sshPort": "SSH পোর্ট",
"sshUrlCopied": "SSH URL কপি করা হয়েছে",
"stars": "তারা",
"startOrStopTheGiteaContainer": "Gitea কন্টেইনার শুরু বা বন্ধ করুন",
"startServer": "সার্ভার শুরু করুন",
"startTheGitServerToBrowse": "সংগ্রহস্থল ব্রাউজ করতে Git সার্ভার শুরু করুন।",
"status5": "অবস্থা:",
"stopGitServer": "Git সার্ভার বন্ধ করুন",
"stopServer": "সার্ভার বন্ধ করুন",
"stopTheGitServer": "Git সার্ভার বন্ধ করবেন?",
"stopsGiteaDataIsPreservedUnless": "থামে Gitea; ম্যানুয়ালি অপসারণ না করা পর্যন্ত ডেটা সংরক্ষণ করা হয়",
"storage": "সঞ্চয়স্থান:",
"success": "সফলতা",
"switch": "সুইচ",
"syncDirection": "সিঙ্ক দিকনির্দেশ",
"syncs": "সিঙ্ক করে",
"testEventLogged": "পরীক্ষা ইভেন্ট লগ",
"testFailed": "পরীক্ষা ব্যর্থ হয়েছে",
"testFailed2": "পরীক্ষা ব্যর্থ হয়েছে: {{message}}",
"thisWillInstall": "এটি ইনস্টল হবে:",
"triggeredBy": "এর দ্বারা ট্রিগার করা হয়েছে:",
"uninstall": "আনইনস্টল করুন",
"uninstallGitServer": "Git সার্ভার আনইনস্টল করুন",
"uninstallTheGiteaServerThisWill": "Gitea সার্ভার আনইনস্টল করবেন? এটি ধারকটি বন্ধ করবে তবে আপনার ডেটা সংরক্ষণ করবে।",
"uninstallingWillStopTheGiteaContainer": "আনইনস্টল করা হলে Gitea কন্টেইনার বন্ধ হয়ে যাবে। আপনার ডেটা সংরক্ষণ করা হবে যদি না আপনি এটি অপসারণ করতে চান৷",
"updated": "আপডেট করা হয়েছে",
"updated7d": "আপডেট করা হয়েছে ≤7d",
"urlCopied": "URL কপি করা হয়েছে",
"urlPath": "URL পথ",
"useThisSecretWhenConfiguringThe": "আপনার সংগ্রহস্থল সেটিংসে ওয়েবহুক কনফিগার করার সময় এই গোপনীয়তাটি ব্যবহার করুন৷",
"useYourSshKey": "আপনার SSH কী ব্যবহার করুন",
"viewLogs": "লগ দেখুন",
"visibility": "দৃশ্যমানতা",
"webBrowserAccess": "ওয়েব ব্রাউজার অ্যাক্সেস",
"webInterface": "ওয়েব ইন্টারফেস",
"webhook": "ওয়েবহুক",
"webhookCreatedSuccessfully": "ওয়েবহুক সফলভাবে তৈরি করা হয়েছে",
"webhookDeleted": "ওয়েবহুক মুছে ফেলা হয়েছে",
"webhookSecret": "ওয়েবহুক সিক্রেট",
"webhookUrlCopied": "Webhook URL কপি করা হয়েছে",
"webhooks": "ওয়েবহুক",
"zeroDowntime": "শূন্য ডাউনটাইম",
"zeroDowntimeDeployment": "জিরো-ডাউনটাইম স্থাপনা"
},
"gitProviders": {
"anyGitRemote": "যেকোনো Git রিমোট",
"bitbucket": "বিটবাকেট",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "স্ন্যাপশট"
},
"storagePanel": {
"createdBucket": "তৈরি করা বালতি \"{{name}}\"",
"createdDatabase": "\"{{name}}\" ডাটাবেস তৈরি করা হয়েছে",
"createdNamespace": "\"{{title}}\" নামস্থান তৈরি করা হয়েছে",
"d1Databases": "D1 ডাটাবেস",
"deleted": "মুছে ফেলা হয়েছে",
"kvNamespaces": "কেভি নামস্থান",
"loadingStorage": "সঞ্চয়স্থান লোড হচ্ছে...",
"r2Buckets": "R2 বালতি",
"r2IsS3CompatibleSoA": "R2 হল S3-সামঞ্জস্যপূর্ণ, তাই এখানে একটি বালতি পরে ServerKit ব্যাকআপ ব্যাক করতে পারে।",
"storageUnavailable": "সঞ্চয়স্থান অনুপলব্ধ৷"
},
"styleGuide": {
"2Found": "2 পাওয়া গেছে",
"aNewVersionIsAvailableFor": "একটি নতুন সংস্করণ আপডেটের জন্য উপলব্ধ।",
@@ -6267,28 +5932,6 @@
"arguments": "যুক্তি",
"result": "ফলাফল"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "একটি Cloudflare টানেল Cloudflare এর প্রান্তের মাধ্যমে একটি স্থানীয় বা ব্যক্তিগত পরিষেবা প্রকাশ করে — কোনও পাবলিক আইপি বা খোলা পোর্টের প্রয়োজন নেই৷",
"cloudflareTunnels": "Cloudflare টানেল (",
"cloudflareTunnelsUnavailable": "Cloudflare টানেল অনুপলব্ধ৷",
"copied": "কপি করা হয়েছে",
"couldNotCopyTheInstallCommand": "ইনস্টল কমান্ড অনুলিপি করা যায়নি",
"createACloudflareTunnel": "একটি Cloudflare টানেল তৈরি করুন",
"createOneAboveToExposeA": "একটি পাবলিক আইপি ছাড়া একটি স্থানীয় পরিষেবা প্রকাশ করতে উপরে একটি তৈরি করুন৷",
"createdTunnel": "\"{{name}}\" টানেল তৈরি করা হয়েছে",
"deletedCloudflareTunnel": "Cloudflare টানেল \"{{name}}\" মুছে ফেলা হয়েছে",
"hostnameRemoved": "হোস্টনাম সরানো হয়েছে",
"hostnameRouted": "হোস্টনেম রাউট করা হয়েছে",
"hostnames": "হোস্টনাম",
"installCommand": "কমান্ড ইনস্টল করুন",
"loadingCloudflareTunnels": "Cloudflare টানেল লোড হচ্ছে...",
"noCloudflareTunnels": "Cloudflare টানেল নেই",
"noInstallCommandAvailable": "কোন ইনস্টল কমান্ড উপলব্ধ.",
"noPublicHostnamesRoutedYet": "কোনো সর্বজনীন হোস্টনাম এখনও রাউট করা হয়নি৷",
"routeSetButTheDnsRecord": "রুট সেট, কিন্তু DNS রেকর্ড ব্যর্থ হয়েছে:",
"runThisOnceOnTheMachine": "আপনার স্থানীয় পরিষেবা হোস্টিং মেশিনে একবার এটি চালান। সংযোগকারী টোকেন শুধুমাত্র এখন দেখানো হয়.",
"theHostnameShouldBeInsideThis": "হোস্টনাম এই জোনের ডোমেইনের মধ্যে থাকা উচিত; একটি প্রক্সিযুক্ত DNS রেকর্ড স্বয়ংক্রিয়ভাবে তৈরি হয়৷"
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "যে দিনগুলিতে একটি নতুন-আচ্ছন্ন অ্যাকাউন্ট তালিকাভুক্তি কার্যকর হওয়ার আগে শুধুমাত্র একটি পাসওয়ার্ড দিয়ে সাইন ইন করা চালিয়ে যেতে পারে৷",
"gracePeriodDays": "গ্রেস পিরিয়ড (দিন)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "উইজেট অনুসন্ধান করুন...",
"typesDragToRepositionAfterAdding": "{{length}} প্রকার · যোগ করার পরে পুনরায় অবস্থানে টেনে আনুন"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "এই ডোমেনে কর্মীকে সংযুক্ত করে যাতে মিলে যাওয়া ট্রাফিক স্ক্রিপ্ট চালায়।",
"code": "কোড",
"deletedWorker": "মুছে ফেলা কর্মী \"{{name}}\"",
"deploy": "স্থাপন করুন",
"deployAWorker": "একজন কর্মী মোতায়েন করুন",
"deployYourFirstEdgeScriptAbove": "উপরে আপনার প্রথম প্রান্ত স্ক্রিপ্ট স্থাপন.",
"deployedWorker": "নিয়োজিত কর্মী \"{{name}}\"",
"loadingWorkers": "কর্মী লোড হচ্ছে...",
"noRoutesYetAddARoute": "এখনো কোনো রুট নেই। একজন কর্মীকে ট্রাফিক পাঠাতে মোতায়েন করার সময় একটি রুট প্যাটার্ন যোগ করুন।",
"noWorkersDeployed": "কোনো শ্রমিক মোতায়েন নেই",
"routePatternOptional": "রুট প্যাটার্ন (ঐচ্ছিক)",
"routeRemoved": "রুট সরানো হয়েছে",
"routes": "রুট (",
"workerDeployedButTheRouteFailed": "কর্মী মোতায়েন করা হয়েছে, কিন্তু রুট ব্যর্থ হয়েছে:",
"workers": "শ্রমিক (",
"workersUnavailable": "শ্রমিক পাওয়া যাচ্ছে না"
},
"workspaceDetail": {
"accessGranted": "অ্যাক্সেস দেওয়া হয়েছে",
"accessRevoked": "অ্যাক্সেস প্রত্যাহার করা হয়েছে৷",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Servername",
"serverProvisioningInitiated": "Serverbereitstellung eingeleitet"
},
"cloudflareWafPanel": {
"activeRules": "Aktive Regeln (",
"addAQuickRuleOrA": "Fügen Sie oben eine Schnellregel oder eine benutzerdefinierte Regel hinzu.",
"addCustomRule": "Benutzerdefinierte Regel hinzufügen",
"addRule": "Regel hinzufügen",
"addedQuickRule": "Schnellregel hinzugefügt: {{label}}",
"applyACuratedCloudflareRuleWithout": "Wenden Sie eine kuratierte Cloudflare-Regel an, ohne einen Ausdruck von Hand zu schreiben.",
"block": "Blockieren",
"blockAdminFromOutsideTheOffice": "Blockieren Sie den Administrator von außerhalb des Büros",
"chooseAQuickRule": "Wählen Sie eine schnelle Regel ...",
"customRule": "Benutzerdefinierte Regel",
"customRuleAdded": "Benutzerdefinierte Regel hinzugefügt",
"expression": "Ausdruck",
"firewallRulesUnavailable": "Firewallregeln nicht verfügbar",
"jsChallenge": "JS-Herausforderung",
"loadingFirewallRules": "Firewallregeln werden geladen…",
"log": "Protokoll",
"managedChallenge": "Herausforderung gemanagt",
"noCustomFirewallRules": "Keine benutzerdefinierten Firewall-Regeln",
"quickRules": "Schnelle Regeln",
"ruleDeleted": "Regel gelöscht",
"writeYourOwnCloudflareFirewallExpression": "Schreiben Sie Ihren eigenen Cloudflare-Firewall-Ausdruck für vollständige Kontrolle."
},
"cloudflareZoneSettings": {
"1Day": "1 Tag",
"1Month": "1 Monat",
"1Week": "1 Woche",
"1Year": "1 Jahr",
"2Years": "2 Jahre",
"6Months": "6 Monate",
"appliedNeedAHigherCloudflarePlan": "Angewendet {{applied}}/{{total}}. {{length}} benötigen einen höheren Cloudflare-Plan.",
"appliedRecommendedHardeningSettings": "Angewendete empfohlene Härtung ({{applied}} Einstellungen)",
"applyRecommendedHardening": "Empfohlene Härtung anwenden",
"backToDnsZones": "Zurück zu DNS-Zonen",
"changingCloudflareSettingsRequiresAnAdmin": "Für die Änderung der Cloudflare-Einstellungen ist ein Administratorkonto erforderlich.",
"clearCloudflareSCachedCopyOf": "Löschen Sie die zwischengespeicherte Kopie Ihrer Website von Cloudflare, damit Besucher neue Inhalte erhalten. Löschen Sie alles oder listen Sie unten bestimmte URLs auf.",
"clearCloudflareSEntireCacheFor": "Den gesamten Cloudflare-Cache für {{value}} löschen?",
"cloudflare": "Wolkenflare",
"cloudflareSettingsUnavailable": "Cloudflare-Einstellungen nicht verfügbar",
"dnsZones": "DNS-Zonen",
"enabled": "Aktiviert",
"enterAtLeastOneUrlTo": "Geben Sie mindestens eine URL zum Löschen ein",
"failedToApplyPreset": "Voreinstellung konnte nicht angewendet werden",
"failedToPurgeCache": "Cache konnte nicht geleert werden",
"failedToPurgeUrls": "URLs konnten nicht gelöscht werden",
"failedToSaveSetting": "Einstellung konnte nicht gespeichert werden",
"includeSubdomains": "Subdomains einschließen",
"itReCachesThisIsSafe": "es wird erneut zwischengespeichert. Dies ist sicher, kann jedoch die Ursprungslast kurzzeitig erhöhen.",
"lockedByYourCloudflarePlan": "Gesperrt durch Ihren Cloudflare-Plan",
"maxAge": "Höchstalter",
"notAvailableOnThisZone": "In dieser Zone nicht verfügbar",
"off": "Aus",
"preload": "Vorladen",
"purgeCache": "Cache leeren",
"purgeEntireCache": "Gesamten Cache leeren",
"purgeEverything": "Alles bereinigen",
"purgeSpecificUrlsOnePerLine": "Bestimmte URLs löschen (eine pro Zeile, bis zu 30)",
"purgeUrls": "URLs löschen",
"purgedTheEntireCache": "Den gesamten Cache geleert",
"purgedUrlFromCache": "{{n}} URL{{value}} aus dem Cache gelöscht",
"setsFullStrictSslAlwaysUse": "Legt vollständiges (strenges) SSL fest, verwendet immer HTTPS, HSTS (6 Monate), eine TLS 1.2-Ebene mit TLS 1.3, Brotli, HTTP/3 und einen 4-Stunden-Browser-Cache. Einstellungen, die Ihr Plan nicht zulässt, werden übersprungen.",
"settingSaved": "Einstellung gespeichert",
"theNextVisitToEachPage": "Der nächste Besuch jeder Seite wird von Ihrem Ursprungsort aus bedient ",
"tunnels": "Tunnel",
"workers": "Arbeiter"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "Für diesen Hostnamen wird im Cloudflare-DNS ein CNAME erstellt, der auf den Tunnel verweist.",
"askingTheAgentToStartThe": "Bitten Sie den Agenten, den Cloudflare-Anmeldevorgang zu starten …",
@@ -1785,76 +1719,6 @@
"aiSettings": "KI-Einstellungen",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Ein Zustellbarkeits-Preflight (PTR, Port 25, RBL) sperrt den ausgehenden Versand, bevor E-Mails diesen Host verlassen können.",
"accountCreated": "Konto erstellt",
"accountDeleted": "Konto gelöscht",
"accounts": "Konten",
"addAMailDomainFirst": "Fügen Sie zunächst eine E-Mail-Domäne hinzu.",
"addDomain": "Domäne hinzufügen",
"address": "Adresse",
"component": "Komponente",
"couldNotAddDomain": "Domäne konnte nicht hinzugefügt werden",
"createFailed": "Erstellen ist fehlgeschlagen",
"delete3": "{{email}} löschen?",
"deleteAccount": "Konto löschen",
"deleteAndAllOfItsAccounts": "{{name}} und alle seine Konten und Aliase löschen?",
"deleteDomain": "Domain löschen",
"deleteFailed": "Das Löschen ist fehlgeschlagen",
"deployDns": "Stellen Sie DNS bereit",
"deployFailedNoLinkedDnsProvider": "Bereitstellung fehlgeschlagen (kein verknüpfter DNS-Anbieter?)",
"dkimSelector": "DKIM-Auswahl:",
"dnsCheckFailed": "Die DNS-Prüfung ist fehlgeschlagen",
"dnsChecked": "DNS überprüft",
"dnsRecordsDeployedToProvider": "Für den Anbieter bereitgestellte DNS-Einträge",
"domainAdded": "Domain hinzugefügt",
"domainDeleted": "Domain gelöscht",
"emailServerInstallStarted": "Die Installation des E-Mail-Servers wurde gestartet",
"enableRelay": "Relais aktivieren",
"enableSpamFiltering": "Aktivieren Sie die Spam-Filterung",
"failedTo": "Fehler bei {{action}} {{component}}",
"failedToDisableRelay": "Das Relais konnte nicht deaktiviert werden",
"failedToWebmail": "{{action}} Webmail konnte nicht ausgeführt werden",
"flushFailed": "Die Spülung ist fehlgeschlagen",
"flushQueue": "Flush-Warteschlange",
"host": "Gastgeber",
"installFailed": "Die Installation ist fehlgeschlagen",
"installMailServer": "Mailserver installieren",
"installWebmail": "Webmail installieren",
"mailServerNotInstalled": "Mailserver nicht installiert",
"mailboxPassword": "Mailbox-Passwort",
"mailboxUsername": "Benutzername des Postfachs",
"mostPanelsNeverRunAMail": "Die meisten Panels betreiben nie einen Mailserver, sondern installieren ihn, wenn Sie ihn brauchen.",
"newMailDomain": "Neue Mail-Domain",
"noMailDomainsYet": "Noch keine Mail-Domains.",
"outboundRelaySmarthost": "Ausgehendes Relay (Smarthost)",
"queue": "Warteschlange",
"queueFlushed": "Warteschlange geleert",
"quota": "Quote",
"quotaInMb": "Kontingent in MB",
"quotaMb": "Kontingent (MB)",
"recipient": "Empfänger",
"relayDisabled": "Relais deaktiviert",
"relayReachable": "Relais erreichbar",
"relaySaved": "Relais gespeichert",
"relayTestFailed": "Der Relaistest ist fehlgeschlagen",
"requiredScore": "Erforderliche Punktzahl",
"roundcubeWebmail": "Roundcube-Webmail",
"ruleUpdateFailed": "Regelaktualisierung fehlgeschlagen",
"ruleUpdateStarted": "Regelaktualisierung gestartet",
"saveFailed": "Speichern fehlgeschlagen",
"searchAccounts": "Konten durchsuchen",
"searchAccountsByAddress": "Konten nach Adresse suchen...",
"searchQueueBySenderRecipientOr": "Durchsuchen Sie die Warteschlange nach Absender, Empfänger oder ID…",
"sender": "Absender",
"spamConfigSaved": "Spam-Konfiguration gespeichert",
"spamassassin": "SpamAssassin",
"updateRules": "Regeln aktualisieren",
"used": "Gebraucht",
"webmail": "Webmail: {{action}}",
"webmailInstallStarted": "Die Webmail-Installation wurde gestartet",
"webmailIsNotInstalled": "Webmail ist nicht installiert."
},
"emailProviders": {
"addedButTestFailed": "Hinzugefügt, aber Test fehlgeschlagen: {{value}}",
"confirm": "Bestätigen",
@@ -2404,193 +2268,6 @@
"empty": "Leer",
"expand": "Erweitern"
},
"git": {
"512mbMinimum1gbRecommended": "~512 MB mindestens (1 GB empfohlen)",
"5gbForDatabaseRepositories": "~5 GB für Datenbank + Repositorys",
"access": "Zugang",
"accessInformation": "Zugriff auf Informationen",
"accessRepositories": "Auf Repositorys zugreifen",
"active": "Aktiv",
"addAWebhookToSyncRepositories": "Fügen Sie einen Webhook hinzu, um Repositorys aus externen Quellen zu synchronisieren.",
"addWebhook": "Webhook hinzufügen",
"addYourPublicKeyInGitea": "Fügen Sie Ihren öffentlichen Schlüssel in Gitea hinzu",
"adminEmail": "Admin-E-Mail",
"adminEmailIsRequired": "Admin-E-Mail ist erforderlich",
"adminPasswordLeaveEmptyToAuto": "Admin-Passwort (leer lassen, um es automatisch zu generieren)",
"adminPasswordSaveThis": "Admin-Passwort: {{adminpassword}} (speichern Sie es!)",
"adminUsername": "Admin-Benutzername",
"application": "Bewerbung",
"areYouSureYouWantTo": "Sind Sie sicher, dass Sie diesen Webhook löschen möchten? Dies kann nicht rückgängig gemacht werden.",
"autoGenerateSecurePassword": "Sicheres Passwort automatisch generieren",
"autoSyncOnPushEvents": "Automatische Synchronisierung bei Push-Ereignissen",
"automaticallyDeployWhenCodeIsPushed": "Automatische Bereitstellung, wenn Code an die Verzweigung übertragen wird",
"bidirectional": "Bidirektional",
"bitbucket": "Bitbucket",
"branch7": "Filiale:",
"branches": "Zweige",
"cloneRepositoriesViaSsh": "Klonen Sie Repositorys über SSH",
"commit": "Begehen",
"commit2": "Commit:",
"commitMessage": "Commit-Nachricht",
"commits": "Commits",
"components": "Komponenten:",
"configureAWebhookWithDeployOn": "Konfigurieren Sie einen Webhook mit aktivierter Option „Deploy on Push“, um die Bereitstellungen hier anzuzeigen.",
"configureWebhooks": "Konfigurieren Sie Webhooks",
"copySshUrl": "Kopieren Sie die SSH-URL",
"copyUrl": "URL kopieren",
"createYourFirstRepositoryInGitea": "Erstellen Sie Ihr erstes Repository in Gitea.",
"dangerZone": "Gefahrenzone",
"defaultBranch": "Standardzweig",
"deleteWebhook": "Webhook löschen",
"deployOnPush": "Bereitstellung per Push",
"deployToApplication": "In der Anwendung bereitstellen",
"deployment": "Bereitstellung",
"deploymentHistory": "Bereitstellungsverlauf",
"deploymentOutput": "Bereitstellungsausgabe",
"deploymentSettings": "Bereitstellungseinstellungen",
"deploymentStartedV": "Bereitstellung gestartet: v{{version}}",
"deploymentV": "Bereitstellung v{{version}}",
"deploymentVLogs": "Bereitstellung v{{version}}-Protokolle",
"deployments": "Bereitstellungen",
"duration": "Dauer:",
"error": "Fehler",
"error2": "Fehler:",
"failedToCreateWebhook": "Webhook konnte nicht erstellt werden",
"failedToCreateWebhook2": "Webhook konnte nicht erstellt werden: {{message}}",
"failedToDelete": "Fehler beim Löschen: {{message}}",
"failedToDeploy": "Bereitstellung fehlgeschlagen: {{message}}",
"failedToInstall": "Installation fehlgeschlagen: {{message}}",
"failedToLoadDeploymentDetails": "Bereitstellungsdetails konnten nicht geladen werden",
"failedToLoadDirectory": "Das Verzeichnis konnte nicht geladen werden",
"failedToLoadLogs": "Protokolle konnten nicht geladen werden: {{message}}",
"failedToLoadMoreCommits": "Es konnten keine weiteren Commits geladen werden",
"failedToLoadRepositoryDetails": "Repository-Details konnten nicht geladen werden",
"failedToStart": "Fehler beim Starten: {{message}}",
"failedToStop": "Stoppen fehlgeschlagen: {{message}}",
"failedToSwitchBranch": "Zweig konnte nicht gewechselt werden",
"failedToToggleWebhook": "Webhook konnte nicht umgeschaltet werden: {{message}}",
"failedToTriggerDeployment": "Die Bereitstellung konnte nicht ausgelöst werden",
"failedToUninstall": "Deinstallation fehlgeschlagen: {{message}}",
"files": "Dateien",
"forks": "Gabeln",
"gitServerInstalledSuccessfully": "Git-Server erfolgreich installiert",
"gitServerStarted": "Git-Server gestartet",
"gitServerStopped": "Git-Server gestoppt",
"gitServerUninstalled": "Git-Server deinstalliert",
"giteaGitServer300mbRam": "Gitea (Git-Server) – ~300 MB RAM",
"giteaPostgresqlDatabase": "Gitea + PostgreSQL-Datenbank",
"giteaServerIsNotRunning": "Der Gitea-Server läuft nicht",
"giteaVersion": "Gitea-Version",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "HTTP-Zugriff",
"httpUrl": "HTTP-URL",
"iVeSavedIt": "Ich habe es gespeichert",
"inactive": "Inaktiv",
"installGitServer": "Installieren Sie den Git-Server",
"installGiteaToHostAndManage": "Installieren Sie Gitea, um Ihre Git-Repositorys lokal zu hosten und zu verwalten.",
"installationFailed": "Die Installation ist fehlgeschlagen",
"lastResult": "Letztes Ergebnis",
"lastSync": "Letzte Synchronisierung",
"leaveEmptyToUseSameName": "Lassen Sie das Feld leer, um denselben Namen zu verwenden",
"loadMore": "Mehr laden",
"loadingDeployments": "Bereitstellungen werden geladen",
"loadingGit": "Git wird geladen",
"loadingRepositories": "Laden von Repositorys",
"loadingWebhooks": "Webhooks werden geladen",
"localRepositoryNameOptional": "Name des lokalen Repositorys (optional)",
"manual": "Handbuch",
"memory": "Speicher:",
"myGithubRepo": "Mein GitHub-Repo",
"nameAndRepositoryUrlAreRequired": "Name und Repository-URL sind erforderlich",
"noDeploymentLogsAvailable": "Keine Bereitstellungsprotokolle verfügbar.",
"noDeploymentsForThisWebhook": "Keine Bereitstellungen für diesen Webhook.",
"noDeploymentsMatchThisFilter": "Keine Bereitstellungen entsprechen diesem Filter.",
"noDeploymentsYet": "Noch keine Bereitstellungen",
"noDescription": "Keine Beschreibung",
"noGitServerInstalled": "Kein Git-Server installiert",
"noRepositoriesYet": "Noch keine Repositories",
"noWebhooksConfigured": "Keine Webhooks konfiguriert",
"noneSyncOnly": "Keine (nur Synchronisierung)",
"openGitea": "Öffnen Sie Gitea",
"openInGitea": "Geöffnet in Gitea",
"optionallyDeployAnApplicationWhenCode": "Stellen Sie optional eine Anwendung bereit, wenn Code gepusht wird",
"postDeployScriptOptional": "Skript nach der Bereitstellung (optional)",
"postDeploymentScriptOutput": "Skriptausgabe nach der Bereitstellung",
"postgresqlDatabase200mbRam": "PostgreSQL-Datenbank – ~200 MB RAM",
"preDeployScriptOptional": "Skript vor der Bereitstellung (optional)",
"preDeploymentScriptOutput": "Skriptausgabe vor der Bereitstellung",
"private": "Privat",
"pullExternalLocal": "Pull (Extern → Lokal)",
"pushLocalExternal": "Push (Lokal → Extern)",
"quickActions": "Schnelle Aktionen",
"recentDeployments": "Aktuelle Bereitstellungen",
"repositories": "Repositories",
"repository": "Repository",
"repositoryUrl": "Repository-URL",
"repositoryUrlCopied": "Repository-URL kopiert",
"requiredForSshClonePush": "Erforderlich für SSH-Klonen/Push",
"resourceRequirements": "Ressourcenanforderungen",
"rollback": "Rollback",
"rollbackCompleted": "Rollback abgeschlossen",
"rollbackDeployment": "Rollback-Bereitstellung",
"rollbackFailed": "Rollback fehlgeschlagen",
"rollbackFailed2": "Rollback fehlgeschlagen: {{message}}",
"running": "Laufen",
"saveThisSecretItWillNot": "Bewahren Sie dieses Geheimnis auf! Es wird nicht erneut angezeigt.",
"secretCopied": "Geheimnis kopiert",
"serverNotRunning": "Server läuft nicht",
"serverOverview": "Serverübersicht",
"serverPower": "Serverleistung",
"serverSettings": "Servereinstellungen",
"serverStatus": "Serverstatus",
"sshAccess": "SSH-Zugriff",
"sshCloneUrl": "SSH-Klon-URL",
"sshKeyRequirement": "SSH-Schlüsselanforderung",
"sshPort": "SSH-Port",
"sshUrlCopied": "SSH-URL kopiert",
"stars": "Sterne",
"startOrStopTheGiteaContainer": "Starten oder stoppen Sie den Gitea-Container",
"startServer": "Starten Sie den Server",
"startTheGitServerToBrowse": "Starten Sie den Git-Server, um Repositorys zu durchsuchen.",
"status5": "Status:",
"stopGitServer": "Stoppen Sie den Git-Server",
"stopServer": "Stoppen Sie den Server",
"stopTheGitServer": "Den Git-Server stoppen?",
"stopsGiteaDataIsPreservedUnless": "Stoppt Gitea; Die Daten bleiben erhalten, sofern sie nicht manuell entfernt werden",
"storage": "Lagerung:",
"success": "Erfolg",
"switch": "Wechseln",
"syncDirection": "Richtung synchronisieren",
"syncs": "Synchronisiert",
"testEventLogged": "Testereignis protokolliert",
"testFailed": "Test fehlgeschlagen",
"testFailed2": "Test fehlgeschlagen: {{message}}",
"thisWillInstall": "Dadurch wird Folgendes installiert:",
"triggeredBy": "Ausgelöst durch:",
"uninstall": "Deinstallieren",
"uninstallGitServer": "Git-Server deinstallieren",
"uninstallTheGiteaServerThisWill": "Den Gitea-Server deinstallieren? Dadurch wird der Container gestoppt, Ihre Daten bleiben jedoch erhalten.",
"uninstallingWillStopTheGiteaContainer": "Durch die Deinstallation wird der Gitea-Container gestoppt. Ihre Daten bleiben erhalten, sofern Sie sich nicht dazu entschließen, sie zu entfernen.",
"updated": "Aktualisiert",
"updated7d": "Aktualisiert ≤7d",
"urlCopied": "URL kopiert",
"urlPath": "URL-Pfad",
"useThisSecretWhenConfiguringThe": "Verwenden Sie dieses Geheimnis, wenn Sie den Webhook in Ihren Repository-Einstellungen konfigurieren.",
"useYourSshKey": "Verwenden Sie Ihren SSH-Schlüssel",
"viewLogs": "Protokolle anzeigen",
"visibility": "Sichtbarkeit",
"webBrowserAccess": "Zugriff über einen Webbrowser",
"webInterface": "Webschnittstelle",
"webhook": "Webhook",
"webhookCreatedSuccessfully": "Webhook erfolgreich erstellt",
"webhookDeleted": "Webhook gelöscht",
"webhookSecret": "Webhook-Geheimnis",
"webhookUrlCopied": "Webhook-URL kopiert",
"webhooks": "Webhooks",
"zeroDowntime": "Keine Ausfallzeiten",
"zeroDowntimeDeployment": "Bereitstellung ohne Ausfallzeiten"
},
"gitProviders": {
"anyGitRemote": "Jede Git-Remote",
"bitbucket": "Bitbucket",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "Schnappschüsse"
},
"storagePanel": {
"createdBucket": "Bucket „{{name}}“ erstellt",
"createdDatabase": "Datenbank „{{name}}“ erstellt",
"createdNamespace": "Namespace „{{title}}“ erstellt",
"d1Databases": "D1-Datenbanken",
"deleted": "Gelöscht",
"kvNamespaces": "KV-Namespaces",
"loadingStorage": "Speicher wird geladen…",
"r2Buckets": "R2-Eimer",
"r2IsS3CompatibleSoA": "R2 ist S3-kompatibel, sodass ein Bucket hier später ServerKit-Backups sichern kann.",
"storageUnavailable": "Speicher nicht verfügbar"
},
"styleGuide": {
"2Found": "2 gefunden",
"aNewVersionIsAvailableFor": "Eine neue Version steht zum Update zur Verfügung.",
@@ -6267,28 +5932,6 @@
"arguments": "Argumente",
"result": "Ergebnis"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Ein Cloudflare-Tunnel stellt einen lokalen oder privaten Dienst über den Edge von Cloudflare bereit – keine öffentliche IP oder offene Ports erforderlich.",
"cloudflareTunnels": "Cloudflare-Tunnel (",
"cloudflareTunnelsUnavailable": "Cloudflare-Tunnel nicht verfügbar",
"copied": "Kopiert",
"couldNotCopyTheInstallCommand": "Der Installationsbefehl konnte nicht kopiert werden",
"createACloudflareTunnel": "Erstellen Sie einen Cloudflare-Tunnel",
"createOneAboveToExposeA": "Erstellen Sie oben einen, um einen lokalen Dienst ohne öffentliche IP verfügbar zu machen.",
"createdTunnel": "Tunnel „{{name}}“ erstellt",
"deletedCloudflareTunnel": "Cloudflare-Tunnel „{{name}}“ gelöscht",
"hostnameRemoved": "Hostname entfernt",
"hostnameRouted": "Hostname weitergeleitet",
"hostnames": "Hostnamen",
"installCommand": "Befehl installieren",
"loadingCloudflareTunnels": "Cloudflare-Tunnel werden geladen…",
"noCloudflareTunnels": "Keine Cloudflare-Tunnel",
"noInstallCommandAvailable": "Kein Installationsbefehl verfügbar.",
"noPublicHostnamesRoutedYet": "Es wurden noch keine öffentlichen Hostnamen weitergeleitet.",
"routeSetButTheDnsRecord": "Route festgelegt, aber der DNS-Eintrag ist fehlgeschlagen: ",
"runThisOnceOnTheMachine": "Führen Sie dies einmal auf dem Computer aus, auf dem Ihr lokaler Dienst gehostet wird. Das Connector-Token wird nur jetzt angezeigt.",
"theHostnameShouldBeInsideThis": "Der Hostname sollte innerhalb der Domäne dieser Zone liegen; Es wird automatisch ein Proxy-DNS-Eintrag erstellt."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Tagelang kann es sein, dass sich ein neu abgedecktes Konto nur mit einem Passwort anmeldet, bevor die Registrierung erzwungen wird.",
"gracePeriodDays": "Kulanzfrist (Tage)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "Such-Widgets…",
"typesDragToRepositionAfterAdding": "{{length}}-Typen · Ziehen Sie, um sie nach dem Hinzufügen neu zu positionieren"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Fügt den Worker dieser Domäne hinzu, sodass das Skript durch entsprechenden Datenverkehr ausgeführt wird.",
"code": "Code",
"deletedWorker": "Gelöschter Arbeiter „{{name}}“",
"deploy": "Bereitstellen",
"deployAWorker": "Setzen Sie einen Worker ein",
"deployYourFirstEdgeScriptAbove": "Stellen Sie oben Ihr erstes Edge-Skript bereit.",
"deployedWorker": "Eingesetzter Arbeiter „{{name}}“",
"loadingWorkers": "Arbeiter werden geladen…",
"noRoutesYetAddARoute": "Noch keine Routen. Fügen Sie bei der Bereitstellung ein Routenmuster hinzu, um Datenverkehr an einen Worker zu senden.",
"noWorkersDeployed": "Keine Arbeiter im Einsatz",
"routePatternOptional": "Routenmuster (optional)",
"routeRemoved": "Route entfernt",
"routes": "Routen (",
"workerDeployedButTheRouteFailed": "Worker bereitgestellt, aber die Route ist fehlgeschlagen: ",
"workers": "Arbeiter (",
"workersUnavailable": "Arbeitskräfte nicht verfügbar"
},
"workspaceDetail": {
"accessGranted": "Zugriff gewährt",
"accessRevoked": "Zugriff widerrufen",
-375
View File
@@ -597,72 +597,6 @@
"serverName": "Server Name",
"serverProvisioningInitiated": "Server provisioning initiated"
},
"cloudflareWafPanel": {
"activeRules": "Active rules (",
"addAQuickRuleOrA": "Add a quick rule or a custom rule above.",
"addCustomRule": "Add custom rule",
"addRule": "Add rule",
"addedQuickRule": "Added quick rule: {{label}}",
"applyACuratedCloudflareRuleWithout": "Apply a curated Cloudflare rule without writing an expression by hand.",
"block": "Block",
"blockAdminFromOutsideTheOffice": "Block admin from outside the office",
"chooseAQuickRule": "Choose a quick rule…",
"customRule": "Custom rule",
"customRuleAdded": "Custom rule added",
"expression": "Expression",
"firewallRulesUnavailable": "Firewall rules unavailable",
"jsChallenge": "JS Challenge",
"loadingFirewallRules": "Loading firewall rules…",
"log": "Log",
"managedChallenge": "Managed Challenge",
"noCustomFirewallRules": "No custom firewall rules",
"quickRules": "Quick rules",
"ruleDeleted": "Rule deleted",
"writeYourOwnCloudflareFirewallExpression": "Write your own Cloudflare firewall expression for full control."
},
"cloudflareZoneSettings": {
"1Day": "1 day",
"1Month": "1 month",
"1Week": "1 week",
"1Year": "1 year",
"2Years": "2 years",
"6Months": "6 months",
"appliedNeedAHigherCloudflarePlan": "Applied {{applied}}/{{total}}. {{length}} need a higher Cloudflare plan.",
"appliedRecommendedHardeningSettings": "Applied recommended hardening ({{applied}} settings)",
"applyRecommendedHardening": "Apply recommended hardening",
"backToDnsZones": "Back to DNS Zones",
"changingCloudflareSettingsRequiresAnAdmin": "Changing Cloudflare settings requires an admin account.",
"clearCloudflareSCachedCopyOf": "Clear Cloudflare's cached copy of your site so visitors get fresh content. Purge everything, or list specific URLs below.",
"clearCloudflareSEntireCacheFor": "Clear Cloudflare's entire cache for {{value}}?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Cloudflare settings unavailable",
"dnsZones": "DNS Zones",
"enabled": "Enabled",
"enterAtLeastOneUrlTo": "Enter at least one URL to purge",
"failedToApplyPreset": "Failed to apply preset",
"failedToPurgeCache": "Failed to purge cache",
"failedToPurgeUrls": "Failed to purge URLs",
"failedToSaveSetting": "Failed to save setting",
"includeSubdomains": "Include subdomains",
"itReCachesThisIsSafe": "it re-caches. This is safe but can briefly increase origin load.",
"lockedByYourCloudflarePlan": "Locked by your Cloudflare plan",
"maxAge": "Max age",
"notAvailableOnThisZone": "Not available on this zone",
"off": "Off",
"preload": "Preload",
"purgeCache": "Purge cache",
"purgeEntireCache": "Purge entire cache",
"purgeEverything": "Purge everything",
"purgeSpecificUrlsOnePerLine": "Purge specific URLs (one per line, up to 30)",
"purgeUrls": "Purge URLs",
"purgedTheEntireCache": "Purged the entire cache",
"purgedUrlFromCache": "Purged {{n}} URL{{value}} from cache",
"setsFullStrictSslAlwaysUse": "Sets Full (strict) SSL, Always Use HTTPS, HSTS (6 months), a TLS 1.2 floor with TLS 1.3, Brotli, HTTP/3, and a 4-hour browser cache. Settings your plan doesn't allow are skipped.",
"settingSaved": "Setting saved",
"theNextVisitToEachPage": "The next visit to each page will be served from your origin until ",
"tunnels": "Tunnels",
"workers": "Workers"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "A CNAME for this hostname will be created in Cloudflare DNS, pointing at the tunnel.",
"askingTheAgentToStartThe": "Asking the agent to start the Cloudflare login flow…",
@@ -1864,76 +1798,6 @@
"aiSettings": "AI settings",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "A deliverability preflight (PTR, port 25, RBL) gates outbound sending before mail can leave this host.",
"accountCreated": "Account created",
"accountDeleted": "Account deleted",
"accounts": "Accounts",
"addAMailDomainFirst": "Add a mail domain first.",
"addDomain": "Add domain",
"address": "Address",
"component": "Component",
"couldNotAddDomain": "Could not add domain",
"createFailed": "Create failed",
"delete3": "Delete {{email}}?",
"deleteAccount": "Delete account",
"deleteAndAllOfItsAccounts": "Delete {{name}} and all of its accounts and aliases?",
"deleteDomain": "Delete domain",
"deleteFailed": "Delete failed",
"deployDns": "Deploy DNS",
"deployFailedNoLinkedDnsProvider": "Deploy failed (no linked DNS provider?)",
"dkimSelector": "DKIM selector:",
"dnsCheckFailed": "DNS check failed",
"dnsChecked": "DNS checked",
"dnsRecordsDeployedToProvider": "DNS records deployed to provider",
"domainAdded": "Domain added",
"domainDeleted": "Domain deleted",
"emailServerInstallStarted": "Email server install started",
"enableRelay": "Enable relay",
"enableSpamFiltering": "Enable spam filtering",
"failedTo": "Failed to {{action}} {{component}}",
"failedToDisableRelay": "Failed to disable relay",
"failedToWebmail": "Failed to {{action}} webmail",
"flushFailed": "Flush failed",
"flushQueue": "Flush queue",
"host": "Host",
"installFailed": "Install failed",
"installMailServer": "Install mail server",
"installWebmail": "Install webmail",
"mailServerNotInstalled": "Mail server not installed",
"mailboxPassword": "Mailbox password",
"mailboxUsername": "Mailbox username",
"mostPanelsNeverRunAMail": "Most panels never run a mail server, install it when you need it.",
"newMailDomain": "New mail domain",
"noMailDomainsYet": "No mail domains yet.",
"outboundRelaySmarthost": "Outbound relay (smarthost)",
"queue": "Queue",
"queueFlushed": "Queue flushed",
"quota": "Quota",
"quotaInMb": "Quota in MB",
"quotaMb": "Quota (MB)",
"recipient": "Recipient",
"relayDisabled": "Relay disabled",
"relayReachable": "Relay reachable",
"relaySaved": "Relay saved",
"relayTestFailed": "Relay test failed",
"requiredScore": "Required score",
"roundcubeWebmail": "Roundcube webmail",
"ruleUpdateFailed": "Rule update failed",
"ruleUpdateStarted": "Rule update started",
"saveFailed": "Save failed",
"searchAccounts": "Search accounts",
"searchAccountsByAddress": "Search accounts by address…",
"searchQueueBySenderRecipientOr": "Search queue by sender, recipient, or ID…",
"sender": "Sender",
"spamConfigSaved": "Spam config saved",
"spamassassin": "SpamAssassin",
"updateRules": "Update rules",
"used": "Used",
"webmail": "Webmail: {{action}}",
"webmailInstallStarted": "Webmail install started",
"webmailIsNotInstalled": "Webmail is not installed."
},
"emailProviders": {
"addedButTestFailed": "Added, but test failed: {{value}}",
"confirm": "Confirm",
@@ -2491,193 +2355,6 @@
"empty": "Empty",
"expand": "Expand"
},
"git": {
"512mbMinimum1gbRecommended": "~512MB minimum (1GB recommended)",
"5gbForDatabaseRepositories": "~5GB for database + repositories",
"access": "Access",
"accessInformation": "Access information",
"accessRepositories": "Access repositories",
"active": "Active",
"addAWebhookToSyncRepositories": "Add a webhook to sync repositories from external sources.",
"addWebhook": "Add Webhook",
"addYourPublicKeyInGitea": "Add your public key in Gitea",
"adminEmail": "Admin Email",
"adminEmailIsRequired": "Admin email is required",
"adminPasswordLeaveEmptyToAuto": "Admin Password (leave empty to auto-generate)",
"adminPasswordSaveThis": "Admin password: {{adminpassword}} (save this!)",
"adminUsername": "Admin Username",
"application": "Application",
"areYouSureYouWantTo": "Are you sure you want to delete this webhook? This cannot be undone.",
"autoGenerateSecurePassword": "Auto-generate secure password",
"autoSyncOnPushEvents": "Auto-sync on push events",
"automaticallyDeployWhenCodeIsPushed": "Automatically deploy when code is pushed to the branch",
"bidirectional": "Bidirectional",
"bitbucket": "Bitbucket",
"branch7": "Branch:",
"branches": "Branches",
"cloneRepositoriesViaSsh": "Clone repositories via SSH",
"commit": "Commit",
"commit2": "Commit:",
"commitMessage": "Commit message",
"commits": "Commits",
"components": "Components:",
"configureAWebhookWithDeployOn": "Configure a webhook with \"Deploy on Push\" enabled to see deployments here.",
"configureWebhooks": "Configure Webhooks",
"copySshUrl": "Copy SSH URL",
"copyUrl": "Copy URL",
"createYourFirstRepositoryInGitea": "Create your first repository in Gitea.",
"dangerZone": "Danger Zone",
"defaultBranch": "Default branch",
"deleteWebhook": "Delete Webhook",
"deployOnPush": "Deploy on push",
"deployToApplication": "Deploy to Application",
"deployment": "Deployment",
"deploymentHistory": "Deployment history",
"deploymentOutput": "Deployment Output",
"deploymentSettings": "Deployment Settings",
"deploymentStartedV": "Deployment started: v{{version}}",
"deploymentV": "Deployment v{{version}}",
"deploymentVLogs": "Deployment v{{version}} Logs",
"deployments": "Deployments",
"duration": "Duration:",
"error": "Error",
"error2": "Error:",
"failedToCreateWebhook": "Failed to create webhook",
"failedToCreateWebhook2": "Failed to create webhook: {{message}}",
"failedToDelete": "Failed to delete: {{message}}",
"failedToDeploy": "Failed to deploy: {{message}}",
"failedToInstall": "Failed to install: {{message}}",
"failedToLoadDeploymentDetails": "Failed to load deployment details",
"failedToLoadDirectory": "Failed to load directory",
"failedToLoadLogs": "Failed to load logs: {{message}}",
"failedToLoadMoreCommits": "Failed to load more commits",
"failedToLoadRepositoryDetails": "Failed to load repository details",
"failedToStart": "Failed to start: {{message}}",
"failedToStop": "Failed to stop: {{message}}",
"failedToSwitchBranch": "Failed to switch branch",
"failedToToggleWebhook": "Failed to toggle webhook: {{message}}",
"failedToTriggerDeployment": "Failed to trigger deployment",
"failedToUninstall": "Failed to uninstall: {{message}}",
"files": "Files",
"forks": "Forks",
"gitServerInstalledSuccessfully": "Git server installed successfully",
"gitServerStarted": "Git server started",
"gitServerStopped": "Git server stopped",
"gitServerUninstalled": "Git server uninstalled",
"giteaGitServer300mbRam": "Gitea (Git server) - ~300MB RAM",
"giteaPostgresqlDatabase": "Gitea + PostgreSQL database",
"giteaServerIsNotRunning": "Gitea server is not running",
"giteaVersion": "Gitea version",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "HTTP Access",
"httpUrl": "HTTP URL",
"iVeSavedIt": "I've Saved It",
"inactive": "Inactive",
"installGitServer": "Install Git Server",
"installGiteaToHostAndManage": "Install Gitea to host and manage your Git repositories locally.",
"installationFailed": "Installation failed",
"lastResult": "Last result",
"lastSync": "Last sync",
"leaveEmptyToUseSameName": "Leave empty to use same name",
"loadMore": "Load More",
"loadingDeployments": "Loading deployments",
"loadingGit": "Loading Git",
"loadingRepositories": "Loading repositories",
"loadingWebhooks": "Loading webhooks",
"localRepositoryNameOptional": "Local Repository Name (optional)",
"manual": "Manual",
"memory": "Memory:",
"myGithubRepo": "My GitHub Repo",
"nameAndRepositoryUrlAreRequired": "Name and repository URL are required",
"noDeploymentLogsAvailable": "No deployment logs available.",
"noDeploymentsForThisWebhook": "No deployments for this webhook.",
"noDeploymentsMatchThisFilter": "No deployments match this filter.",
"noDeploymentsYet": "No deployments yet",
"noDescription": "No description",
"noGitServerInstalled": "No Git Server Installed",
"noRepositoriesYet": "No repositories yet",
"noWebhooksConfigured": "No webhooks configured",
"noneSyncOnly": "None (sync only)",
"openGitea": "Open Gitea",
"openInGitea": "Open in Gitea",
"optionallyDeployAnApplicationWhenCode": "Optionally deploy an application when code is pushed",
"postDeployScriptOptional": "Post-deploy Script (optional)",
"postDeploymentScriptOutput": "Post-deployment Script Output",
"postgresqlDatabase200mbRam": "PostgreSQL database - ~200MB RAM",
"preDeployScriptOptional": "Pre-deploy Script (optional)",
"preDeploymentScriptOutput": "Pre-deployment Script Output",
"private": "Private",
"pullExternalLocal": "Pull (External → Local)",
"pushLocalExternal": "Push (Local → External)",
"quickActions": "Quick actions",
"recentDeployments": "Recent deployments",
"repositories": "Repositories",
"repository": "Repository",
"repositoryUrl": "Repository URL",
"repositoryUrlCopied": "Repository URL copied",
"requiredForSshClonePush": "Required for SSH clone/push",
"resourceRequirements": "Resource Requirements",
"rollback": "Rollback",
"rollbackCompleted": "Rollback completed",
"rollbackDeployment": "Rollback Deployment",
"rollbackFailed": "Rollback failed",
"rollbackFailed2": "Rollback failed: {{message}}",
"running": "Running",
"saveThisSecretItWillNot": "Save this secret! It will not be shown again.",
"secretCopied": "Secret copied",
"serverNotRunning": "Server not running",
"serverOverview": "Server overview",
"serverPower": "Server power",
"serverSettings": "Server settings",
"serverStatus": "Server status",
"sshAccess": "SSH Access",
"sshCloneUrl": "SSH Clone URL",
"sshKeyRequirement": "SSH key requirement",
"sshPort": "SSH port",
"sshUrlCopied": "SSH URL copied",
"stars": "Stars",
"startOrStopTheGiteaContainer": "Start or stop the Gitea container",
"startServer": "Start Server",
"startTheGitServerToBrowse": "Start the Git server to browse repositories.",
"status5": "Status:",
"stopGitServer": "Stop Git Server",
"stopServer": "Stop Server",
"stopTheGitServer": "Stop the Git server?",
"stopsGiteaDataIsPreservedUnless": "Stops Gitea; data is preserved unless removed manually",
"storage": "Storage:",
"success": "Success",
"switch": "Switch",
"syncDirection": "Sync Direction",
"syncs": "Syncs",
"testEventLogged": "Test event logged",
"testFailed": "Test failed",
"testFailed2": "Test failed: {{message}}",
"thisWillInstall": "This will install:",
"triggeredBy": "Triggered by:",
"uninstall": "Uninstall",
"uninstallGitServer": "Uninstall Git Server",
"uninstallTheGiteaServerThisWill": "Uninstall the Gitea server? This will stop the container but preserve your data.",
"uninstallingWillStopTheGiteaContainer": "Uninstalling will stop the Gitea container. Your data will be preserved unless you choose to remove it.",
"updated": "Updated",
"updated7d": "Updated ≤7d",
"urlCopied": "URL copied",
"urlPath": "URL path",
"useThisSecretWhenConfiguringThe": "Use this secret when configuring the webhook in your repository settings.",
"useYourSshKey": "Use your SSH key",
"viewLogs": "View Logs",
"visibility": "Visibility",
"webBrowserAccess": "Web browser access",
"webInterface": "Web interface",
"webhook": "Webhook",
"webhookCreatedSuccessfully": "Webhook created successfully",
"webhookDeleted": "Webhook deleted",
"webhookSecret": "Webhook Secret",
"webhookUrlCopied": "Webhook URL copied",
"webhooks": "Webhooks",
"zeroDowntime": "Zero downtime",
"zeroDowntimeDeployment": "Zero-downtime deployment"
},
"gitProviders": {
"anyGitRemote": "Any Git remote",
"bitbucket": "Bitbucket",
@@ -5793,18 +5470,6 @@
"storageDestinations": {
"snapshots": "Snapshots"
},
"storagePanel": {
"createdBucket": "Created bucket \"{{name}}\"",
"createdDatabase": "Created database \"{{name}}\"",
"createdNamespace": "Created namespace \"{{title}}\"",
"d1Databases": "D1 databases",
"deleted": "Deleted",
"kvNamespaces": "KV namespaces",
"loadingStorage": "Loading storage…",
"r2Buckets": "R2 buckets",
"r2IsS3CompatibleSoA": "R2 is S3-compatible, so a bucket here can back ServerKit backups later.",
"storageUnavailable": "Storage unavailable"
},
"styleGuide": {
"2Found": "2 found",
"aNewVersionIsAvailableFor": "A new version is available for update.",
@@ -6481,28 +6146,6 @@
"arguments": "Arguments",
"result": "Result"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "A Cloudflare Tunnel exposes a local or private service through Cloudflare's edge — no public IP or open ports required.",
"cloudflareTunnels": "Cloudflare Tunnels (",
"cloudflareTunnelsUnavailable": "Cloudflare Tunnels unavailable",
"copied": "Copied",
"couldNotCopyTheInstallCommand": "Could not copy the install command",
"createACloudflareTunnel": "Create a Cloudflare Tunnel",
"createOneAboveToExposeA": "Create one above to expose a local service without a public IP.",
"createdTunnel": "Created tunnel \"{{name}}\"",
"deletedCloudflareTunnel": "Deleted Cloudflare Tunnel \"{{name}}\"",
"hostnameRemoved": "Hostname removed",
"hostnameRouted": "Hostname routed",
"hostnames": "Hostnames",
"installCommand": "Install command",
"loadingCloudflareTunnels": "Loading Cloudflare Tunnels…",
"noCloudflareTunnels": "No Cloudflare Tunnels",
"noInstallCommandAvailable": "No install command available.",
"noPublicHostnamesRoutedYet": "No public hostnames routed yet.",
"routeSetButTheDnsRecord": "Route set, but the DNS record failed: ",
"runThisOnceOnTheMachine": "Run this once on the machine hosting your local service. The connector token is shown only now.",
"theHostnameShouldBeInsideThis": "The hostname should be inside this zone's domain; a proxied DNS record is created automatically."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Days a newly-covered account may keep signing in with just a password before enrolment is enforced.",
"gracePeriodDays": "Grace period (days)",
@@ -6854,24 +6497,6 @@
"searchWidgets": "Search widgets…",
"typesDragToRepositionAfterAdding": "{{length}} types · drag to reposition after adding"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Attaches the worker to this domain so matching traffic runs the script.",
"code": "Code",
"deletedWorker": "Deleted worker \"{{name}}\"",
"deploy": "Deploy",
"deployAWorker": "Deploy a Worker",
"deployYourFirstEdgeScriptAbove": "Deploy your first edge script above.",
"deployedWorker": "Deployed worker \"{{name}}\"",
"loadingWorkers": "Loading workers…",
"noRoutesYetAddARoute": "No routes yet. Add a route pattern when deploying to send traffic to a worker.",
"noWorkersDeployed": "No Workers deployed",
"routePatternOptional": "Route pattern (optional)",
"routeRemoved": "Route removed",
"routes": "Routes (",
"workerDeployedButTheRouteFailed": "Worker deployed, but the route failed: ",
"workers": "Workers (",
"workersUnavailable": "Workers unavailable"
},
"workspaceDetail": {
"accessGranted": "Access granted",
"accessRevoked": "Access revoked",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Nombre del servidor",
"serverProvisioningInitiated": "Aprovisionamiento del servidor iniciado"
},
"cloudflareWafPanel": {
"activeRules": "Reglas activas (",
"addAQuickRuleOrA": "Añade una regla rápida o una regla personalizada arriba.",
"addCustomRule": "Añadir regla personalizada",
"addRule": "Añadir regla",
"addedQuickRule": "Regla rápida añadida: {{label}}",
"applyACuratedCloudflareRuleWithout": "Aplica una regla de Cloudflare seleccionada sin escribir una expresión a mano.",
"block": "Bloquear",
"blockAdminFromOutsideTheOffice": "Bloquear el acceso de administración desde fuera de la oficina",
"chooseAQuickRule": "Elige una regla rápida…",
"customRule": "Regla personalizada",
"customRuleAdded": "Regla personalizada añadida",
"expression": "Expresión",
"firewallRulesUnavailable": "Reglas de firewall no disponibles",
"jsChallenge": "Desafío JS",
"loadingFirewallRules": "Cargando las reglas de firewall…",
"log": "Registro",
"managedChallenge": "Desafío gestionado",
"noCustomFirewallRules": "No hay reglas de firewall personalizadas",
"quickRules": "Reglas rápidas",
"ruleDeleted": "Regla eliminada",
"writeYourOwnCloudflareFirewallExpression": "Escribe tu propia expresión de firewall de Cloudflare para tener control total."
},
"cloudflareZoneSettings": {
"1Day": "1 día",
"1Month": "1 mes",
"1Week": "1 semana",
"1Year": "1 año",
"2Years": "2 años",
"6Months": "6 meses",
"appliedNeedAHigherCloudflarePlan": "Se aplicaron {{applied}}/{{total}}. {{length}} requieren un plan de Cloudflare superior.",
"appliedRecommendedHardeningSettings": "Se aplicó el refuerzo de seguridad recomendado ({{applied}} ajustes)",
"applyRecommendedHardening": "Aplicar el refuerzo de seguridad recomendado",
"backToDnsZones": "Volver a las zonas DNS",
"changingCloudflareSettingsRequiresAnAdmin": "Cambiar la configuración de Cloudflare requiere una cuenta de administrador.",
"clearCloudflareSCachedCopyOf": "Vacía la copia en caché que Cloudflare guarda de tu sitio para que los visitantes reciban contenido actualizado. Púrgalo todo o indica URL específicas a continuación.",
"clearCloudflareSEntireCacheFor": "¿Vaciar toda la caché de Cloudflare de {{value}}?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "La configuración de Cloudflare no está disponible",
"dnsZones": "Zonas DNS",
"enabled": "Activado",
"enterAtLeastOneUrlTo": "Introduce al menos una URL para purgar",
"failedToApplyPreset": "No se pudo aplicar el preajuste",
"failedToPurgeCache": "No se pudo purgar la caché",
"failedToPurgeUrls": "No se pudieron purgar las URL",
"failedToSaveSetting": "No se pudo guardar el ajuste",
"includeSubdomains": "Incluir subdominios",
"itReCachesThisIsSafe": "vuelva a guardarse en caché. Es seguro, pero puede aumentar brevemente la carga del origen.",
"lockedByYourCloudflarePlan": "Bloqueado por tu plan de Cloudflare",
"maxAge": "Edad máxima",
"notAvailableOnThisZone": "No disponible en esta zona",
"off": "Desactivado",
"preload": "Precarga",
"purgeCache": "Purgar la caché",
"purgeEntireCache": "Purgar toda la caché",
"purgeEverything": "Purgarlo todo",
"purgeSpecificUrlsOnePerLine": "Purgar URL específicas (una por línea, hasta 30)",
"purgeUrls": "Purgar URL",
"purgedTheEntireCache": "Se purgó toda la caché",
"purgedUrlFromCache": "Se purgaron {{n}} URL{{value}} de la caché",
"setsFullStrictSslAlwaysUse": "Activa SSL Full (strict), Always Use HTTPS, HSTS (6 meses), TLS 1.2 como mínimo con TLS 1.3, Brotli, HTTP/3 y una caché de navegador de 4 horas. Se omiten los ajustes que tu plan no permite.",
"settingSaved": "Ajuste guardado",
"theNextVisitToEachPage": "La próxima visita a cada página se servirá desde tu origen hasta que ",
"tunnels": "Túneles",
"workers": "Workers"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "Se creará un CNAME para este hostname en el DNS de Cloudflare, apuntando al túnel.",
"askingTheAgentToStartThe": "Pidiendo al agente que inicie el flujo de inicio de sesión de Cloudflare…",
@@ -1785,76 +1719,6 @@
"aiSettings": "Configuración de IA",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Una verificación previa de entregabilidad (PTR, puerto 25, RBL) controla el envío saliente antes de que el correo pueda salir de este host.",
"accountCreated": "Cuenta creada",
"accountDeleted": "Cuenta eliminada",
"accounts": "Cuentas",
"addAMailDomainFirst": "Añade primero un dominio de correo.",
"addDomain": "Añadir dominio",
"address": "Dirección",
"component": "Componente",
"couldNotAddDomain": "No se pudo añadir el dominio",
"createFailed": "Error al crear",
"delete3": "¿Eliminar {{email}}?",
"deleteAccount": "Eliminar cuenta",
"deleteAndAllOfItsAccounts": "¿Eliminar {{name}} y todas sus cuentas y alias?",
"deleteDomain": "Eliminar dominio",
"deleteFailed": "Error al eliminar",
"deployDns": "Desplegar DNS",
"deployFailedNoLinkedDnsProvider": "Error al desplegar (¿no hay proveedor de DNS vinculado?)",
"dkimSelector": "Selector DKIM:",
"dnsCheckFailed": "Falló la comprobación de DNS",
"dnsChecked": "DNS comprobado",
"dnsRecordsDeployedToProvider": "Registros DNS desplegados en el proveedor",
"domainAdded": "Dominio añadido",
"domainDeleted": "Dominio eliminado",
"emailServerInstallStarted": "Instalación del servidor de correo iniciada",
"enableRelay": "Activar relay",
"enableSpamFiltering": "Activar filtrado de spam",
"failedTo": "No se pudo {{action}} {{component}}",
"failedToDisableRelay": "No se pudo desactivar el relay",
"failedToWebmail": "No se pudo {{action}} el webmail",
"flushFailed": "Error al vaciar la cola",
"flushQueue": "Vaciar la cola",
"host": "Host",
"installFailed": "Error en la instalación",
"installMailServer": "Instalar servidor de correo",
"installWebmail": "Instalar webmail",
"mailServerNotInstalled": "Servidor de correo no instalado",
"mailboxPassword": "Contraseña del buzón",
"mailboxUsername": "Nombre de usuario del buzón",
"mostPanelsNeverRunAMail": "La mayoría de los paneles nunca ejecutan un servidor de correo; instálalo cuando lo necesites.",
"newMailDomain": "Nuevo dominio de correo",
"noMailDomainsYet": "Aún no hay dominios de correo.",
"outboundRelaySmarthost": "Relay saliente (smarthost)",
"queue": "Cola",
"queueFlushed": "Cola vaciada",
"quota": "Cuota",
"quotaInMb": "Cuota en MB",
"quotaMb": "Cuota (MB)",
"recipient": "Destinatario",
"relayDisabled": "Relay desactivado",
"relayReachable": "Relay accesible",
"relaySaved": "Relay guardado",
"relayTestFailed": "Falló la prueba del relay",
"requiredScore": "Puntuación requerida",
"roundcubeWebmail": "Webmail Roundcube",
"ruleUpdateFailed": "Error al actualizar las reglas",
"ruleUpdateStarted": "Actualización de reglas iniciada",
"saveFailed": "Error al guardar",
"searchAccounts": "Buscar cuentas",
"searchAccountsByAddress": "Buscar cuentas por dirección...",
"searchQueueBySenderRecipientOr": "Buscar en la cola por remitente, destinatario o ID…",
"sender": "Remitente",
"spamConfigSaved": "Configuración de spam guardada",
"spamassassin": "SpamAssassin",
"updateRules": "Actualizar reglas",
"used": "Usado",
"webmail": "Webmail: {{action}}",
"webmailInstallStarted": "Se inició la instalación del webmail",
"webmailIsNotInstalled": "El webmail no está instalado."
},
"emailProviders": {
"addedButTestFailed": "Se añadió, pero la prueba falló: {{value}}",
"confirm": "Confirmar",
@@ -2404,193 +2268,6 @@
"empty": "Vacío",
"expand": "Expandir"
},
"git": {
"512mbMinimum1gbRecommended": "~512 MB como mínimo (1 GB recomendado)",
"5gbForDatabaseRepositories": "~5 GB para base de datos + repositorios",
"access": "Acceso",
"accessInformation": "Información de acceso",
"accessRepositories": "Acceder a los repositorios",
"active": "Activo",
"addAWebhookToSyncRepositories": "Añade un webhook para sincronizar repositorios desde fuentes externas.",
"addWebhook": "Añadir webhook",
"addYourPublicKeyInGitea": "Añade tu clave pública en Gitea",
"adminEmail": "Correo del administrador",
"adminEmailIsRequired": "El correo del administrador es obligatorio",
"adminPasswordLeaveEmptyToAuto": "Contraseña del administrador (déjala vacía para generarla automáticamente)",
"adminPasswordSaveThis": "Contraseña del administrador: {{adminpassword}} (¡guárdala!)",
"adminUsername": "Nombre de usuario del administrador",
"application": "Aplicación",
"areYouSureYouWantTo": "¿Seguro que quieres eliminar este webhook? Esta acción no se puede deshacer.",
"autoGenerateSecurePassword": "Generar automáticamente una contraseña segura",
"autoSyncOnPushEvents": "Sincronizar automáticamente en eventos push",
"automaticallyDeployWhenCodeIsPushed": "Desplegar automáticamente cuando se haga push de código a la rama",
"bidirectional": "Bidireccional",
"bitbucket": "Bitbucket",
"branch7": "Rama:",
"branches": "Ramas",
"cloneRepositoriesViaSsh": "Clonar repositorios por SSH",
"commit": "Commit",
"commit2": "Commit:",
"commitMessage": "Mensaje del commit",
"commits": "Commits",
"components": "Componentes:",
"configureAWebhookWithDeployOn": "Configura un webhook con \"Desplegar al hacer push\" activado para ver los despliegues aquí.",
"configureWebhooks": "Configurar webhooks",
"copySshUrl": "Copiar URL SSH",
"copyUrl": "Copiar URL",
"createYourFirstRepositoryInGitea": "Crea tu primer repositorio en Gitea.",
"dangerZone": "Zona de peligro",
"defaultBranch": "Rama predeterminada",
"deleteWebhook": "Eliminar webhook",
"deployOnPush": "Desplegar al hacer push",
"deployToApplication": "Desplegar en la aplicación",
"deployment": "Despliegue",
"deploymentHistory": "Historial de despliegues",
"deploymentOutput": "Salida del despliegue",
"deploymentSettings": "Configuración de despliegue",
"deploymentStartedV": "Despliegue iniciado: v{{version}}",
"deploymentV": "Despliegue v{{version}}",
"deploymentVLogs": "Registros del despliegue v{{version}}",
"deployments": "Despliegues",
"duration": "Duración:",
"error": "Error",
"error2": "Error:",
"failedToCreateWebhook": "No se pudo crear el webhook",
"failedToCreateWebhook2": "No se pudo crear el webhook: {{message}}",
"failedToDelete": "No se pudo eliminar: {{message}}",
"failedToDeploy": "No se pudo desplegar: {{message}}",
"failedToInstall": "No se pudo instalar: {{message}}",
"failedToLoadDeploymentDetails": "No se pudieron cargar los detalles del despliegue",
"failedToLoadDirectory": "No se pudo cargar el directorio",
"failedToLoadLogs": "No se pudieron cargar los registros: {{message}}",
"failedToLoadMoreCommits": "No se pudieron cargar más commits",
"failedToLoadRepositoryDetails": "No se pudieron cargar los detalles del repositorio",
"failedToStart": "No se pudo iniciar: {{message}}",
"failedToStop": "No se pudo detener: {{message}}",
"failedToSwitchBranch": "No se pudo cambiar de rama",
"failedToToggleWebhook": "No se pudo cambiar el estado del webhook: {{message}}",
"failedToTriggerDeployment": "No se pudo iniciar el despliegue",
"failedToUninstall": "No se pudo desinstalar: {{message}}",
"files": "Archivos",
"forks": "Forks",
"gitServerInstalledSuccessfully": "Servidor Git instalado correctamente",
"gitServerStarted": "Servidor Git iniciado",
"gitServerStopped": "Servidor Git detenido",
"gitServerUninstalled": "Servidor Git desinstalado",
"giteaGitServer300mbRam": "Gitea (servidor Git) - ~300 MB de RAM",
"giteaPostgresqlDatabase": "Gitea + base de datos PostgreSQL",
"giteaServerIsNotRunning": "El servidor Gitea no se está ejecutando",
"giteaVersion": "Versión de Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "Acceso HTTP",
"httpUrl": "URL HTTP",
"iVeSavedIt": "Ya la guardé",
"inactive": "Inactivo",
"installGitServer": "Instalar servidor Git",
"installGiteaToHostAndManage": "Instala Gitea para alojar y gestionar tus repositorios Git de forma local.",
"installationFailed": "La instalación falló",
"lastResult": "Último resultado",
"lastSync": "Última sincronización",
"leaveEmptyToUseSameName": "Déjalo vacío para usar el mismo nombre",
"loadMore": "Cargar más",
"loadingDeployments": "Cargando despliegues",
"loadingGit": "Cargando Git",
"loadingRepositories": "Cargando repositorios",
"loadingWebhooks": "Cargando webhooks",
"localRepositoryNameOptional": "Nombre del repositorio local (opcional)",
"manual": "Manual",
"memory": "Memoria:",
"myGithubRepo": "Mi repositorio de GitHub",
"nameAndRepositoryUrlAreRequired": "El nombre y la URL del repositorio son obligatorios",
"noDeploymentLogsAvailable": "No hay registros de despliegue disponibles.",
"noDeploymentsForThisWebhook": "No hay despliegues para este webhook.",
"noDeploymentsMatchThisFilter": "Ningún despliegue coincide con este filtro.",
"noDeploymentsYet": "Aún no hay despliegues",
"noDescription": "Sin descripción",
"noGitServerInstalled": "No hay ningún servidor Git instalado",
"noRepositoriesYet": "Aún no hay repositorios",
"noWebhooksConfigured": "No hay webhooks configurados",
"noneSyncOnly": "Ninguna (solo sincronización)",
"openGitea": "Abrir Gitea",
"openInGitea": "Abrir en Gitea",
"optionallyDeployAnApplicationWhenCode": "Opcionalmente, despliega una aplicación cuando se haga push de código",
"postDeployScriptOptional": "Script posterior al despliegue (opcional)",
"postDeploymentScriptOutput": "Salida del script posterior al despliegue",
"postgresqlDatabase200mbRam": "Base de datos PostgreSQL - ~200 MB de RAM",
"preDeployScriptOptional": "Script previo al despliegue (opcional)",
"preDeploymentScriptOutput": "Salida del script previo al despliegue",
"private": "Privado",
"pullExternalLocal": "Pull (externo → local)",
"pushLocalExternal": "Push (local → externo)",
"quickActions": "Acciones rápidas",
"recentDeployments": "Despliegues recientes",
"repositories": "Repositorios",
"repository": "Repositorio",
"repositoryUrl": "URL del repositorio",
"repositoryUrlCopied": "URL del repositorio copiada",
"requiredForSshClonePush": "Necesaria para clonar/hacer push por SSH",
"resourceRequirements": "Requisitos de recursos",
"rollback": "Revertir",
"rollbackCompleted": "Reversión completada",
"rollbackDeployment": "Revertir despliegue",
"rollbackFailed": "La reversión falló",
"rollbackFailed2": "La reversión falló: {{message}}",
"running": "En ejecución",
"saveThisSecretItWillNot": "¡Guarda este secreto! No se volverá a mostrar.",
"secretCopied": "Secreto copiado",
"serverNotRunning": "El servidor no se está ejecutando",
"serverOverview": "Resumen del servidor",
"serverPower": "Encendido del servidor",
"serverSettings": "Configuración del servidor",
"serverStatus": "Estado del servidor",
"sshAccess": "Acceso SSH",
"sshCloneUrl": "URL de clonación SSH",
"sshKeyRequirement": "Requisito de clave SSH",
"sshPort": "Puerto SSH",
"sshUrlCopied": "URL SSH copiada",
"stars": "Estrellas",
"startOrStopTheGiteaContainer": "Inicia o detén el contenedor de Gitea",
"startServer": "Iniciar servidor",
"startTheGitServerToBrowse": "Inicia el servidor Git para explorar los repositorios.",
"status5": "Estado:",
"stopGitServer": "Detener el servidor Git",
"stopServer": "Detener servidor",
"stopTheGitServer": "¿Detener el servidor Git?",
"stopsGiteaDataIsPreservedUnless": "Detiene Gitea; los datos se conservan a menos que se eliminen manualmente",
"storage": "Almacenamiento:",
"success": "Éxito",
"switch": "Cambiar",
"syncDirection": "Dirección de sincronización",
"syncs": "Sincronizaciones",
"testEventLogged": "Evento de prueba registrado",
"testFailed": "La prueba falló",
"testFailed2": "La prueba falló: {{message}}",
"thisWillInstall": "Esto instalará:",
"triggeredBy": "Activado por:",
"uninstall": "Desinstalar",
"uninstallGitServer": "Desinstalar el servidor Git",
"uninstallTheGiteaServerThisWill": "¿Desinstalar el servidor Gitea? Esto detendrá el contenedor, pero conservará tus datos.",
"uninstallingWillStopTheGiteaContainer": "Desinstalar detendrá el contenedor de Gitea. Tus datos se conservarán a menos que elijas eliminarlos.",
"updated": "Actualizado",
"updated7d": "Actualizado ≤7 d",
"urlCopied": "URL copiada",
"urlPath": "Ruta de la URL",
"useThisSecretWhenConfiguringThe": "Usa este secreto al configurar el webhook en la configuración de tu repositorio.",
"useYourSshKey": "Usa tu clave SSH",
"viewLogs": "Ver registros",
"visibility": "Visibilidad",
"webBrowserAccess": "Acceso desde el navegador web",
"webInterface": "Interfaz web",
"webhook": "Webhook",
"webhookCreatedSuccessfully": "Webhook creado correctamente",
"webhookDeleted": "Webhook eliminado",
"webhookSecret": "Secreto del webhook",
"webhookUrlCopied": "URL del webhook copiada",
"webhooks": "Webhooks",
"zeroDowntime": "Sin tiempo de inactividad",
"zeroDowntimeDeployment": "Despliegue sin tiempo de inactividad"
},
"gitProviders": {
"anyGitRemote": "Cualquier remoto Git",
"bitbucket": "Bitbucket",
@@ -5596,18 +5273,6 @@
"storageDestinations": {
"snapshots": "Instantáneas"
},
"storagePanel": {
"createdBucket": "Bucket \"{{name}}\" creado",
"createdDatabase": "Base de datos \"{{name}}\" creada",
"createdNamespace": "Namespace \"{{title}}\" creado",
"d1Databases": "Bases de datos D1",
"deleted": "Eliminado",
"kvNamespaces": "Namespaces KV",
"loadingStorage": "Cargando el almacenamiento…",
"r2Buckets": "Buckets R2",
"r2IsS3CompatibleSoA": "R2 es compatible con S3, así que un bucket aquí puede respaldar las copias de seguridad de ServerKit más adelante.",
"storageUnavailable": "Almacenamiento no disponible"
},
"styleGuide": {
"2Found": "2 encontrados",
"aNewVersionIsAvailableFor": "Hay una nueva versión disponible para actualizar.",
@@ -6282,28 +5947,6 @@
"arguments": "Argumentos",
"result": "Resultado"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Un túnel de Cloudflare expone un servicio local o privado a través del edge de Cloudflare, sin necesidad de una IP pública ni puertos abiertos.",
"cloudflareTunnels": "Túneles de Cloudflare (",
"cloudflareTunnelsUnavailable": "Túneles de Cloudflare no disponibles",
"copied": "Copiado",
"couldNotCopyTheInstallCommand": "No se pudo copiar el comando de instalación",
"createACloudflareTunnel": "Crear un túnel de Cloudflare",
"createOneAboveToExposeA": "Crea uno arriba para exponer un servicio local sin una IP pública.",
"createdTunnel": "Túnel \"{{name}}\" creado",
"deletedCloudflareTunnel": "Túnel de Cloudflare \"{{name}}\" eliminado",
"hostnameRemoved": "Hostname eliminado",
"hostnameRouted": "Hostname enrutado",
"hostnames": "Hostnames",
"installCommand": "Comando de instalación",
"loadingCloudflareTunnels": "Cargando túneles de Cloudflare…",
"noCloudflareTunnels": "No hay túneles de Cloudflare",
"noInstallCommandAvailable": "No hay ningún comando de instalación disponible.",
"noPublicHostnamesRoutedYet": "Aún no hay hostnames públicos enrutados.",
"routeSetButTheDnsRecord": "Ruta configurada, pero el registro DNS falló: ",
"runThisOnceOnTheMachine": "Ejecuta esto una vez en la máquina que aloja tu servicio local. El token del conector solo se muestra ahora.",
"theHostnameShouldBeInsideThis": "El hostname debe estar dentro del dominio de esta zona; se crea automáticamente un registro DNS con proxy."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Días que una cuenta recién cubierta puede seguir iniciando sesión solo con contraseña antes de que se exija el registro.",
"gracePeriodDays": "Período de gracia (días)",
@@ -6624,24 +6267,6 @@
"searchWidgets": "Buscar widgets…",
"typesDragToRepositionAfterAdding": "{{length}} tipos · arrástralos para reordenarlos después de añadirlos"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Adjunta el worker a este dominio para que el tráfico que coincida ejecute el script.",
"code": "Código",
"deletedWorker": "Worker \"{{name}}\" eliminado",
"deploy": "Desplegar",
"deployAWorker": "Desplegar un worker",
"deployYourFirstEdgeScriptAbove": "Despliega tu primer script edge arriba.",
"deployedWorker": "Worker \"{{name}}\" desplegado",
"loadingWorkers": "Cargando workers…",
"noRoutesYetAddARoute": "Aún no hay rutas. Añade un patrón de ruta al desplegar para enviar tráfico a un worker.",
"noWorkersDeployed": "No hay workers desplegados",
"routePatternOptional": "Patrón de ruta (opcional)",
"routeRemoved": "Ruta eliminada",
"routes": "Rutas (",
"workerDeployedButTheRouteFailed": "Worker desplegado, pero la ruta falló: ",
"workers": "Workers (",
"workersUnavailable": "Workers no disponibles"
},
"workspaceDetail": {
"accessGranted": "Acceso concedido",
"accessRevoked": "Acceso revocado",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Nom du serveur",
"serverProvisioningInitiated": "Approvisionnement du serveur lancé"
},
"cloudflareWafPanel": {
"activeRules": "Règles actives (",
"addAQuickRuleOrA": "Ajoutez une règle rapide ou une règle personnalisée ci-dessus.",
"addCustomRule": "Ajouter une règle personnalisée",
"addRule": "Ajouter une règle",
"addedQuickRule": "Règle rapide ajoutée : {{label}}",
"applyACuratedCloudflareRuleWithout": "Appliquez une règle Cloudflare organisée sans écrire d’expression à la main.",
"block": "Bloc",
"blockAdminFromOutsideTheOffice": "Bloquer l'administrateur en dehors du bureau",
"chooseAQuickRule": "Choisissez une règle rapide…",
"customRule": "Règle personnalisée",
"customRuleAdded": "Règle personnalisée ajoutée",
"expression": "Expression",
"firewallRulesUnavailable": "Règles de pare-feu indisponibles",
"jsChallenge": "Défi JS",
"loadingFirewallRules": "Chargement des règles de pare-feu…",
"log": "Enregistrer",
"managedChallenge": "Défi géré",
"noCustomFirewallRules": "Aucune règle de pare-feu personnalisée",
"quickRules": "Règles rapides",
"ruleDeleted": "Règle supprimée",
"writeYourOwnCloudflareFirewallExpression": "Écrivez votre propre expression de pare-feu Cloudflare pour un contrôle total."
},
"cloudflareZoneSettings": {
"1Day": "1 jour",
"1Month": "1 mois",
"1Week": "1 semaine",
"1Year": "1 an",
"2Years": "2 ans",
"6Months": "6 mois",
"appliedNeedAHigherCloudflarePlan": "Appliqué {{applied}}/{{total}}. {{length}} nécessite un plan Cloudflare supérieur.",
"appliedRecommendedHardeningSettings": "Durcissement recommandé appliqué (paramètres {{applied}})",
"applyRecommendedHardening": "Appliquer le durcissement recommandé",
"backToDnsZones": "Retour aux zones DNS",
"changingCloudflareSettingsRequiresAnAdmin": "La modification des paramètres du Cloudflare nécessite un compte administrateur.",
"clearCloudflareSCachedCopyOf": "Effacez la copie en cache de votre site par Cloudflare afin que les visiteurs obtiennent du nouveau contenu. Purgez tout ou répertoriez les URL spécifiques ci-dessous.",
"clearCloudflareSEntireCacheFor": "Effacer tout le cache de Cloudflare pour {{value}} ?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Paramètres Cloudflare indisponibles",
"dnsZones": "Zones DNS",
"enabled": "Activé",
"enterAtLeastOneUrlTo": "Entrez au moins un URL à purger",
"failedToApplyPreset": "Échec de l'application du préréglage",
"failedToPurgeCache": "Échec de la purge du cache",
"failedToPurgeUrls": "Échec de la purge des URL",
"failedToSaveSetting": "Échec de l'enregistrement du paramètre",
"includeSubdomains": "Inclure les sous-domaines",
"itReCachesThisIsSafe": "il se remet en cache. Ceci est sûr mais peut augmenter brièvement la charge d'origine.",
"lockedByYourCloudflarePlan": "Verrouillé par votre forfait Cloudflare",
"maxAge": "Âge maximum",
"notAvailableOnThisZone": "Non disponible sur cette zone",
"off": "Désactivé",
"preload": "Préchargement",
"purgeCache": "Purger le cache",
"purgeEntireCache": "Purger tout le cache",
"purgeEverything": "Purger tout",
"purgeSpecificUrlsOnePerLine": "Purger les URL spécifiques (un par ligne, jusqu'à 30)",
"purgeUrls": "Purger les URL",
"purgedTheEntireCache": "Purgé tout le cache",
"purgedUrlFromCache": "{{n}} URL{{value}} purgé du cache",
"setsFullStrictSslAlwaysUse": "Définit SSL complet (strict), utilisez toujours HTTPS, HSTS (6 mois), un étage TLS 1.2 avec TLS 1.3, Brotli, HTTP/3 et un cache de navigateur de 4 heures. Les paramètres que votre forfait n'autorise pas sont ignorés.",
"settingSaved": "Paramètre enregistré",
"theNextVisitToEachPage": "La prochaine visite sur chaque page sera servie depuis votre origine jusqu'à",
"tunnels": "Tunnels",
"workers": "Ouvriers"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "Un CNAME pour ce nom d'hôte sera créé dans Cloudflare DNS, pointant vers le tunnel.",
"askingTheAgentToStartThe": "Demander à l'agent de démarrer le flux de connexion Cloudflare…",
@@ -1785,76 +1719,6 @@
"aiSettings": "Paramètres de l'IA",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Un contrôle en amont de la délivrabilité (PTR, port 25, RBL) contrôle l'envoi sortant avant que le courrier puisse quitter cet hôte.",
"accountCreated": "Compte créé",
"accountDeleted": "Compte supprimé",
"accounts": "Comptes",
"addAMailDomainFirst": "Ajoutez d'abord un domaine de messagerie.",
"addDomain": "Ajouter un domaine",
"address": "Adresse",
"component": "Composant",
"couldNotAddDomain": "Impossible d'ajouter le domaine",
"createFailed": "Échec de la création",
"delete3": "Supprimer {{email}} ?",
"deleteAccount": "Supprimer le compte",
"deleteAndAllOfItsAccounts": "Supprimer {{name}} et tous ses comptes et alias ?",
"deleteDomain": "Supprimer le domaine",
"deleteFailed": "Échec de la suppression",
"deployDns": "Déployer DNS",
"deployFailedNoLinkedDnsProvider": "Échec du déploiement (pas de fournisseur DNS lié ?)",
"dkimSelector": "Sélecteur DKIM :",
"dnsCheckFailed": "Échec de la vérification DNS",
"dnsChecked": "DNS vérifié",
"dnsRecordsDeployedToProvider": "Enregistrements DNS déployés vers le fournisseur",
"domainAdded": "Domaine ajouté",
"domainDeleted": "Domaine supprimé",
"emailServerInstallStarted": "L'installation du serveur de messagerie a démarré",
"enableRelay": "Activer le relais",
"enableSpamFiltering": "Activer le filtrage anti-spam",
"failedTo": "Échec de {{action}} {{component}}",
"failedToDisableRelay": "Échec de la désactivation du relais",
"failedToWebmail": "Échec de la messagerie Web {{action}}",
"flushFailed": "Échec du vidage",
"flushQueue": "Vider la file d'attente",
"host": "Hôte",
"installFailed": "L'installation a échoué",
"installMailServer": "Installer le serveur de messagerie",
"installWebmail": "Installer la messagerie Web",
"mailServerNotInstalled": "Serveur de messagerie non installé",
"mailboxPassword": "Mot de passe de la boîte aux lettres",
"mailboxUsername": "Nom d'utilisateur de la boîte aux lettres",
"mostPanelsNeverRunAMail": "La plupart des panels n'exécutent jamais de serveur de messagerie, installez-le lorsque vous en avez besoin.",
"newMailDomain": "Nouveau domaine de messagerie",
"noMailDomainsYet": "Aucun domaine de messagerie pour l'instant.",
"outboundRelaySmarthost": "Relais sortant (smarthost)",
"queue": "File d'attente",
"queueFlushed": "File d'attente vidée",
"quota": "Quota",
"quotaInMb": "Quota en Mo",
"quotaMb": "Quota (Mo)",
"recipient": "Destinataire",
"relayDisabled": "Relais désactivé",
"relayReachable": "Relais accessible",
"relaySaved": "Relais enregistré",
"relayTestFailed": "Le test du relais a échoué",
"requiredScore": "Note requise",
"roundcubeWebmail": "Messagerie Web Roundcube",
"ruleUpdateFailed": "Échec de la mise à jour des règles",
"ruleUpdateStarted": "La mise à jour des règles a commencé",
"saveFailed": "Échec de l'enregistrement",
"searchAccounts": "Rechercher des comptes",
"searchAccountsByAddress": "Rechercher des comptes par adresse...",
"searchQueueBySenderRecipientOr": "File d’attente de recherche par expéditeur, destinataire ou identifiant…",
"sender": "Expéditeur",
"spamConfigSaved": "Configuration du spam enregistrée",
"spamassassin": "SpamAssassin",
"updateRules": "Mettre à jour les règles",
"used": "Utilisé",
"webmail": "Webmail : {{action}}",
"webmailInstallStarted": "Installation de la messagerie Web démarrée",
"webmailIsNotInstalled": "Le Webmail n'est pas installé."
},
"emailProviders": {
"addedButTestFailed": "Ajouté, mais le test a échoué : {{value}}",
"confirm": "Confirmer",
@@ -2404,193 +2268,6 @@
"empty": "Vide",
"expand": "Développer"
},
"git": {
"512mbMinimum1gbRecommended": "~ 512 Mo minimum (1 Go recommandé)",
"5gbForDatabaseRepositories": "~ 5 Go pour la base de données + les référentiels",
"access": "Accéder",
"accessInformation": "Accéder aux informations",
"accessRepositories": "Accéder aux référentiels",
"active": "Actif",
"addAWebhookToSyncRepositories": "Ajoutez un webhook pour synchroniser les référentiels à partir de sources externes.",
"addWebhook": "Ajouter un webhook",
"addYourPublicKeyInGitea": "Ajoutez votre clé publique dans Gitea",
"adminEmail": "E-mail de l'administrateur",
"adminEmailIsRequired": "L'adresse e-mail de l'administrateur est requise",
"adminPasswordLeaveEmptyToAuto": "Mot de passe administrateur (laissez vide pour générer automatiquement)",
"adminPasswordSaveThis": "Mot de passe administrateur : {{adminpassword}} (conservez-le !)",
"adminUsername": "Nom d'utilisateur administrateur",
"application": "Application",
"areYouSureYouWantTo": "Êtes-vous sûr de vouloir supprimer ce webhook ? Cela ne peut pas être annulé.",
"autoGenerateSecurePassword": "Générer automatiquement un mot de passe sécurisé",
"autoSyncOnPushEvents": "Synchronisation automatique sur les événements push",
"automaticallyDeployWhenCodeIsPushed": "Déployer automatiquement lorsque le code est poussé vers la branche",
"bidirectional": "Bidirectionnel",
"bitbucket": "Bitbucket",
"branch7": "Bifurquer:",
"branches": "Succursales",
"cloneRepositoriesViaSsh": "Cloner des référentiels via SSH",
"commit": "Commettre",
"commit2": "Commettre:",
"commitMessage": "Message de validation",
"commits": "S'engage",
"components": "Composants :",
"configureAWebhookWithDeployOn": "Configurez un webhook avec « Déployer sur Push » activé pour voir les déploiements ici.",
"configureWebhooks": "Configurer les webhooks",
"copySshUrl": "Copie SSH URL",
"copyUrl": "Copier URL",
"createYourFirstRepositoryInGitea": "Créez votre premier référentiel dans Gitea.",
"dangerZone": "Zone dangereuse",
"defaultBranch": "Branche par défaut",
"deleteWebhook": "Supprimer le webhook",
"deployOnPush": "Déployer en push",
"deployToApplication": "Déployer sur l'application",
"deployment": "Déploiement",
"deploymentHistory": "Historique de déploiement",
"deploymentOutput": "Résultat du déploiement",
"deploymentSettings": "Paramètres de déploiement",
"deploymentStartedV": "Déploiement démarré : v{{version}}",
"deploymentV": "Déploiement v{{version}}",
"deploymentVLogs": "Déploiement v{{version}} Journaux",
"deployments": "Déploiements",
"duration": "Durée:",
"error": "Erreur",
"error2": "Erreur:",
"failedToCreateWebhook": "Échec de la création du webhook",
"failedToCreateWebhook2": "Échec de la création du webhook : {{message}}",
"failedToDelete": "Échec de la suppression : {{message}}",
"failedToDeploy": "Échec du déploiement : {{message}}",
"failedToInstall": "Échec de l'installation : {{message}}",
"failedToLoadDeploymentDetails": "Échec du chargement des détails du déploiement",
"failedToLoadDirectory": "Échec du chargement du répertoire",
"failedToLoadLogs": "Échec du chargement des journaux : {{message}}",
"failedToLoadMoreCommits": "Échec du chargement de plus de commits",
"failedToLoadRepositoryDetails": "Échec du chargement des détails du référentiel",
"failedToStart": "Échec du démarrage : {{message}}",
"failedToStop": "Échec de l'arrêt : {{message}}",
"failedToSwitchBranch": "Échec du changement de branche",
"failedToToggleWebhook": "Échec de l'activation du webhook : {{message}}",
"failedToTriggerDeployment": "Échec du déclenchement du déploiement",
"failedToUninstall": "Échec de la désinstallation : {{message}}",
"files": "Fichiers",
"forks": "Fourchettes",
"gitServerInstalledSuccessfully": "Le serveur Git est installé avec succès",
"gitServerStarted": "Le serveur Git a démarré",
"gitServerStopped": "Serveur Git arrêté",
"gitServerUninstalled": "Serveur Git désinstallé",
"giteaGitServer300mbRam": "Gitea (serveur Git) - ~ 300 Mo de RAM",
"giteaPostgresqlDatabase": "Base de données Gitea + PostgreSQL",
"giteaServerIsNotRunning": "Le serveur Gitea ne fonctionne pas",
"giteaVersion": "Version Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "Accès HTTP",
"httpUrl": "HTTP URL",
"iVeSavedIt": "Je l'ai sauvegardé",
"inactive": "Inactif",
"installGitServer": "Installer le serveur Git",
"installGiteaToHostAndManage": "Installez Gitea pour héberger et gérer vos référentiels Git localement.",
"installationFailed": "L'installation a échoué",
"lastResult": "Dernier résultat",
"lastSync": "Dernière synchronisation",
"leaveEmptyToUseSameName": "Laisser vide pour utiliser le même nom",
"loadMore": "Charger plus",
"loadingDeployments": "Chargement des déploiements",
"loadingGit": "Chargement Git",
"loadingRepositories": "Chargement des référentiels",
"loadingWebhooks": "Chargement des webhooks",
"localRepositoryNameOptional": "Nom du référentiel local (facultatif)",
"manual": "Manuel",
"memory": "Mémoire:",
"myGithubRepo": "Mon dépôt GitHub",
"nameAndRepositoryUrlAreRequired": "Le nom et le référentiel URL sont requis",
"noDeploymentLogsAvailable": "Aucun journal de déploiement disponible.",
"noDeploymentsForThisWebhook": "Aucun déploiement pour ce webhook.",
"noDeploymentsMatchThisFilter": "Aucun déploiement ne correspond à ce filtre.",
"noDeploymentsYet": "Aucun déploiement pour l'instant",
"noDescription": "Pas de description",
"noGitServerInstalled": "Aucun serveur Git installé",
"noRepositoriesYet": "Pas encore de référentiels",
"noWebhooksConfigured": "Aucun webhook configuré",
"noneSyncOnly": "Aucun (synchronisation uniquement)",
"openGitea": "Ouvrir Gitea",
"openInGitea": "Ouvert dans Gitea",
"optionallyDeployAnApplicationWhenCode": "Déployez éventuellement une application lorsque le code est poussé",
"postDeployScriptOptional": "Script de post-déploiement (facultatif)",
"postDeploymentScriptOutput": "Sortie du script post-déploiement",
"postgresqlDatabase200mbRam": "Base de données PostgreSQL - ~ 200 Mo de RAM",
"preDeployScriptOptional": "Script de pré-déploiement (facultatif)",
"preDeploymentScriptOutput": "Sortie du script de pré-déploiement",
"private": "Privé",
"pullExternalLocal": "Tirer (Externe → Local)",
"pushLocalExternal": "Pousser (Local → Externe)",
"quickActions": "Actions rapides",
"recentDeployments": "Déploiements récents",
"repositories": "Dépôts",
"repository": "Dépôt",
"repositoryUrl": "Référentiel URL",
"repositoryUrlCopied": "Référentiel URL copié",
"requiredForSshClonePush": "Requis pour le clonage/push SSH",
"resourceRequirements": "Besoins en ressources",
"rollback": "Restauration",
"rollbackCompleted": "Restauration terminée",
"rollbackDeployment": "Déploiement de restauration",
"rollbackFailed": "Échec de la restauration",
"rollbackFailed2": "Échec de la restauration : {{message}}",
"running": "En cours d'exécution",
"saveThisSecretItWillNot": "Gardez ce secret ! Il ne sera plus affiché.",
"secretCopied": "Secret copié",
"serverNotRunning": "Le serveur ne fonctionne pas",
"serverOverview": "Présentation du serveur",
"serverPower": "Puissance du serveur",
"serverSettings": "Paramètres du serveur",
"serverStatus": "Statut du serveur",
"sshAccess": "Accès SSH",
"sshCloneUrl": "SSH Clone URL",
"sshKeyRequirement": "Exigence de clé SSH",
"sshPort": "Port SSH",
"sshUrlCopied": "SSH URL copié",
"stars": "Étoiles",
"startOrStopTheGiteaContainer": "Démarrer ou arrêter le conteneur Gitea",
"startServer": "Démarrer le serveur",
"startTheGitServerToBrowse": "Démarrez le serveur Git pour parcourir les référentiels.",
"status5": "Statut:",
"stopGitServer": "Arrêtez le serveur Git",
"stopServer": "Arrêter le serveur",
"stopTheGitServer": "Arrêter le serveur Git ?",
"stopsGiteaDataIsPreservedUnless": "Arrête Gitea ; les données sont conservées sauf si elles sont supprimées manuellement",
"storage": "Stockage:",
"success": "Succès",
"switch": "Changer",
"syncDirection": "Direction de synchronisation",
"syncs": "Synchronisations",
"testEventLogged": "Événement de test enregistré",
"testFailed": "Le test a échoué",
"testFailed2": "Échec du test : {{message}}",
"thisWillInstall": "Cela installera :",
"triggeredBy": "Déclenché par :",
"uninstall": "Désinstaller",
"uninstallGitServer": "Désinstaller le serveur Git",
"uninstallTheGiteaServerThisWill": "Désinstaller le serveur Gitea ? Cela arrêtera le conteneur mais préservera vos données.",
"uninstallingWillStopTheGiteaContainer": "La désinstallation arrêtera le conteneur Gitea. Vos données seront conservées sauf si vous choisissez de les supprimer.",
"updated": "Mis à jour",
"updated7d": "Mis à jour ≤7j",
"urlCopied": "URL copié",
"urlPath": "Chemin URL",
"useThisSecretWhenConfiguringThe": "Utilisez ce secret lors de la configuration du webhook dans les paramètres de votre référentiel.",
"useYourSshKey": "Utilisez votre clé SSH",
"viewLogs": "Afficher les journaux",
"visibility": "Visibilité",
"webBrowserAccess": "Accès au navigateur Web",
"webInterface": "Interface Internet",
"webhook": "Webhook",
"webhookCreatedSuccessfully": "Webhook créé avec succès",
"webhookDeleted": "Webhook supprimé",
"webhookSecret": "Secret du webhook",
"webhookUrlCopied": "Webhook URL copié",
"webhooks": "Webhooks",
"zeroDowntime": "Zéro temps d'arrêt",
"zeroDowntimeDeployment": "Déploiement sans temps d'arrêt"
},
"gitProviders": {
"anyGitRemote": "Toute télécommande Git",
"bitbucket": "Bitbucket",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "Instantanés"
},
"storagePanel": {
"createdBucket": "Compartiment créé \"{{name}}\"",
"createdDatabase": "Base de données créée \"{{name}}\"",
"createdNamespace": "Espace de noms créé \"{{title}}\"",
"d1Databases": "Bases de données D1",
"deleted": "Supprimé",
"kvNamespaces": "Espaces de noms KV",
"loadingStorage": "Chargement du stockage…",
"r2Buckets": "Godets R2",
"r2IsS3CompatibleSoA": "R2 est compatible S3, donc un compartiment ici peut sauvegarder les sauvegardes ServerKit ultérieurement.",
"storageUnavailable": "Stockage indisponible"
},
"styleGuide": {
"2Found": "2 trouvés",
"aNewVersionIsAvailableFor": "Une nouvelle version est disponible pour mise à jour.",
@@ -6267,28 +5932,6 @@
"arguments": "Arguments",
"result": "Résultat"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Un tunnel Cloudflare expose un service local ou privé via la périphérie du Cloudflare — aucune adresse IP publique ni port ouvert requis.",
"cloudflareTunnels": "Tunnels Cloudflare (",
"cloudflareTunnelsUnavailable": "Cloudflare Tunnels indisponibles",
"copied": "Copié",
"couldNotCopyTheInstallCommand": "Impossible de copier la commande d'installation",
"createACloudflareTunnel": "Créer un tunnel Cloudflare",
"createOneAboveToExposeA": "Créez-en un ci-dessus pour exposer un service local sans adresse IP publique.",
"createdTunnel": "Tunnel créé \"{{name}}\"",
"deletedCloudflareTunnel": "Suppression du tunnel Cloudflare \"{{name}}\"",
"hostnameRemoved": "Nom d'hôte supprimé",
"hostnameRouted": "Nom d'hôte routé",
"hostnames": "Noms d'hôtes",
"installCommand": "Commande d'installation",
"loadingCloudflareTunnels": "Chargement des tunnels Cloudflare…",
"noCloudflareTunnels": "Pas de tunnel Cloudflare",
"noInstallCommandAvailable": "Aucune commande d'installation disponible.",
"noPublicHostnamesRoutedYet": "Aucun nom d'hôte public n'a encore été routé.",
"routeSetButTheDnsRecord": "Route définie, mais l'enregistrement DNS a échoué :",
"runThisOnceOnTheMachine": "Exécutez-le une fois sur la machine hébergeant votre service local. Le jeton de connecteur n'est affiché que maintenant.",
"theHostnameShouldBeInsideThis": "Le nom d'hôte doit se trouver dans le domaine de cette zone ; un enregistrement DNS proxy est créé automatiquement."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Jours pendant lesquels un compte nouvellement couvert peut continuer à se connecter avec juste un mot de passe avant que l'inscription ne soit appliquée.",
"gracePeriodDays": "Délai de grâce (jours)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "Rechercher des widgets…",
"typesDragToRepositionAfterAdding": "Types {{length}} · faites glisser pour repositionner après l'ajout"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Attache le travailleur à ce domaine afin que le trafic correspondant exécute le script.",
"code": "Code",
"deletedWorker": "Travailleur supprimé \"{{name}}\"",
"deploy": "Déployer",
"deployAWorker": "Déployer un travailleur",
"deployYourFirstEdgeScriptAbove": "Déployez votre premier script Edge ci-dessus.",
"deployedWorker": "Travailleur déployé \"{{name}}\"",
"loadingWorkers": "Chargement des travailleurs…",
"noRoutesYetAddARoute": "Aucun itinéraire pour l'instant. Ajoutez un modèle de routage lors du déploiement pour envoyer du trafic à un travailleur.",
"noWorkersDeployed": "Aucun travailleur déployé",
"routePatternOptional": "Modèle d'itinéraire (facultatif)",
"routeRemoved": "Itinéraire supprimé",
"routes": "Itinéraires (",
"workerDeployedButTheRouteFailed": "Worker déployé, mais la route a échoué :",
"workers": "Travailleurs (",
"workersUnavailable": "Travailleurs indisponibles"
},
"workspaceDetail": {
"accessGranted": "Accès accordé",
"accessRevoked": "Accès révoqué",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Nama Server",
"serverProvisioningInitiated": "Penyediaan server dimulai"
},
"cloudflareWafPanel": {
"activeRules": "Aturan aktif (",
"addAQuickRuleOrA": "Tambahkan aturan cepat atau aturan khusus di atas.",
"addCustomRule": "Tambahkan aturan khusus",
"addRule": "Tambahkan aturan",
"addedQuickRule": "Menambahkan aturan cepat: {{label}}",
"applyACuratedCloudflareRuleWithout": "Terapkan aturan Cloudflare yang dikurasi tanpa menulis ekspresi dengan tangan.",
"block": "Memblokir",
"blockAdminFromOutsideTheOffice": "Blokir admin dari luar kantor",
"chooseAQuickRule": "Pilih aturan cepat…",
"customRule": "Aturan adat",
"customRuleAdded": "Aturan khusus ditambahkan",
"expression": "Ekspresi",
"firewallRulesUnavailable": "Aturan firewall tidak tersedia",
"jsChallenge": "Tantangan JS",
"loadingFirewallRules": "Memuat aturan firewall…",
"log": "Catatan",
"managedChallenge": "Tantangan Terkelola",
"noCustomFirewallRules": "Tidak ada aturan firewall khusus",
"quickRules": "Aturan cepat",
"ruleDeleted": "Aturan dihapus",
"writeYourOwnCloudflareFirewallExpression": "Tulis ekspresi firewall Cloudflare Anda sendiri untuk kontrol penuh."
},
"cloudflareZoneSettings": {
"1Day": "1 hari",
"1Month": "1 bulan",
"1Week": "1 minggu",
"1Year": "1 tahun",
"2Years": "2 tahun",
"6Months": "6 bulan",
"appliedNeedAHigherCloudflarePlan": "{{applied}}/{{total}} yang diterapkan. {{length}} memerlukan paket Cloudflare yang lebih tinggi.",
"appliedRecommendedHardeningSettings": "Pengerasan yang direkomendasikan diterapkan (pengaturan {{applied}})",
"applyRecommendedHardening": "Terapkan pengerasan yang disarankan",
"backToDnsZones": "Kembali ke Zona DNS",
"changingCloudflareSettingsRequiresAnAdmin": "Mengubah pengaturan Cloudflare memerlukan akun admin.",
"clearCloudflareSCachedCopyOf": "Hapus salinan cache situs Anda Cloudflare sehingga pengunjung mendapatkan konten segar. Hapus semuanya, atau cantumkan URL tertentu di bawah.",
"clearCloudflareSEntireCacheFor": "Hapus seluruh cache Cloudflare untuk {{value}}?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Pengaturan Cloudflare tidak tersedia",
"dnsZones": "Zona DNS",
"enabled": "Diaktifkan",
"enterAtLeastOneUrlTo": "Masukkan setidaknya satu URL untuk dibersihkan",
"failedToApplyPreset": "Gagal menerapkan preset",
"failedToPurgeCache": "Gagal membersihkan cache",
"failedToPurgeUrls": "Gagal membersihkan URL",
"failedToSaveSetting": "Gagal menyimpan setelan",
"includeSubdomains": "Sertakan subdomain",
"itReCachesThisIsSafe": "itu di-cache ulang. Ini aman tetapi dapat meningkatkan muatan asal untuk sementara waktu.",
"lockedByYourCloudflarePlan": "Terkunci oleh paket Cloudflare Anda",
"maxAge": "Usia maksimal",
"notAvailableOnThisZone": "Tidak tersedia di zona ini",
"off": "Mati",
"preload": "Pramuat",
"purgeCache": "Bersihkan cache",
"purgeEntireCache": "Bersihkan seluruh cache",
"purgeEverything": "Bersihkan semuanya",
"purgeSpecificUrlsOnePerLine": "Bersihkan URL tertentu (satu per baris, hingga 30)",
"purgeUrls": "Bersihkan URL",
"purgedTheEntireCache": "Membersihkan seluruh cache",
"purgedUrlFromCache": "Menghapus {{n}} URL{{value}} dari cache",
"setsFullStrictSslAlwaysUse": "Menyetel SSL Penuh (ketat), Selalu Gunakan HTTPS, HSTS (6 bulan), TLS 1.2 lantai dengan TLS 1.3, Brotli, HTTP/3, dan cache browser 4 jam. Pengaturan yang tidak diizinkan oleh paket Anda akan dilewati.",
"settingSaved": "Pengaturan disimpan",
"theNextVisitToEachPage": "Kunjungan berikutnya ke setiap halaman akan dilayani dari asal Anda sampai",
"tunnels": "Terowongan",
"workers": "Pekerja"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "CNAME untuk nama host ini akan dibuat di Cloudflare DNS, menunjuk ke terowongan.",
"askingTheAgentToStartThe": "Meminta agen untuk memulai alur login Cloudflare…",
@@ -1785,76 +1719,6 @@
"aiSettings": "Pengaturan AI",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Preflight keterkiriman (PTR, port 25, RBL) membuka gerbang pengiriman keluar sebelum email dapat meninggalkan host ini.",
"accountCreated": "Akun dibuat",
"accountDeleted": "Akun dihapus",
"accounts": "Akun",
"addAMailDomainFirst": "Tambahkan domain email terlebih dahulu.",
"addDomain": "Tambahkan domain",
"address": "Alamat",
"component": "Komponen",
"couldNotAddDomain": "Tidak dapat menambahkan domain",
"createFailed": "Pembuatan gagal",
"delete3": "Hapus {{email}}?",
"deleteAccount": "Hapus akun",
"deleteAndAllOfItsAccounts": "Hapus {{name}} dan semua akun serta aliasnya?",
"deleteDomain": "Hapus domain",
"deleteFailed": "Penghapusan gagal",
"deployDns": "Terapkan DNS",
"deployFailedNoLinkedDnsProvider": "Penerapan gagal (tidak ada penyedia DNS yang tertaut?)",
"dkimSelector": "Pemilih DKIM:",
"dnsCheckFailed": "Pemeriksaan DNS gagal",
"dnsChecked": "DNS dicentang",
"dnsRecordsDeployedToProvider": "Catatan DNS disebarkan ke penyedia",
"domainAdded": "Domain ditambahkan",
"domainDeleted": "Domain dihapus",
"emailServerInstallStarted": "Penginstalan server email dimulai",
"enableRelay": "Aktifkan relai",
"enableSpamFiltering": "Aktifkan pemfilteran spam",
"failedTo": "Gagal {{action}} {{component}}",
"failedToDisableRelay": "Gagal menonaktifkan relai",
"failedToWebmail": "Gagal mengirim email web {{action}}",
"flushFailed": "Pembilasan gagal",
"flushQueue": "Antrian siram",
"host": "Tuan rumah",
"installFailed": "Penginstalan gagal",
"installMailServer": "Instal server email",
"installWebmail": "Instal email web",
"mailServerNotInstalled": "Server email tidak diinstal",
"mailboxPassword": "Kata sandi kotak surat",
"mailboxUsername": "Nama pengguna kotak surat",
"mostPanelsNeverRunAMail": "Kebanyakan panel tidak pernah menjalankan server email, instal saat Anda membutuhkannya.",
"newMailDomain": "Domain email baru",
"noMailDomainsYet": "Belum ada domain email.",
"outboundRelaySmarthost": "Relai keluar (smarthost)",
"queue": "Antre",
"queueFlushed": "Antrian memerah",
"quota": "Kuota",
"quotaInMb": "Kuota dalam MB",
"quotaMb": "Kuota (MB)",
"recipient": "Penerima",
"relayDisabled": "Relai dinonaktifkan",
"relayReachable": "Relai dapat dijangkau",
"relaySaved": "Relai disimpan",
"relayTestFailed": "Uji relai gagal",
"requiredScore": "Skor yang diperlukan",
"roundcubeWebmail": "Email web Roundcube",
"ruleUpdateFailed": "Pembaruan aturan gagal",
"ruleUpdateStarted": "Pembaruan aturan dimulai",
"saveFailed": "Gagal menyimpan",
"searchAccounts": "Cari akun",
"searchAccountsByAddress": "Cari akun berdasarkan alamat...",
"searchQueueBySenderRecipientOr": "Cari antrian berdasarkan pengirim, penerima, atau ID…",
"sender": "Pengirim",
"spamConfigSaved": "Konfigurasi spam disimpan",
"spamassassin": "Pembunuh Spam",
"updateRules": "Perbarui aturan",
"used": "Digunakan",
"webmail": "Email web: {{action}}",
"webmailInstallStarted": "Penginstalan email web dimulai",
"webmailIsNotInstalled": "Webmail tidak diinstal."
},
"emailProviders": {
"addedButTestFailed": "Ditambahkan, tetapi pengujian gagal: {{value}}",
"confirm": "Mengonfirmasi",
@@ -2404,193 +2268,6 @@
"empty": "Kosong",
"expand": "Memperluas"
},
"git": {
"512mbMinimum1gbRecommended": "~Minimum 512MB (disarankan 1GB)",
"5gbForDatabaseRepositories": "~5GB untuk database + repositori",
"access": "Mengakses",
"accessInformation": "Akses informasi",
"accessRepositories": "Akses repositori",
"active": "Aktif",
"addAWebhookToSyncRepositories": "Tambahkan webhook untuk menyinkronkan repositori dari sumber eksternal.",
"addWebhook": "Tambahkan Webhook",
"addYourPublicKeyInGitea": "Tambahkan kunci publik Anda di Gitea",
"adminEmail": "Email Admin",
"adminEmailIsRequired": "Email admin diperlukan",
"adminPasswordLeaveEmptyToAuto": "Kata Sandi Admin (biarkan kosong untuk dibuat secara otomatis)",
"adminPasswordSaveThis": "Kata sandi admin: {{adminpassword}} (simpan ini!)",
"adminUsername": "Nama Pengguna Admin",
"application": "Aplikasi",
"areYouSureYouWantTo": "Apakah Anda yakin ingin menghapus webhook ini? Hal ini tidak dapat dibatalkan.",
"autoGenerateSecurePassword": "Buat kata sandi aman secara otomatis",
"autoSyncOnPushEvents": "Sinkronisasi otomatis pada acara push",
"automaticallyDeployWhenCodeIsPushed": "Disebarkan secara otomatis ketika kode dikirim ke cabang",
"bidirectional": "Dua arah",
"bitbucket": "Bitbucket",
"branch7": "Cabang:",
"branches": "Cabang",
"cloneRepositoriesViaSsh": "Mengkloning repositori melalui SSH",
"commit": "Melakukan",
"commit2": "Melakukan:",
"commitMessage": "Pesan komit",
"commits": "Berkomitmen",
"components": "Komponen:",
"configureAWebhookWithDeployOn": "Konfigurasikan webhook dengan \"Deploy on Push\" yang diaktifkan untuk melihat penerapan di sini.",
"configureWebhooks": "Konfigurasikan Webhook",
"copySshUrl": "Salin SSH URL",
"copyUrl": "Salin URL",
"createYourFirstRepositoryInGitea": "Buat repositori pertama Anda di Gitea.",
"dangerZone": "Zona Bahaya",
"defaultBranch": "Cabang bawaan",
"deleteWebhook": "Hapus Webhook",
"deployOnPush": "Terapkan saat push",
"deployToApplication": "Terapkan ke Aplikasi",
"deployment": "Penyebaran",
"deploymentHistory": "Sejarah penerapan",
"deploymentOutput": "Keluaran Penerapan",
"deploymentSettings": "Pengaturan Penerapan",
"deploymentStartedV": "Penerapan dimulai: v{{version}}",
"deploymentV": "Penerapan v{{version}}",
"deploymentVLogs": "Penerapan Log v{{version}}",
"deployments": "Penerapan",
"duration": "Lamanya:",
"error": "Kesalahan",
"error2": "Kesalahan:",
"failedToCreateWebhook": "Gagal membuat webhook",
"failedToCreateWebhook2": "Gagal membuat webhook: {{message}}",
"failedToDelete": "Gagal menghapus: {{message}}",
"failedToDeploy": "Gagal menerapkan: {{message}}",
"failedToInstall": "Gagal menginstal: {{message}}",
"failedToLoadDeploymentDetails": "Gagal memuat detail penerapan",
"failedToLoadDirectory": "Gagal memuat direktori",
"failedToLoadLogs": "Gagal memuat log: {{message}}",
"failedToLoadMoreCommits": "Gagal memuat komitmen lainnya",
"failedToLoadRepositoryDetails": "Gagal memuat detail repositori",
"failedToStart": "Gagal memulai: {{message}}",
"failedToStop": "Gagal berhenti: {{message}}",
"failedToSwitchBranch": "Gagal berpindah cabang",
"failedToToggleWebhook": "Gagal mengalihkan webhook: {{message}}",
"failedToTriggerDeployment": "Gagal memicu penerapan",
"failedToUninstall": "Gagal menghapus instalan: {{message}}",
"files": "File",
"forks": "Garpu",
"gitServerInstalledSuccessfully": "Server Git berhasil diinstal",
"gitServerStarted": "Server Git dimulai",
"gitServerStopped": "Server Git berhenti",
"gitServerUninstalled": "Server Git dihapus instalasinya",
"giteaGitServer300mbRam": "Gitea (server Git) - ~RAM 300MB",
"giteaPostgresqlDatabase": "Basis data Gitea + PostgreSQL",
"giteaServerIsNotRunning": "Server Gitea tidak berjalan",
"giteaVersion": "Versi Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "Akses HTTP",
"httpUrl": "HTTP URL",
"iVeSavedIt": "Saya sudah menyimpannya",
"inactive": "Tidak aktif",
"installGitServer": "Instal Server Git",
"installGiteaToHostAndManage": "Instal Gitea untuk menghosting dan mengelola repositori Git Anda secara lokal.",
"installationFailed": "Instalasi gagal",
"lastResult": "Hasil terakhir",
"lastSync": "Sinkronisasi terakhir",
"leaveEmptyToUseSameName": "Biarkan kosong untuk menggunakan nama yang sama",
"loadMore": "Muat Lebih Banyak",
"loadingDeployments": "Memuat penerapan",
"loadingGit": "Memuat Git",
"loadingRepositories": "Memuat repositori",
"loadingWebhooks": "Memuat webhook",
"localRepositoryNameOptional": "Nama Repositori Lokal (opsional)",
"manual": "petunjuk",
"memory": "Ingatan:",
"myGithubRepo": "Repo GitHub saya",
"nameAndRepositoryUrlAreRequired": "Nama dan repositori URL diperlukan",
"noDeploymentLogsAvailable": "Tidak ada log penerapan yang tersedia.",
"noDeploymentsForThisWebhook": "Tidak ada penerapan untuk webhook ini.",
"noDeploymentsMatchThisFilter": "Tidak ada penerapan yang cocok dengan filter ini.",
"noDeploymentsYet": "Belum ada penerapan",
"noDescription": "Tidak ada deskripsi",
"noGitServerInstalled": "Tidak Ada Server Git yang Terpasang",
"noRepositoriesYet": "Belum ada repositori",
"noWebhooksConfigured": "Tidak ada webhook yang dikonfigurasi",
"noneSyncOnly": "Tidak ada (hanya sinkronisasi)",
"openGitea": "Buka Gitea",
"openInGitea": "Buka di Gitea",
"optionallyDeployAnApplicationWhenCode": "Secara opsional, terapkan aplikasi saat kode didorong",
"postDeployScriptOptional": "Skrip Pasca-penerapan (opsional)",
"postDeploymentScriptOutput": "Output Skrip Pasca Penerapan",
"postgresqlDatabase200mbRam": "Basis data PostgreSQL - ~RAM 200MB",
"preDeployScriptOptional": "Skrip Pra-penerapan (opsional)",
"preDeploymentScriptOutput": "Output Skrip Pra-penerapan",
"private": "Pribadi",
"pullExternalLocal": "Tarik (Eksternal → Lokal)",
"pushLocalExternal": "Dorong (Lokal → Eksternal)",
"quickActions": "Tindakan cepat",
"recentDeployments": "Penerapan terbaru",
"repositories": "Repositori",
"repository": "Gudang",
"repositoryUrl": "Repositori URL",
"repositoryUrlCopied": "Repositori URL disalin",
"requiredForSshClonePush": "Diperlukan untuk kloning/dorongan SSH",
"resourceRequirements": "Persyaratan Sumber Daya",
"rollback": "Kembalikan",
"rollbackCompleted": "Pengembalian selesai",
"rollbackDeployment": "Penerapan Kembalikan",
"rollbackFailed": "Pengembalian gagal",
"rollbackFailed2": "Pengembalian gagal: {{message}}",
"running": "Berlari",
"saveThisSecretItWillNot": "Simpan rahasia ini! Itu tidak akan ditampilkan lagi.",
"secretCopied": "Rahasia disalin",
"serverNotRunning": "Server tidak berjalan",
"serverOverview": "Ikhtisar server",
"serverPower": "Kekuatan server",
"serverSettings": "Pengaturan server",
"serverStatus": "Status pelayan",
"sshAccess": "Akses SSH",
"sshCloneUrl": "SSH Klon URL",
"sshKeyRequirement": "Persyaratan kunci SSH",
"sshPort": "Pelabuhan SSH",
"sshUrlCopied": "SSH URL disalin",
"stars": "Bintang",
"startOrStopTheGiteaContainer": "Memulai atau menghentikan kontainer Gitea",
"startServer": "Mulai Server",
"startTheGitServerToBrowse": "Mulai server Git untuk menelusuri repositori.",
"status5": "Status:",
"stopGitServer": "Hentikan Server Git",
"stopServer": "Hentikan Server",
"stopTheGitServer": "Hentikan server Git?",
"stopsGiteaDataIsPreservedUnless": "Menghentikan Gitea; data dipertahankan kecuali dihapus secara manual",
"storage": "Penyimpanan:",
"success": "Kesuksesan",
"switch": "Mengalihkan",
"syncDirection": "Arah Sinkronisasi",
"syncs": "Sinkronisasi",
"testEventLogged": "Peristiwa pengujian dicatat",
"testFailed": "Tes gagal",
"testFailed2": "Tes gagal: {{message}}",
"thisWillInstall": "Ini akan menginstal:",
"triggeredBy": "Dipicu oleh:",
"uninstall": "Copot pemasangan",
"uninstallGitServer": "Hapus instalasi Server Git",
"uninstallTheGiteaServerThisWill": "Copot pemasangan server Gitea? Ini akan menghentikan penampung tetapi mempertahankan data Anda.",
"uninstallingWillStopTheGiteaContainer": "Menghapus instalasi akan menghentikan penampung Gitea. Data Anda akan disimpan kecuali Anda memilih untuk menghapusnya.",
"updated": "Diperbarui",
"updated7d": "Diperbarui ≤7d",
"urlCopied": "URL disalin",
"urlPath": "Jalur URL",
"useThisSecretWhenConfiguringThe": "Gunakan rahasia ini saat mengonfigurasi webhook di pengaturan repositori Anda.",
"useYourSshKey": "Gunakan kunci SSH Anda",
"viewLogs": "Lihat Log",
"visibility": "Visibilitas",
"webBrowserAccess": "Akses peramban web",
"webInterface": "Antarmuka web",
"webhook": "kait web",
"webhookCreatedSuccessfully": "Webhook berhasil dibuat",
"webhookDeleted": "Webhook dihapus",
"webhookSecret": "Rahasia Webhook",
"webhookUrlCopied": "Webhook URL disalin",
"webhooks": "Webhook",
"zeroDowntime": "Tidak ada waktu henti",
"zeroDowntimeDeployment": "Penerapan tanpa waktu henti"
},
"gitProviders": {
"anyGitRemote": "Remote Git apa pun",
"bitbucket": "Bitbucket",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "Jepretan"
},
"storagePanel": {
"createdBucket": "Ember yang dibuat \"{{name}}\"",
"createdDatabase": "Basis data yang dibuat \"{{name}}\"",
"createdNamespace": "Ruang nama dibuat \"{{title}}\"",
"d1Databases": "database D1",
"deleted": "Dihapus",
"kvNamespaces": "Ruang nama KV",
"loadingStorage": "Memuat penyimpanan…",
"r2Buckets": "ember R2",
"r2IsS3CompatibleSoA": "R2 kompatibel dengan S3, jadi bucket di sini dapat mendukung cadangan ServerKit nanti.",
"storageUnavailable": "Penyimpanan tidak tersedia"
},
"styleGuide": {
"2Found": "2 ditemukan",
"aNewVersionIsAvailableFor": "Versi baru tersedia untuk pembaruan.",
@@ -6267,28 +5932,6 @@
"arguments": "Argumen",
"result": "Hasil"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Terowongan Cloudflare memaparkan layanan lokal atau pribadi melalui tepi Cloudflare — tidak diperlukan IP publik atau port terbuka.",
"cloudflareTunnels": "Terowongan Cloudflare (",
"cloudflareTunnelsUnavailable": "Terowongan Cloudflare tidak tersedia",
"copied": "Disalin",
"couldNotCopyTheInstallCommand": "Tidak dapat menyalin perintah instal",
"createACloudflareTunnel": "Buat Terowongan Cloudflare",
"createOneAboveToExposeA": "Buat yang di atas untuk mengekspos layanan lokal tanpa IP publik.",
"createdTunnel": "Terowongan yang dibuat \"{{name}}\"",
"deletedCloudflareTunnel": "Terowongan Cloudflare \"{{name}}\" yang Dihapus",
"hostnameRemoved": "Nama host dihapus",
"hostnameRouted": "Nama host dialihkan",
"hostnames": "Nama host",
"installCommand": "Instal perintah",
"loadingCloudflareTunnels": "Memuat Terowongan Cloudflare…",
"noCloudflareTunnels": "Tidak Ada Terowongan Cloudflare",
"noInstallCommandAvailable": "Tidak ada perintah instal yang tersedia.",
"noPublicHostnamesRoutedYet": "Belum ada nama host publik yang dirutekan.",
"routeSetButTheDnsRecord": "Rute ditetapkan, tetapi catatan DNS gagal:",
"runThisOnceOnTheMachine": "Jalankan ini sekali di mesin yang menghosting layanan lokal Anda. Token konektor hanya ditampilkan sekarang.",
"theHostnameShouldBeInsideThis": "Nama host harus berada di dalam domain zona ini; catatan DNS yang diproksi dibuat secara otomatis."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Hari dimana akun yang baru dilindungi dapat terus masuk hanya dengan kata sandi sebelum pendaftaran diterapkan.",
"gracePeriodDays": "Masa tenggang (hari)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "Cari widget…",
"typesDragToRepositionAfterAdding": "Tipe {{length}} · seret untuk memposisikan ulang setelah menambahkan"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Melampirkan pekerja ke domain ini sehingga lalu lintas yang cocok menjalankan skrip.",
"code": "Kode",
"deletedWorker": "Pekerja yang dihapus \"{{name}}\"",
"deploy": "Menyebarkan",
"deployAWorker": "Menyebarkan Pekerja",
"deployYourFirstEdgeScriptAbove": "Terapkan skrip tepi pertama Anda di atas.",
"deployedWorker": "Pekerja yang dikerahkan \"{{name}}\"",
"loadingWorkers": "Memuat pekerja…",
"noRoutesYetAddARoute": "Belum ada rute. Tambahkan pola rute saat menyebarkan untuk mengirimkan lalu lintas ke pekerja.",
"noWorkersDeployed": "Tidak ada Pekerja yang dikerahkan",
"routePatternOptional": "Pola rute (opsional)",
"routeRemoved": "Rute dihapus",
"routes": "Rute (",
"workerDeployedButTheRouteFailed": "Pekerja dikerahkan, namun rutenya gagal:",
"workers": "Pekerja (",
"workersUnavailable": "Pekerja tidak tersedia"
},
"workspaceDetail": {
"accessGranted": "Akses diberikan",
"accessRevoked": "Akses dicabut",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Nome del server",
"serverProvisioningInitiated": "Provisioning del server avviato"
},
"cloudflareWafPanel": {
"activeRules": "Regole attive (",
"addAQuickRuleOrA": "Aggiungi una regola rapida o una regola personalizzata sopra.",
"addCustomRule": "Aggiungi regola personalizzata",
"addRule": "Aggiungi regola",
"addedQuickRule": "Aggiunta regola rapida: {{label}}",
"applyACuratedCloudflareRuleWithout": "Applica una regola Cloudflare curata senza scrivere un'espressione a mano.",
"block": "Bloccare",
"blockAdminFromOutsideTheOffice": "Blocca l'amministratore dall'esterno dell'ufficio",
"chooseAQuickRule": "Scegli una regola veloce...",
"customRule": "Regola personalizzata",
"customRuleAdded": "Aggiunta regola personalizzata",
"expression": "Espressione",
"firewallRulesUnavailable": "Regole firewall non disponibili",
"jsChallenge": "Sfida JS",
"loadingFirewallRules": "Caricamento delle regole del firewall…",
"log": "Tronco d'albero",
"managedChallenge": "Sfida gestita",
"noCustomFirewallRules": "Nessuna regola firewall personalizzata",
"quickRules": "Regole veloci",
"ruleDeleted": "Regola eliminata",
"writeYourOwnCloudflareFirewallExpression": "Scrivi la tua espressione firewall Cloudflare per il pieno controllo."
},
"cloudflareZoneSettings": {
"1Day": "1 giorno",
"1Month": "1 mese",
"1Week": "1 settimana",
"1Year": "1 anno",
"2Years": "2 anni",
"6Months": "6 mesi",
"appliedNeedAHigherCloudflarePlan": "Applicato {{applied}}/{{total}}. {{length}} necessita di un piano Cloudflare superiore.",
"appliedRecommendedHardeningSettings": "Indurimento consigliato applicato (impostazioni {{applied}})",
"applyRecommendedHardening": "Applicare l'indurente consigliato",
"backToDnsZones": "Torna alle zone DNS",
"changingCloudflareSettingsRequiresAnAdmin": "La modifica delle impostazioni di Cloudflare richiede un account amministratore.",
"clearCloudflareSCachedCopyOf": "Cancella la copia cache del tuo sito di Cloudflare in modo che i visitatori ricevano nuovi contenuti. Elimina tutto o elenca gli URL specifici di seguito.",
"clearCloudflareSEntireCacheFor": "Svuotare l'intera cache di Cloudflare per {{value}}?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Impostazioni Cloudflare non disponibili",
"dnsZones": "DNS Zone",
"enabled": "Abilitato",
"enterAtLeastOneUrlTo": "Inserisci almeno uno URL per eliminare",
"failedToApplyPreset": "Impossibile applicare la preimpostazione",
"failedToPurgeCache": "Impossibile eliminare la cache",
"failedToPurgeUrls": "Impossibile eliminare URL",
"failedToSaveSetting": "Impossibile salvare l'impostazione",
"includeSubdomains": "Includi sottodomini",
"itReCachesThisIsSafe": "viene nuovamente memorizzato nella cache. Questo è sicuro ma può aumentare brevemente il carico all'origine.",
"lockedByYourCloudflarePlan": "Bloccato dal tuo piano Cloudflare",
"maxAge": "Età massima",
"notAvailableOnThisZone": "Non disponibile in questa zona",
"off": "Spento",
"preload": "Precarica",
"purgeCache": "Svuota cache",
"purgeEntireCache": "Elimina l'intera cache",
"purgeEverything": "Elimina tutto",
"purgeSpecificUrlsOnePerLine": "Elimina URL specifici (uno per linea, fino a 30)",
"purgeUrls": "Spurgare URL",
"purgedTheEntireCache": "Eliminata l'intera cache",
"purgedUrlFromCache": "Eliminato {{n}} URL{{value}} dalla cache",
"setsFullStrictSslAlwaysUse": "Set completo (ristretto) SSL, Usa sempre HTTPS, HSTS (6 mesi), un piano TLS 1.2 con TLS 1.3, Brotli, HTTP/3 e una cache del browser di 4 ore. Le impostazioni non consentite dal tuo piano vengono saltate.",
"settingSaved": "Impostazione salvata",
"theNextVisitToEachPage": "La visita successiva a ciascuna pagina verrà servita dalla tua origine fino al",
"tunnels": "Tunnel",
"workers": "Lavoratori"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "Verrà creato un CNAME per questo nome host in Cloudflare DNS, puntando al tunnel.",
"askingTheAgentToStartThe": "Richiesta all'agente di avviare il flusso di accesso Cloudflare…",
@@ -1785,76 +1719,6 @@
"aiSettings": "Impostazioni dell'IA",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Un preflight di consegnabilità (PTR, porta 25, RBL) blocca l'invio in uscita prima che la posta possa lasciare questo host.",
"accountCreated": "Conto creato",
"accountDeleted": "Conto eliminato",
"accounts": "Conti",
"addAMailDomainFirst": "Aggiungi prima un dominio di posta.",
"addDomain": "Aggiungi dominio",
"address": "Indirizzo",
"component": "Componente",
"couldNotAddDomain": "Impossibile aggiungere il dominio",
"createFailed": "Creazione fallita",
"delete3": "Eliminare {{email}}?",
"deleteAccount": "Elimina account",
"deleteAndAllOfItsAccounts": "Eliminare {{name}} e tutti i suoi account e alias?",
"deleteDomain": "Elimina dominio",
"deleteFailed": "Eliminazione non riuscita",
"deployDns": "Distribuire DNS",
"deployFailedNoLinkedDnsProvider": "Distribuzione non riuscita (nessun provider DNS collegato?)",
"dkimSelector": "Selettore DKIM:",
"dnsCheckFailed": "Controllo DNS fallito",
"dnsChecked": "DNS controllato",
"dnsRecordsDeployedToProvider": "Record DNS distribuiti al provider",
"domainAdded": "Dominio aggiunto",
"domainDeleted": "Dominio eliminato",
"emailServerInstallStarted": "Avviata l'installazione del server di posta elettronica",
"enableRelay": "Abilita relè",
"enableSpamFiltering": "Abilita il filtro antispam",
"failedTo": "Impossibile {{action}} {{component}}",
"failedToDisableRelay": "Impossibile disabilitare il relè",
"failedToWebmail": "Impossibile accedere alla webmail {{action}}",
"flushFailed": "Lo scarico non è riuscito",
"flushQueue": "Coda di scarico",
"host": "Ospite",
"installFailed": "Installazione non riuscita",
"installMailServer": "Installa il server di posta",
"installWebmail": "Installa la webmail",
"mailServerNotInstalled": "Server di posta non installato",
"mailboxPassword": "Password della casella di posta",
"mailboxUsername": "Nome utente della casella di posta",
"mostPanelsNeverRunAMail": "La maggior parte dei pannelli non esegue mai un server di posta, installalo quando ne hai bisogno.",
"newMailDomain": "Nuovo dominio di posta",
"noMailDomainsYet": "Nessun dominio di posta ancora.",
"outboundRelaySmarthost": "Inoltro in uscita (smarthost)",
"queue": "Coda",
"queueFlushed": "Coda svuotata",
"quota": "Quota",
"quotaInMb": "Quota in MB",
"quotaMb": "Quota (MB)",
"recipient": "Destinatario",
"relayDisabled": "Relè disabilitato",
"relayReachable": "Relè raggiungibile",
"relaySaved": "Relè salvato",
"relayTestFailed": "Il test dei relè è fallito",
"requiredScore": "Punteggio richiesto",
"roundcubeWebmail": "Webmail Roundcube",
"ruleUpdateFailed": "Aggiornamento della regola non riuscito",
"ruleUpdateStarted": "È iniziato l'aggiornamento delle regole",
"saveFailed": "Salvataggio non riuscito",
"searchAccounts": "Cerca account",
"searchAccountsByAddress": "Cerca account per indirizzo...",
"searchQueueBySenderRecipientOr": "Cerca nella coda per mittente, destinatario o ID...",
"sender": "Mittente",
"spamConfigSaved": "Configurazione spam salvata",
"spamassassin": "SpamAssassino",
"updateRules": "Aggiorna le regole",
"used": "Usato",
"webmail": "Webmail: {{action}}",
"webmailInstallStarted": "Avviata l'installazione della webmail",
"webmailIsNotInstalled": "La webmail non è installata."
},
"emailProviders": {
"addedButTestFailed": "Aggiunto, ma test fallito: {{value}}",
"confirm": "Confermare",
@@ -2404,193 +2268,6 @@
"empty": "Vuoto",
"expand": "Espandere"
},
"git": {
"512mbMinimum1gbRecommended": "~512 MB minimo (consigliato 1 GB)",
"5gbForDatabaseRepositories": "~5 GB per database + repository",
"access": "Accesso",
"accessInformation": "Accedi alle informazioni",
"accessRepositories": "Accedi ai repository",
"active": "Attivo",
"addAWebhookToSyncRepositories": "Aggiungi un webhook per sincronizzare i repository da fonti esterne.",
"addWebhook": "Aggiungi webhook",
"addYourPublicKeyInGitea": "Aggiungi la tua chiave pubblica in Gitea",
"adminEmail": "E-mail dell'amministratore",
"adminEmailIsRequired": "È richiesta l'e-mail dell'amministratore",
"adminPasswordLeaveEmptyToAuto": "Password amministratore (lascia vuoto per la generazione automatica)",
"adminPasswordSaveThis": "Password amministratore: {{adminpassword}} (salvala!)",
"adminUsername": "Nome utente amministratore",
"application": "Applicazione",
"areYouSureYouWantTo": "Sei sicuro di voler eliminare questo webhook? Questa operazione non può essere annullata.",
"autoGenerateSecurePassword": "Genera automaticamente una password sicura",
"autoSyncOnPushEvents": "Sincronizzazione automatica sugli eventi push",
"automaticallyDeployWhenCodeIsPushed": "Distribuisci automaticamente quando il codice viene inviato al ramo",
"bidirectional": "Bidirezionale",
"bitbucket": "Bitbucket",
"branch7": "Ramo:",
"branches": "Rami",
"cloneRepositoriesViaSsh": "Clonare repository tramite SSH",
"commit": "Impegnarsi",
"commit2": "Impegnarsi:",
"commitMessage": "Invia messaggio",
"commits": "Si impegna",
"components": "Componenti:",
"configureAWebhookWithDeployOn": "Configura un webhook con \"Deploy on Push\" abilitato per vedere le distribuzioni qui.",
"configureWebhooks": "Configura webhook",
"copySshUrl": "Copia SSH URL",
"copyUrl": "Copia URL",
"createYourFirstRepositoryInGitea": "Crea il tuo primo repository in Gitea.",
"dangerZone": "Zona pericolosa",
"defaultBranch": "Ramo predefinito",
"deleteWebhook": "Elimina webhook",
"deployOnPush": "Distribuisci in modalità push",
"deployToApplication": "Distribuisci all'applicazione",
"deployment": "Distribuzione",
"deploymentHistory": "Cronologia della distribuzione",
"deploymentOutput": "Output di distribuzione",
"deploymentSettings": "Impostazioni di distribuzione",
"deploymentStartedV": "Distribuzione iniziata: v{{version}}",
"deploymentV": "Distribuzione v{{version}}",
"deploymentVLogs": "Registri di distribuzione v{{version}}",
"deployments": "Distribuzioni",
"duration": "Durata:",
"error": "Errore",
"error2": "Errore:",
"failedToCreateWebhook": "Impossibile creare il webhook",
"failedToCreateWebhook2": "Impossibile creare il webhook: {{message}}",
"failedToDelete": "Impossibile eliminare: {{message}}",
"failedToDeploy": "Impossibile distribuire: {{message}}",
"failedToInstall": "Impossibile installare: {{message}}",
"failedToLoadDeploymentDetails": "Impossibile caricare i dettagli della distribuzione",
"failedToLoadDirectory": "Impossibile caricare la directory",
"failedToLoadLogs": "Impossibile caricare i registri: {{message}}",
"failedToLoadMoreCommits": "Impossibile caricare altri commit",
"failedToLoadRepositoryDetails": "Impossibile caricare i dettagli del repository",
"failedToStart": "Impossibile avviare: {{message}}",
"failedToStop": "Impossibile arrestare: {{message}}",
"failedToSwitchBranch": "Impossibile cambiare ramo",
"failedToToggleWebhook": "Impossibile attivare/disattivare il webhook: {{message}}",
"failedToTriggerDeployment": "Impossibile attivare la distribuzione",
"failedToUninstall": "Impossibile disinstallare: {{message}}",
"files": "File",
"forks": "Forchette",
"gitServerInstalledSuccessfully": "Server Git installato correttamente",
"gitServerStarted": "Server Git avviato",
"gitServerStopped": "Server Git arrestato",
"gitServerUninstalled": "Server Git disinstallato",
"giteaGitServer300mbRam": "Gitea (server Git) - ~300 MB di RAM",
"giteaPostgresqlDatabase": "Banca dati Gitea + PostgreSQL",
"giteaServerIsNotRunning": "Il server Gitea non è in esecuzione",
"giteaVersion": "Versione Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "HTTP Accesso",
"httpUrl": "HTTP URL",
"iVeSavedIt": "L'ho salvato",
"inactive": "Inattivo",
"installGitServer": "Installa il server Git",
"installGiteaToHostAndManage": "Installa Gitea per ospitare e gestire localmente i tuoi repository Git.",
"installationFailed": "Installazione non riuscita",
"lastResult": "Ultimo risultato",
"lastSync": "Ultima sincronizzazione",
"leaveEmptyToUseSameName": "Lascia vuoto per utilizzare lo stesso nome",
"loadMore": "Carica altro",
"loadingDeployments": "Caricamento delle distribuzioni",
"loadingGit": "Caricamento in corso Git",
"loadingRepositories": "Caricamento dei repository",
"loadingWebhooks": "Caricamento webhook",
"localRepositoryNameOptional": "Nome del repository locale (facoltativo)",
"manual": "Manuale",
"memory": "Memoria:",
"myGithubRepo": "Il mio repository GitHub",
"nameAndRepositoryUrlAreRequired": "Nome e repository URL sono obbligatori",
"noDeploymentLogsAvailable": "Nessun log di distribuzione disponibile.",
"noDeploymentsForThisWebhook": "Nessuna distribuzione per questo webhook.",
"noDeploymentsMatchThisFilter": "Nessuna distribuzione corrisponde a questo filtro.",
"noDeploymentsYet": "Ancora nessuna distribuzione",
"noDescription": "Nessuna descrizione",
"noGitServerInstalled": "Nessun server Git installato",
"noRepositoriesYet": "Nessun repository ancora",
"noWebhooksConfigured": "Nessun webhook configurato",
"noneSyncOnly": "Nessuno (solo sincronizzazione)",
"openGitea": "Apri Gitea",
"openInGitea": "Apri in Gitea",
"optionallyDeployAnApplicationWhenCode": "Facoltativamente, distribuire un'applicazione quando viene inviato il codice",
"postDeployScriptOptional": "Script post-distribuzione (facoltativo)",
"postDeploymentScriptOutput": "Output dello script post-distribuzione",
"postgresqlDatabase200mbRam": "Database PostgreSQL - ~200 MB di RAM",
"preDeployScriptOptional": "Script di pre-distribuzione (facoltativo)",
"preDeploymentScriptOutput": "Output dello script pre-distribuzione",
"private": "Privato",
"pullExternalLocal": "Pull (Esterno → Locale)",
"pushLocalExternal": "Push (Locale → Esterno)",
"quickActions": "Azioni rapide",
"recentDeployments": "Implementazioni recenti",
"repositories": "Repository",
"repository": "Deposito",
"repositoryUrl": "Deposito URL",
"repositoryUrlCopied": "Repository URL copiato",
"requiredForSshClonePush": "Richiesto per clone/push SSH",
"resourceRequirements": "Requisiti delle risorse",
"rollback": "Rollback",
"rollbackCompleted": "Rollback completato",
"rollbackDeployment": "Distribuzione di rollback",
"rollbackFailed": "Rollback non riuscito",
"rollbackFailed2": "Rollback non riuscito: {{message}}",
"running": "Corsa",
"saveThisSecretItWillNot": "Salva questo segreto! Non verrà mostrato nuovamente.",
"secretCopied": "Copia segreta",
"serverNotRunning": "Server non in esecuzione",
"serverOverview": "Panoramica del server",
"serverPower": "Potenza del server",
"serverSettings": "Impostazioni del server",
"serverStatus": "Stato del server",
"sshAccess": "SSH Accesso",
"sshCloneUrl": "SSH Clona URL",
"sshKeyRequirement": "Requisito chiave SSH",
"sshPort": "Attacco SSH",
"sshUrlCopied": "SSH URL copiato",
"stars": "Stelle",
"startOrStopTheGiteaContainer": "Avvia o arresta il contenitore Gitea",
"startServer": "Avvia Server",
"startTheGitServerToBrowse": "Avviare il server Git per sfogliare i repository.",
"status5": "Stato:",
"stopGitServer": "Arrestare il server Git",
"stopServer": "Arresta il server",
"stopTheGitServer": "Arrestare il server Git?",
"stopsGiteaDataIsPreservedUnless": "Arresti Gitea; i dati vengono conservati a meno che non vengano rimossi manualmente",
"storage": "Magazzinaggio:",
"success": "Successo",
"switch": "Interruttore",
"syncDirection": "Sincronizza direzione",
"syncs": "Sincronizzazioni",
"testEventLogged": "Evento di prova registrato",
"testFailed": "Prova fallita",
"testFailed2": "Test fallito: {{message}}",
"thisWillInstall": "Questo installerà:",
"triggeredBy": "Innescato da:",
"uninstall": "Disinstallare",
"uninstallGitServer": "Disinstallare il server Git",
"uninstallTheGiteaServerThisWill": "Disinstallare il server Gitea? Ciò interromperà il contenitore ma preserverà i tuoi dati.",
"uninstallingWillStopTheGiteaContainer": "La disinstallazione interromperà il contenitore Gitea. I tuoi dati verranno conservati a meno che tu non scelga di rimuoverli.",
"updated": "Aggiornato",
"updated7d": "Aggiornato ≤7d",
"urlCopied": "URL copiato",
"urlPath": "Percorso URL",
"useThisSecretWhenConfiguringThe": "Utilizza questo segreto durante la configurazione del webhook nelle impostazioni del repository.",
"useYourSshKey": "Usa la tua chiave SSH",
"viewLogs": "Visualizza registri",
"visibility": "Visibilità",
"webBrowserAccess": "Accesso al browser Web",
"webInterface": "Interfaccia web",
"webhook": "Webhook",
"webhookCreatedSuccessfully": "Webhook creato correttamente",
"webhookDeleted": "Webhook eliminato",
"webhookSecret": "Segreto del webhook",
"webhookUrlCopied": "Webhook URL copiato",
"webhooks": "Webhook",
"zeroDowntime": "Zero tempi di inattività",
"zeroDowntimeDeployment": "Distribuzione senza tempi di inattività"
},
"gitProviders": {
"anyGitRemote": "Qualsiasi telecomando Git",
"bitbucket": "Bitbucket",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "Istantanee"
},
"storagePanel": {
"createdBucket": "Benna creata \"{{name}}\"",
"createdDatabase": "Creato il database \"{{name}}\"",
"createdNamespace": "Spazio dei nomi creato \"{{title}}\"",
"d1Databases": "Banche dati D1",
"deleted": "Eliminato",
"kvNamespaces": "Spazi dei nomi KV",
"loadingStorage": "Caricamento spazio di archiviazione…",
"r2Buckets": "Benne R2",
"r2IsS3CompatibleSoA": "R2 è compatibile con S3, quindi un bucket qui può eseguire i backup ServerKit in un secondo momento.",
"storageUnavailable": "Spazio di archiviazione non disponibile"
},
"styleGuide": {
"2Found": "2 trovati",
"aNewVersionIsAvailableFor": "Una nuova versione è disponibile per l'aggiornamento.",
@@ -6267,28 +5932,6 @@
"arguments": "Argomenti",
"result": "Risultato"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Un tunnel Cloudflare espone un servizio locale o privato attraverso il perimetro di Cloudflare: non sono richiesti IP pubblici o porte aperte.",
"cloudflareTunnels": "Cloudflare Tunnel (",
"cloudflareTunnelsUnavailable": "Cloudflare Tunnel non disponibili",
"copied": "Copiato",
"couldNotCopyTheInstallCommand": "Impossibile copiare il comando di installazione",
"createACloudflareTunnel": "Crea un tunnel Cloudflare",
"createOneAboveToExposeA": "Creane uno qui sopra per esporre un servizio locale senza un IP pubblico.",
"createdTunnel": "Tunnel creato \"{{name}}\"",
"deletedCloudflareTunnel": "Tunnel Cloudflare eliminato \"{{name}}\"",
"hostnameRemoved": "Nome host rimosso",
"hostnameRouted": "Nome host instradato",
"hostnames": "Nomi host",
"installCommand": "Installa il comando",
"loadingCloudflareTunnels": "Caricamento tunnel Cloudflare…",
"noCloudflareTunnels": "Nessun tunnel Cloudflare",
"noInstallCommandAvailable": "Nessun comando di installazione disponibile.",
"noPublicHostnamesRoutedYet": "Nessun nome host pubblico ancora instradato.",
"routeSetButTheDnsRecord": "Percorso impostato, ma il record DNS non è riuscito:",
"runThisOnceOnTheMachine": "Eseguilo una volta sul computer che ospita il tuo servizio locale. Il token del connettore viene visualizzato solo ora.",
"theHostnameShouldBeInsideThis": "Il nome host dovrebbe trovarsi all'interno del dominio di questa zona; un record DNS proxy viene creato automaticamente."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Giorni in cui un account appena coperto può continuare ad accedere solo con una password prima che venga applicata la registrazione.",
"gracePeriodDays": "Periodo di grazia (giorni)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "Cerca widget…",
"typesDragToRepositionAfterAdding": "Tipi {{length}} · trascinare per riposizionare dopo l'aggiunta"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Collega il lavoratore a questo dominio in modo che il traffico corrispondente esegua lo script.",
"code": "Codice",
"deletedWorker": "Lavoratore eliminato \"{{name}}\"",
"deploy": "Distribuire",
"deployAWorker": "Schierare un lavoratore",
"deployYourFirstEdgeScriptAbove": "Distribuisci il tuo primo script edge sopra.",
"deployedWorker": "Operaio distaccato \"{{name}}\"",
"loadingWorkers": "Caricamento dei lavoratori…",
"noRoutesYetAddARoute": "Nessun percorso ancora. Aggiungi un modello di percorso durante la distribuzione per inviare traffico a un lavoratore.",
"noWorkersDeployed": "Nessun lavoratore schierato",
"routePatternOptional": "Schema del percorso (opzionale)",
"routeRemoved": "Itinerario rimosso",
"routes": "Percorsi (",
"workerDeployedButTheRouteFailed": "Worker distribuito, ma il percorso non è riuscito:",
"workers": "Lavoratori (",
"workersUnavailable": "Lavoratori indisponibili"
},
"workspaceDetail": {
"accessGranted": "Accesso concesso",
"accessRevoked": "Accesso revocato",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "서버 이름",
"serverProvisioningInitiated": "서버 프로비저닝이 시작되었습니다."
},
"cloudflareWafPanel": {
"activeRules": "활성 규칙(",
"addAQuickRuleOrA": "위에 빠른 규칙이나 맞춤 규칙을 추가하세요.",
"addCustomRule": "맞춤 규칙 추가",
"addRule": "규칙 추가",
"addedQuickRule": "빠른 규칙 추가됨: {{label}}",
"applyACuratedCloudflareRuleWithout": "표현식을 직접 작성하지 않고도 선별된 Cloudflare 규칙을 적용할 수 있습니다.",
"block": "블록",
"blockAdminFromOutsideTheOffice": "사무실 외부의 관리자 차단",
"chooseAQuickRule": "빠른 규칙을 선택하세요…",
"customRule": "맞춤 규칙",
"customRuleAdded": "맞춤 규칙이 추가되었습니다.",
"expression": "표현",
"firewallRulesUnavailable": "방화벽 규칙을 사용할 수 없습니다.",
"jsChallenge": "JS 챌린지",
"loadingFirewallRules": "방화벽 규칙 로드 중…",
"log": "로그",
"managedChallenge": "관리형 챌린지",
"noCustomFirewallRules": "맞춤 방화벽 규칙 없음",
"quickRules": "빠른 규칙",
"ruleDeleted": "규칙이 삭제되었습니다.",
"writeYourOwnCloudflareFirewallExpression": "완전한 제어를 위해 Cloudflare 방화벽 표현을 직접 작성하세요."
},
"cloudflareZoneSettings": {
"1Day": "1일",
"1Month": "1개월",
"1Week": "1주",
"1Year": "1년",
"2Years": "2년",
"6Months": "6개월",
"appliedNeedAHigherCloudflarePlan": "{{applied}}/{{total}}개를 적용했습니다. {{length}}에는 더 높은 Cloudflare 요금제가 필요합니다.",
"appliedRecommendedHardeningSettings": "권장 강화 적용({{applied}} 설정)",
"applyRecommendedHardening": "권장 강화 적용",
"backToDnsZones": "DNS 영역으로 돌아가기",
"changingCloudflareSettingsRequiresAnAdmin": "Cloudflare 설정을 변경하려면 관리자 계정이 필요합니다.",
"clearCloudflareSCachedCopyOf": "방문자가 새로운 콘텐츠를 얻을 수 있도록 Cloudflare의 캐시된 사이트 사본을 삭제하세요. 모든 항목을 삭제하거나 아래에 특정 URL을 나열하세요.",
"clearCloudflareSEntireCacheFor": "{{value}}에 대한 Cloudflare의 전체 캐시를 지우시겠습니까?",
"cloudflare": "클라우드플레어",
"cloudflareSettingsUnavailable": "Cloudflare 설정을 사용할 수 없습니다.",
"dnsZones": "DNS 영역",
"enabled": "활성화됨",
"enterAtLeastOneUrlTo": "삭제할 URL을 하나 이상 입력하세요.",
"failedToApplyPreset": "사전 설정을 적용하지 못했습니다.",
"failedToPurgeCache": "캐시를 삭제하지 못했습니다.",
"failedToPurgeUrls": "URL을 삭제하지 못했습니다.",
"failedToSaveSetting": "설정을 저장하지 못했습니다.",
"includeSubdomains": "하위 도메인 포함",
"itReCachesThisIsSafe": "다시 캐시됩니다. 이는 안전하지만 원점 부하를 일시적으로 증가시킬 수 있습니다.",
"lockedByYourCloudflarePlan": "Cloudflare 요금제에 의해 잠김",
"maxAge": "최대 연령",
"notAvailableOnThisZone": "이 구역에서는 사용할 수 없습니다.",
"off": "끄기",
"preload": "예압",
"purgeCache": "캐시 제거",
"purgeEntireCache": "전체 캐시 제거",
"purgeEverything": "모든 것을 제거하세요",
"purgeSpecificUrlsOnePerLine": "특정 URL 삭제(한 줄에 하나씩, 최대 30개)",
"purgeUrls": "URL 제거",
"purgedTheEntireCache": "전체 캐시를 삭제했습니다.",
"purgedUrlFromCache": "캐시에서 {{n}} URL{{value}}을(를) 삭제했습니다.",
"setsFullStrictSslAlwaysUse": "전체(엄격) SSL, 항상 HTTPS 사용, HSTS(6개월), TLS 1.3이 포함된 TLS 1.2층, Brotli, HTTP/3 및 4시간 브라우저 캐시를 설정합니다. 계획에서 허용하지 않는 설정은 건너뜁니다.",
"settingSaved": "설정이 저장되었습니다.",
"theNextVisitToEachPage": "각 페이지에 대한 다음 방문은 원본에서 다음까지 제공됩니다. ",
"tunnels": "터널",
"workers": "노동자"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "이 호스트 이름에 대한 CNAME은 터널을 가리키는 Cloudflare DNS에 생성됩니다.",
"askingTheAgentToStartThe": "에이전트에게 Cloudflare 로그인 흐름을 시작하도록 요청하는 중…",
@@ -1785,76 +1719,6 @@
"aiSettings": "AI 설정",
"serverkitAi": "서버킷 AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "배달 가능성 프리플라이트(PTR, 포트 25, RBL)는 메일이 이 호스트를 떠나기 전에 아웃바운드 전송을 제어합니다.",
"accountCreated": "계정이 생성되었습니다",
"accountDeleted": "계정이 삭제되었습니다",
"accounts": "계정",
"addAMailDomainFirst": "먼저 메일 도메인을 추가하세요.",
"addDomain": "도메인 추가",
"address": "주소",
"component": "구성 요소",
"couldNotAddDomain": "도메인을 추가할 수 없습니다.",
"createFailed": "생성 실패",
"delete3": "{{email}}을(를) 삭제하시겠습니까?",
"deleteAccount": "계정 삭제",
"deleteAndAllOfItsAccounts": "{{name}} 및 해당 계정과 별칭을 모두 삭제하시겠습니까?",
"deleteDomain": "도메인 삭제",
"deleteFailed": "삭제 실패",
"deployDns": "DNS 배포",
"deployFailedNoLinkedDnsProvider": "배포 실패(연결된 DNS 공급자가 없습니까?)",
"dkimSelector": "DKIM 선택기:",
"dnsCheckFailed": "DNS 확인 실패",
"dnsChecked": "DNS 확인됨",
"dnsRecordsDeployedToProvider": "공급자에 배포된 DNS 레코드",
"domainAdded": "도메인이 추가됨",
"domainDeleted": "도메인이 삭제되었습니다.",
"emailServerInstallStarted": "이메일 서버 설치가 시작되었습니다.",
"enableRelay": "릴레이 활성화",
"enableSpamFiltering": "스팸 필터링 활성화",
"failedTo": "{{action}} {{component}} 실패",
"failedToDisableRelay": "릴레이를 비활성화하지 못했습니다.",
"failedToWebmail": "{{action}} 웹메일을 보내지 못했습니다.",
"flushFailed": "플러시 실패",
"flushQueue": "대기열 플러시",
"host": "호스트",
"installFailed": "설치 실패",
"installMailServer": "메일 서버 설치",
"installWebmail": "웹메일 설치",
"mailServerNotInstalled": "메일 서버가 설치되지 않았습니다",
"mailboxPassword": "사서함 비밀번호",
"mailboxUsername": "사서함 사용자 이름",
"mostPanelsNeverRunAMail": "대부분의 패널은 메일 서버를 실행하지 않으므로 필요할 때 설치하십시오.",
"newMailDomain": "새 메일 도메인",
"noMailDomainsYet": "아직 메일 도메인이 없습니다.",
"outboundRelaySmarthost": "아웃바운드 릴레이(스마트호스트)",
"queue": "대기열",
"queueFlushed": "대기열이 플러시되었습니다.",
"quota": "할당량",
"quotaInMb": "할당량(MB)",
"quotaMb": "할당량(MB)",
"recipient": "수신자",
"relayDisabled": "릴레이 비활성화됨",
"relayReachable": "릴레이 연결 가능",
"relaySaved": "릴레이 저장됨",
"relayTestFailed": "릴레이 테스트 실패",
"requiredScore": "필수 점수",
"roundcubeWebmail": "Roundcube 웹메일",
"ruleUpdateFailed": "규칙 업데이트 실패",
"ruleUpdateStarted": "규칙 업데이트가 시작되었습니다.",
"saveFailed": "저장 실패",
"searchAccounts": "계정 검색",
"searchAccountsByAddress": "주소로 계정 검색...",
"searchQueueBySenderRecipientOr": "발신자, 수신자, ID별로 대기열 검색…",
"sender": "보낸 사람",
"spamConfigSaved": "스팸 구성이 저장되었습니다.",
"spamassassin": "스팸어새신",
"updateRules": "업데이트 규칙",
"used": "중고",
"webmail": "웹메일: {{action}}",
"webmailInstallStarted": "웹메일 설치가 시작되었습니다",
"webmailIsNotInstalled": "웹메일이 설치되지 않았습니다."
},
"emailProviders": {
"addedButTestFailed": "추가되었으나 테스트 실패: {{value}}",
"confirm": "확인",
@@ -2404,193 +2268,6 @@
"empty": "비어 있음",
"expand": "펼치기"
},
"git": {
"512mbMinimum1gbRecommended": "최소 ~512MB(1GB 권장)",
"5gbForDatabaseRepositories": "데이터베이스 + 리포지토리의 경우 ~5GB",
"access": "액세스",
"accessInformation": "액세스 정보",
"accessRepositories": "저장소에 액세스",
"active": "활성",
"addAWebhookToSyncRepositories": "외부 소스의 저장소를 동기화하려면 웹후크를 추가하세요.",
"addWebhook": "웹훅 추가",
"addYourPublicKeyInGitea": "Gitea에 공개 키 추가",
"adminEmail": "관리자 이메일",
"adminEmailIsRequired": "관리자 이메일이 필요합니다",
"adminPasswordLeaveEmptyToAuto": "관리자 비밀번호(자동 생성하려면 비워두세요)",
"adminPasswordSaveThis": "관리자 비밀번호: {{adminpassword}} (이것을 저장하세요!)",
"adminUsername": "관리자 사용자 이름",
"application": "신청",
"areYouSureYouWantTo": "이 웹훅을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"autoGenerateSecurePassword": "보안 비밀번호 자동 생성",
"autoSyncOnPushEvents": "푸시 이벤트 시 자동 동기화",
"automaticallyDeployWhenCodeIsPushed": "코드가 브랜치에 푸시되면 자동으로 배포",
"bidirectional": "양방향",
"bitbucket": "비트버킷",
"branch7": "지점:",
"branches": "지점",
"cloneRepositoriesViaSsh": "SSH를 통한 복제 저장소",
"commit": "커밋",
"commit2": "커밋:",
"commitMessage": "커밋 메시지",
"commits": "커밋",
"components": "구성요소:",
"configureAWebhookWithDeployOn": "여기에서 배포를 보려면 '푸시 시 배포'가 활성화된 웹후크를 구성하세요.",
"configureWebhooks": "웹훅 구성",
"copySshUrl": "SSH URL 복사",
"copyUrl": "URL 복사",
"createYourFirstRepositoryInGitea": "Gitea에서 첫 번째 저장소를 만드세요.",
"dangerZone": "위험지대",
"defaultBranch": "기본 지점",
"deleteWebhook": "웹훅 삭제",
"deployOnPush": "푸시 시 배포",
"deployToApplication": "애플리케이션에 배포",
"deployment": "배포",
"deploymentHistory": "배포 기록",
"deploymentOutput": "배포 출력",
"deploymentSettings": "배포 설정",
"deploymentStartedV": "배포 시작됨: v{{version}}",
"deploymentV": "배포 v{{version}}",
"deploymentVLogs": "배포 v{{version}} 로그",
"deployments": "배포",
"duration": "기간:",
"error": "오류",
"error2": "오류:",
"failedToCreateWebhook": "웹훅을 생성하지 못했습니다.",
"failedToCreateWebhook2": "웹훅 생성 실패: {{message}}",
"failedToDelete": "삭제 실패: {{message}}",
"failedToDeploy": "배포 실패: {{message}}",
"failedToInstall": "설치 실패: {{message}}",
"failedToLoadDeploymentDetails": "배포 세부정보를 로드하지 못했습니다.",
"failedToLoadDirectory": "디렉터리를 로드하지 못했습니다.",
"failedToLoadLogs": "로그를 로드하지 못했습니다: {{message}}",
"failedToLoadMoreCommits": "추가 커밋을 로드하지 못했습니다.",
"failedToLoadRepositoryDetails": "저장소 세부정보를 로드하지 못했습니다.",
"failedToStart": "시작 실패: {{message}}",
"failedToStop": "중지 실패: {{message}}",
"failedToSwitchBranch": "지점 전환 실패",
"failedToToggleWebhook": "웹훅 전환 실패: {{message}}",
"failedToTriggerDeployment": "배포를 트리거하지 못했습니다.",
"failedToUninstall": "제거 실패: {{message}}",
"files": "파일",
"forks": "포크",
"gitServerInstalledSuccessfully": "Git 서버가 성공적으로 설치되었습니다.",
"gitServerStarted": "힘내 서버가 시작되었습니다",
"gitServerStopped": "힘내 서버가 중지되었습니다",
"gitServerUninstalled": "Git 서버가 제거되었습니다.",
"giteaGitServer300mbRam": "Gitea(Git 서버) - ~300MB RAM",
"giteaPostgresqlDatabase": "Gitea + PostgreSQL 데이터베이스",
"giteaServerIsNotRunning": "Gitea 서버가 실행되고 있지 않습니다",
"giteaVersion": "Gitea 버전",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "HTTP 액세스",
"httpUrl": "HTTP URL",
"iVeSavedIt": "저장했어요",
"inactive": "비활성",
"installGitServer": "힘내 서버 설치",
"installGiteaToHostAndManage": "Git 저장소를 로컬에서 호스팅하고 관리하려면 Gitea를 설치하세요.",
"installationFailed": "설치 실패",
"lastResult": "마지막 결과",
"lastSync": "마지막 동기화",
"leaveEmptyToUseSameName": "동일한 이름을 사용하려면 비워 두세요.",
"loadMore": "더 로드하기",
"loadingDeployments": "배포 로드 중",
"loadingGit": "힘내 로딩",
"loadingRepositories": "저장소 로드 중",
"loadingWebhooks": "웹훅 로드 중",
"localRepositoryNameOptional": "로컬 저장소 이름(선택 사항)",
"manual": "매뉴얼",
"memory": "메모리:",
"myGithubRepo": "내 GitHub 레포",
"nameAndRepositoryUrlAreRequired": "이름과 저장소 URL이 필요합니다.",
"noDeploymentLogsAvailable": "사용 가능한 배포 로그가 없습니다.",
"noDeploymentsForThisWebhook": "이 웹훅에 대한 배포가 없습니다.",
"noDeploymentsMatchThisFilter": "이 필터와 일치하는 배포가 없습니다.",
"noDeploymentsYet": "아직 배포가 없습니다.",
"noDescription": "설명 없음",
"noGitServerInstalled": "Git 서버가 설치되지 않았습니다.",
"noRepositoriesYet": "아직 저장소가 없습니다.",
"noWebhooksConfigured": "구성된 웹훅이 없습니다.",
"noneSyncOnly": "없음(동기화만)",
"openGitea": "오픈 기테아",
"openInGitea": "기테아에서 열기",
"optionallyDeployAnApplicationWhenCode": "코드가 푸시되면 선택적으로 애플리케이션 배포",
"postDeployScriptOptional": "배포 후 스크립트(선택 사항)",
"postDeploymentScriptOutput": "배포 후 스크립트 출력",
"postgresqlDatabase200mbRam": "PostgreSQL 데이터베이스 - ~200MB RAM",
"preDeployScriptOptional": "사전 배포 스크립트(선택 사항)",
"preDeploymentScriptOutput": "배포 전 스크립트 출력",
"private": "비공개",
"pullExternalLocal": "풀(외부 → 로컬)",
"pushLocalExternal": "푸시(로컬 → 외부)",
"quickActions": "빠른 작업",
"recentDeployments": "최근 배포",
"repositories": "저장소",
"repository": "저장소",
"repositoryUrl": "저장소 URL",
"repositoryUrlCopied": "저장소 URL이 복사되었습니다.",
"requiredForSshClonePush": "SSH 복제/푸시에 필요",
"resourceRequirements": "리소스 요구 사항",
"rollback": "롤백",
"rollbackCompleted": "롤백 완료",
"rollbackDeployment": "롤백 배포",
"rollbackFailed": "롤백 실패",
"rollbackFailed2": "롤백 실패: {{message}}",
"running": "달리기",
"saveThisSecretItWillNot": "이 비밀을 저장하세요! 다시 표시되지 않습니다.",
"secretCopied": "비밀번호가 복사되었습니다.",
"serverNotRunning": "서버가 실행되지 않음",
"serverOverview": "서버 개요",
"serverPower": "서버 파워",
"serverSettings": "서버 설정",
"serverStatus": "서버 상태",
"sshAccess": "SSH 액세스",
"sshCloneUrl": "SSH 복제 URL",
"sshKeyRequirement": "SSH 키 요구 사항",
"sshPort": "SSH 포트",
"sshUrlCopied": "SSH URL이 복사되었습니다.",
"stars": "별",
"startOrStopTheGiteaContainer": "Gitea 컨테이너 시작 또는 중지",
"startServer": "서버 시작",
"startTheGitServerToBrowse": "Git 서버를 시작하여 리포지토리를 찾아보세요.",
"status5": "상태:",
"stopGitServer": "Git 서버 중지",
"stopServer": "서버 중지",
"stopTheGitServer": "Git 서버를 중지하시겠습니까?",
"stopsGiteaDataIsPreservedUnless": "Gitea를 중지합니다. 데이터는 수동으로 제거하지 않는 한 보존됩니다.",
"storage": "저장:",
"success": "성공",
"switch": "스위치",
"syncDirection": "동기화 방향",
"syncs": "동기화",
"testEventLogged": "테스트 이벤트가 기록되었습니다.",
"testFailed": "테스트 실패",
"testFailed2": "테스트 실패: {{message}}",
"thisWillInstall": "그러면 다음이 설치됩니다:",
"triggeredBy": "트리거 원인:",
"uninstall": "제거",
"uninstallGitServer": "Git 서버 제거",
"uninstallTheGiteaServerThisWill": "Gitea 서버를 제거하시겠습니까? 이렇게 하면 컨테이너가 중지되지만 데이터는 보존됩니다.",
"uninstallingWillStopTheGiteaContainer": "제거하면 Gitea 컨테이너가 중지됩니다. 귀하의 데이터는 귀하가 제거를 선택하지 않는 한 보존됩니다.",
"updated": "업데이트됨",
"updated7d": "7일 이하로 업데이트됨",
"urlCopied": "URL 복사됨",
"urlPath": "URL 경로",
"useThisSecretWhenConfiguringThe": "저장소 설정에서 웹훅을 구성할 때 이 비밀을 사용하세요.",
"useYourSshKey": "SSH 키 사용",
"viewLogs": "로그 보기",
"visibility": "가시성",
"webBrowserAccess": "웹 브라우저 접속",
"webInterface": "웹 인터페이스",
"webhook": "웹훅",
"webhookCreatedSuccessfully": "웹훅이 성공적으로 생성되었습니다.",
"webhookDeleted": "웹훅이 삭제되었습니다.",
"webhookSecret": "웹훅 비밀",
"webhookUrlCopied": "웹훅 URL이 복사되었습니다.",
"webhooks": "웹훅",
"zeroDowntime": "다운타임 없음",
"zeroDowntimeDeployment": "가동 중지 시간 없는 배포"
},
"gitProviders": {
"anyGitRemote": "모든 Git 원격",
"bitbucket": "비트버킷",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "스냅샷"
},
"storagePanel": {
"createdBucket": "\"{{name}}\" 버킷을 생성했습니다.",
"createdDatabase": "\"{{name}}\" 데이터베이스를 생성했습니다.",
"createdNamespace": "\"{{title}}\" 네임스페이스를 생성했습니다.",
"d1Databases": "D1 데이터베이스",
"deleted": "삭제됨",
"kvNamespaces": "KV 네임스페이스",
"loadingStorage": "저장용량 로드 중…",
"r2Buckets": "R2 버킷",
"r2IsS3CompatibleSoA": "R2는 S3와 호환되므로 여기 버킷은 나중에 ServerKit 백업을 백업할 수 있습니다.",
"storageUnavailable": "저장공간을 사용할 수 없음"
},
"styleGuide": {
"2Found": "2개 발견됨",
"aNewVersionIsAvailableFor": "업데이트할 수 있는 새 버전이 있습니다.",
@@ -6267,28 +5932,6 @@
"arguments": "인수",
"result": "결과"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Cloudflare Tunnel은 Cloudflare의 에지를 통해 로컬 또는 개인 서비스를 노출합니다. 공용 IP나 개방형 포트가 필요하지 않습니다.",
"cloudflareTunnels": "Cloudflare 터널(",
"cloudflareTunnelsUnavailable": "Cloudflare 터널을 사용할 수 없습니다.",
"copied": "복사됨",
"couldNotCopyTheInstallCommand": "설치 명령을 복사할 수 없습니다.",
"createACloudflareTunnel": "Cloudflare 터널 만들기",
"createOneAboveToExposeA": "공개 IP 없이 로컬 서비스를 노출하려면 위에서 하나를 만드세요.",
"createdTunnel": "\"{{name}}\" 터널을 생성했습니다.",
"deletedCloudflareTunnel": "Cloudflare Tunnel \"{{name}}\"을(를) 삭제했습니다.",
"hostnameRemoved": "호스트 이름이 삭제되었습니다.",
"hostnameRouted": "호스트 이름이 라우팅되었습니다.",
"hostnames": "호스트 이름",
"installCommand": "설치 명령",
"loadingCloudflareTunnels": "Cloudflare 터널을 로드하는 중…",
"noCloudflareTunnels": "Cloudflare 터널 없음",
"noInstallCommandAvailable": "사용 가능한 설치 명령이 없습니다.",
"noPublicHostnamesRoutedYet": "아직 라우팅된 공개 호스트 이름이 없습니다.",
"routeSetButTheDnsRecord": "경로가 설정되었지만 DNS 레코드가 실패했습니다. ",
"runThisOnceOnTheMachine": "로컬 서비스를 호스팅하는 머신에서 이를 한 번 실행하세요. 커넥터 토큰은 지금만 표시됩니다.",
"theHostnameShouldBeInsideThis": "호스트 이름은 이 영역의 도메인 내에 있어야 합니다. 프록시된 DNS 레코드가 자동으로 생성됩니다."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "새로 등록된 계정은 등록이 시행되기 전에 비밀번호만으로 계속 로그인할 수 있습니다.",
"gracePeriodDays": "유예기간(일)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "위젯 검색…",
"typesDragToRepositionAfterAdding": "{{length}} 유형 · 추가 후 드래그하여 위치를 변경하세요"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "일치하는 트래픽이 스크립트를 실행하도록 작업자를 이 도메인에 연결합니다.",
"code": "코드",
"deletedWorker": "삭제된 작업자 \"{{name}}\"",
"deploy": "배포",
"deployAWorker": "작업자 배포",
"deployYourFirstEdgeScriptAbove": "위의 첫 번째 Edge 스크립트를 배포하세요.",
"deployedWorker": "배포된 작업자 \"{{name}}\"",
"loadingWorkers": "작업자 로드 중…",
"noRoutesYetAddARoute": "아직 경로가 없습니다. 작업자에게 트래픽을 보내려면 배포 시 경로 패턴을 추가하세요.",
"noWorkersDeployed": "배치된 작업자 없음",
"routePatternOptional": "경로 패턴(선택사항)",
"routeRemoved": "경로가 삭제되었습니다.",
"routes": "노선(",
"workerDeployedButTheRouteFailed": "작업자가 배포되었지만 경로가 실패했습니다. ",
"workers": "노동자(",
"workersUnavailable": "근로자 이용 불가"
},
"workspaceDetail": {
"accessGranted": "액세스 권한이 부여됨",
"accessRevoked": "액세스가 취소되었습니다.",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Nazwa serwera",
"serverProvisioningInitiated": "Rozpoczęto udostępnianie serwera"
},
"cloudflareWafPanel": {
"activeRules": "Aktywne reguły (",
"addAQuickRuleOrA": "Dodaj szybką regułę lub regułę niestandardową powyżej.",
"addCustomRule": "Dodaj niestandardową regułę",
"addRule": "Dodaj regułę",
"addedQuickRule": "Dodano szybką regułę: {{label}}",
"applyACuratedCloudflareRuleWithout": "Zastosuj wybraną regułę Cloudflare bez ręcznego wpisywania wyrażeń.",
"block": "Blok",
"blockAdminFromOutsideTheOffice": "Zablokuj administratora spoza biura",
"chooseAQuickRule": "Wybierz szybką regułę…",
"customRule": "Niestandardowa reguła",
"customRuleAdded": "Dodano niestandardową regułę",
"expression": "Wyrażenie",
"firewallRulesUnavailable": "Reguły zapory są niedostępne",
"jsChallenge": "Wyzwanie JS",
"loadingFirewallRules": "Ładowanie reguł zapory sieciowej…",
"log": "Dziennik",
"managedChallenge": "Zarządzane wyzwanie",
"noCustomFirewallRules": "Brak niestandardowych reguł zapory sieciowej",
"quickRules": "Szybkie zasady",
"ruleDeleted": "Reguła usunięta",
"writeYourOwnCloudflareFirewallExpression": "Napisz własne wyrażenie zapory ogniowej Cloudflare, aby uzyskać pełną kontrolę."
},
"cloudflareZoneSettings": {
"1Day": "1 dzień",
"1Month": "1 miesiąc",
"1Week": "1 tydzień",
"1Year": "1 rok",
"2Years": "2 lata",
"6Months": "6 miesięcy",
"appliedNeedAHigherCloudflarePlan": "Zastosowano {{applied}}/{{total}}. {{length}} potrzebuje wyższego planu Cloudflare.",
"appliedRecommendedHardeningSettings": "Zastosowano zalecane hartowanie (ustawienia {{applied}})",
"applyRecommendedHardening": "Zastosuj zalecane utwardzanie",
"backToDnsZones": "Powrót do Stref DNS",
"changingCloudflareSettingsRequiresAnAdmin": "Zmiana ustawień Cloudflare wymaga konta administratora.",
"clearCloudflareSCachedCopyOf": "Wyczyść kopię swojej witryny w pamięci podręcznej Cloudflare, aby odwiedzający mogli uzyskać świeżą zawartość. Usuń wszystko lub wypisz poniżej konkretne URL.",
"clearCloudflareSEntireCacheFor": "Wyczyścić całą pamięć podręczną Cloudflare dla {{value}}?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Ustawienia Cloudflare są niedostępne",
"dnsZones": "DNS Strefy",
"enabled": "Włączony",
"enterAtLeastOneUrlTo": "Wprowadź co najmniej jeden URL do usunięcia",
"failedToApplyPreset": "Nie udało się zastosować ustawienia wstępnego",
"failedToPurgeCache": "Nie udało się wyczyścić pamięci podręcznej",
"failedToPurgeUrls": "Nie udało się usunąć URL",
"failedToSaveSetting": "Nie udało się zapisać ustawienia",
"includeSubdomains": "Uwzględnij subdomeny",
"itReCachesThisIsSafe": "ponownie buforuje. Jest to bezpieczne, ale może na krótko zwiększyć obciążenie początkowe.",
"lockedByYourCloudflarePlan": "Zablokowane przez Twój plan Cloudflare",
"maxAge": "Maksymalny wiek",
"notAvailableOnThisZone": "Niedostępne w tej strefie",
"off": "Wyłączony",
"preload": "Wstępnie załaduj",
"purgeCache": "Wyczyść pamięć podręczną",
"purgeEntireCache": "Wyczyść całą pamięć podręczną",
"purgeEverything": "Oczyść wszystko",
"purgeSpecificUrlsOnePerLine": "Przeczyść określone URL (po jednym na linię, do 30)",
"purgeUrls": "Usuń URL",
"purgedTheEntireCache": "Wyczyszczono całą pamięć podręczną",
"purgedUrlFromCache": "Usunięto {{n}} URL{{value}} z pamięci podręcznej",
"setsFullStrictSslAlwaysUse": "Ustawia pełny (ścisły) SSL, zawsze używaj HTTPS, HSTS (6 miesięcy), podłogę TLS 1.2 z TLS 1.3, Brotli, HTTP/3 i 4-godzinną pamięć podręczną przeglądarki. Ustawienia, na które nie pozwala Twój plan, są pomijane.",
"settingSaved": "Ustawienie zapisane",
"theNextVisitToEachPage": "Następna wizyta na każdej stronie będzie obsługiwana od Twojego pochodzenia do",
"tunnels": "Tunele",
"workers": "Pracownicy"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "CNAME dla tej nazwy hosta zostanie utworzona w Cloudflare DNS, wskazując na tunel.",
"askingTheAgentToStartThe": "Prośba do agenta o rozpoczęcie procesu logowania do Cloudflare…",
@@ -1785,76 +1719,6 @@
"aiSettings": "Ustawienia AI",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Wstępna inspekcja dostarczalności (PTR, port 25, RBL) blokuje wysyłanie wychodzące, zanim poczta będzie mogła opuścić ten host.",
"accountCreated": "Konto utworzone",
"accountDeleted": "Konto usunięte",
"accounts": "Konta",
"addAMailDomainFirst": "Najpierw dodaj domenę pocztową.",
"addDomain": "Dodaj domenę",
"address": "Adres",
"component": "Część",
"couldNotAddDomain": "Nie można dodać domeny",
"createFailed": "Tworzenie nie powiodło się",
"delete3": "Usunąć {{email}}?",
"deleteAccount": "Usuń konto",
"deleteAndAllOfItsAccounts": "Usunąć {{name}} i wszystkie jego konta i aliasy?",
"deleteDomain": "Usuń domenę",
"deleteFailed": "Usunięcie nie powiodło się",
"deployDns": "Wdróż DNS",
"deployFailedNoLinkedDnsProvider": "Wdrożenie nie powiodło się (brak powiązanego dostawcy DNS?)",
"dkimSelector": "Selektor DKIM:",
"dnsCheckFailed": "Kontrola DNS nie powiodła się",
"dnsChecked": "DNS sprawdzone",
"dnsRecordsDeployedToProvider": "Rekordy DNS wdrożone u dostawcy",
"domainAdded": "Domena dodana",
"domainDeleted": "Domena usunięta",
"emailServerInstallStarted": "Rozpoczęto instalację serwera poczty e-mail",
"enableRelay": "Włącz przekaźnik",
"enableSpamFiltering": "Włącz filtrowanie spamu",
"failedTo": "Nie udało się {{action}} {{component}}",
"failedToDisableRelay": "Nie udało się wyłączyć przekaźnika",
"failedToWebmail": "Nie udało się uzyskać dostępu do poczty internetowej {{action}}",
"flushFailed": "Spłukiwanie nie powiodło się",
"flushQueue": "Spłucz kolejkę",
"host": "Gospodarz",
"installFailed": "Instalacja nie powiodła się",
"installMailServer": "Zainstaluj serwer pocztowy",
"installWebmail": "Zainstaluj pocztę internetową",
"mailServerNotInstalled": "Serwer pocztowy nie jest zainstalowany",
"mailboxPassword": "Hasło do skrzynki pocztowej",
"mailboxUsername": "Nazwa użytkownika skrzynki pocztowej",
"mostPanelsNeverRunAMail": "Większość paneli nigdy nie uruchamia serwera pocztowego, instaluj go, kiedy go potrzebujesz.",
"newMailDomain": "Nowa domena pocztowa",
"noMailDomainsYet": "Nie ma jeszcze domen pocztowych.",
"outboundRelaySmarthost": "Przekaźnik wychodzący (smarthost)",
"queue": "Kolejka",
"queueFlushed": "Kolejka opróżniona",
"quota": "Kontyngent",
"quotaInMb": "Limit w MB",
"quotaMb": "Limit (MB)",
"recipient": "Odbiorca",
"relayDisabled": "Przekaźnik wyłączony",
"relayReachable": "Przekaźnik osiągalny",
"relaySaved": "Przekaźnik zapisany",
"relayTestFailed": "Test przekaźnika nie powiódł się",
"requiredScore": "Wymagany wynik",
"roundcubeWebmail": "Poczta internetowa Roundcube",
"ruleUpdateFailed": "Aktualizacja reguły nie powiodła się",
"ruleUpdateStarted": "Rozpoczęto aktualizację reguły",
"saveFailed": "Zapisywanie nie powiodło się",
"searchAccounts": "Przeszukaj konta",
"searchAccountsByAddress": "Wyszukaj konta według adresu...",
"searchQueueBySenderRecipientOr": "Przeszukaj kolejkę według nadawcy, odbiorcy lub identyfikatora…",
"sender": "Nadawca",
"spamConfigSaved": "Konfiguracja spamu została zapisana",
"spamassassin": "Zabójca spamu",
"updateRules": "Aktualizuj zasady",
"used": "Używany",
"webmail": "Poczta internetowa: {{action}}",
"webmailInstallStarted": "Rozpoczęto instalację poczty internetowej",
"webmailIsNotInstalled": "Poczta internetowa nie jest zainstalowana."
},
"emailProviders": {
"addedButTestFailed": "Dodano, ale test nie powiódł się: {{value}}",
"confirm": "Potwierdzać",
@@ -2404,193 +2268,6 @@
"empty": "Pusty",
"expand": "Zwiększać"
},
"git": {
"512mbMinimum1gbRecommended": "Minimum ~512 MB (zalecane 1 GB)",
"5gbForDatabaseRepositories": "~5 GB na bazę danych + repozytoria",
"access": "Dostęp",
"accessInformation": "Dostęp do informacji",
"accessRepositories": "Dostęp do repozytoriów",
"active": "Aktywny",
"addAWebhookToSyncRepositories": "Dodaj webhook, aby synchronizować repozytoria ze źródeł zewnętrznych.",
"addWebhook": "Dodaj webhooka",
"addYourPublicKeyInGitea": "Dodaj swój klucz publiczny w Gitea",
"adminEmail": "Adres e-mail administratora",
"adminEmailIsRequired": "Adres e-mail administratora jest wymagany",
"adminPasswordLeaveEmptyToAuto": "Hasło administratora (pozostaw puste, aby wygenerować automatycznie)",
"adminPasswordSaveThis": "Hasło administratora: {{adminpassword}} (zapisz to!)",
"adminUsername": "Nazwa użytkownika administratora",
"application": "Aplikacja",
"areYouSureYouWantTo": "Czy na pewno chcesz usunąć tego webhooka? Tego nie można cofnąć.",
"autoGenerateSecurePassword": "Automatycznie generuj bezpieczne hasło",
"autoSyncOnPushEvents": "Automatyczna synchronizacja zdarzeń push",
"automaticallyDeployWhenCodeIsPushed": "Wdróż automatycznie, gdy kod zostanie wypchnięty do gałęzi",
"bidirectional": "Dwukierunkowy",
"bitbucket": "Bitbucketa",
"branch7": "Oddział:",
"branches": "Gałęzie",
"cloneRepositoriesViaSsh": "Klonuj repozytoria za pomocą SSH",
"commit": "Popełniać",
"commit2": "Popełniać:",
"commitMessage": "Zatwierdź wiadomość",
"commits": "Zatwierdza",
"components": "Komponenty:",
"configureAWebhookWithDeployOn": "Aby zobaczyć tutaj wdrożenia, skonfiguruj webhook z włączoną opcją „Wdrażaj w trybie Push”.",
"configureWebhooks": "Skonfiguruj webhooki",
"copySshUrl": "Skopiuj SSH URL",
"copyUrl": "Skopiuj URL",
"createYourFirstRepositoryInGitea": "Utwórz swoje pierwsze repozytorium w Gitea.",
"dangerZone": "Strefa zagrożenia",
"defaultBranch": "Domyślna gałąź",
"deleteWebhook": "Usuń webhooka",
"deployOnPush": "Wdrażaj w trybie push",
"deployToApplication": "Wdróż w aplikacji",
"deployment": "Zastosowanie",
"deploymentHistory": "Historia wdrożeń",
"deploymentOutput": "Dane wyjściowe wdrożenia",
"deploymentSettings": "Ustawienia wdrożenia",
"deploymentStartedV": "Rozpoczęto wdrażanie: v{{version}}",
"deploymentV": "Wdrożenie v{{version}}",
"deploymentVLogs": "Dzienniki wdrożenia v{{version}}",
"deployments": "Wdrożenia",
"duration": "Czas trwania:",
"error": "Błąd",
"error2": "Błąd:",
"failedToCreateWebhook": "Nie udało się utworzyć webhooka",
"failedToCreateWebhook2": "Nie udało się utworzyć webhooka: {{message}}",
"failedToDelete": "Nie udało się usunąć: {{message}}",
"failedToDeploy": "Nie udało się wdrożyć: {{message}}",
"failedToInstall": "Nie udało się zainstalować: {{message}}",
"failedToLoadDeploymentDetails": "Nie udało się wczytać szczegółów wdrożenia",
"failedToLoadDirectory": "Nie udało się załadować katalogu",
"failedToLoadLogs": "Nie udało się załadować dzienników: {{message}}",
"failedToLoadMoreCommits": "Nie udało się załadować więcej zatwierdzeń",
"failedToLoadRepositoryDetails": "Nie udało się załadować szczegółów repozytorium",
"failedToStart": "Nie udało się uruchomić: {{message}}",
"failedToStop": "Nie udało się zatrzymać: {{message}}",
"failedToSwitchBranch": "Nie udało się przełączyć oddziału",
"failedToToggleWebhook": "Nie udało się przełączyć webhooka: {{message}}",
"failedToTriggerDeployment": "Nie udało się uruchomić wdrożenia",
"failedToUninstall": "Nie udało się odinstalować: {{message}}",
"files": "Akta",
"forks": "Widelce",
"gitServerInstalledSuccessfully": "Serwer Git został pomyślnie zainstalowany",
"gitServerStarted": "Serwer Git został uruchomiony",
"gitServerStopped": "Serwer Git został zatrzymany",
"gitServerUninstalled": "Odinstalowano serwer Git",
"giteaGitServer300mbRam": "Gitea (serwer Git) - ~300MB RAM",
"giteaPostgresqlDatabase": "Baza danych Gitea + PostgreSQL",
"giteaServerIsNotRunning": "Serwer Gitea nie działa",
"giteaVersion": "Wersja Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "Dostęp HTTP",
"httpUrl": "HTTP URL",
"iVeSavedIt": "Zapisałem to",
"inactive": "Nieaktywny",
"installGitServer": "Zainstaluj serwer Git",
"installGiteaToHostAndManage": "Zainstaluj Gitea, aby lokalnie hostować i zarządzać repozytoriami Git.",
"installationFailed": "Instalacja nie powiodła się",
"lastResult": "Ostatni wynik",
"lastSync": "Ostatnia synchronizacja",
"leaveEmptyToUseSameName": "Pozostaw puste, aby używać tej samej nazwy",
"loadMore": "Załaduj więcej",
"loadingDeployments": "Ładowanie wdrożeń",
"loadingGit": "Ładowanie Git",
"loadingRepositories": "Ładowanie repozytoriów",
"loadingWebhooks": "Ładowanie webhooków",
"localRepositoryNameOptional": "Nazwa lokalnego repozytorium (opcjonalnie)",
"manual": "Podręcznik",
"memory": "Pamięć:",
"myGithubRepo": "Moje repozytorium GitHub",
"nameAndRepositoryUrlAreRequired": "Wymagana jest nazwa i repozytorium URL",
"noDeploymentLogsAvailable": "Brak dostępnych dzienników wdrażania.",
"noDeploymentsForThisWebhook": "Brak wdrożeń tego webhooka.",
"noDeploymentsMatchThisFilter": "Żadne wdrożenia nie pasują do tego filtra.",
"noDeploymentsYet": "Nie ma jeszcze żadnych wdrożeń",
"noDescription": "Brak opisu",
"noGitServerInstalled": "Brak zainstalowanego serwera Git",
"noRepositoriesYet": "Nie ma jeszcze repozytoriów",
"noWebhooksConfigured": "Nie skonfigurowano żadnych webhooków",
"noneSyncOnly": "Brak (tylko synchronizacja)",
"openGitea": "Otwórz Gitea",
"openInGitea": "Otwórz w Gitea",
"optionallyDeployAnApplicationWhenCode": "Opcjonalnie Wdróż aplikację po wypchnięciu kodu",
"postDeployScriptOptional": "Skrypt po wdrożeniu (opcjonalnie)",
"postDeploymentScriptOutput": "Dane wyjściowe skryptu po wdrożeniu",
"postgresqlDatabase200mbRam": "Baza danych PostgreSQL - ~200MB RAM",
"preDeployScriptOptional": "Skrypt przed wdrożeniem (opcjonalnie)",
"preDeploymentScriptOutput": "Dane wyjściowe skryptu przed wdrożeniem",
"private": "Prywatny",
"pullExternalLocal": "Pull (zewnętrzny → lokalny)",
"pushLocalExternal": "Push (lokalny → zewnętrzny)",
"quickActions": "Szybkie działania",
"recentDeployments": "Ostatnie wdrożenia",
"repositories": "Repozytoria",
"repository": "Magazyn",
"repositoryUrl": "Repozytorium URL",
"repositoryUrlCopied": "Repozytorium URL skopiowane",
"requiredForSshClonePush": "Wymagane w przypadku klonowania/wypychania SSH",
"resourceRequirements": "Wymagania dotyczące zasobów",
"rollback": "Wycofanie",
"rollbackCompleted": "Wycofywanie zakończone",
"rollbackDeployment": "Wdrożenie wycofania",
"rollbackFailed": "Wycofanie nie powiodło się",
"rollbackFailed2": "Wycofanie nie powiodło się: {{message}}",
"running": "Działanie",
"saveThisSecretItWillNot": "Zachowaj ten sekret! Nie będzie już więcej pokazywany.",
"secretCopied": "Sekret skopiowany",
"serverNotRunning": "Serwer nie działa",
"serverOverview": "Przegląd serwera",
"serverPower": "Moc serwera",
"serverSettings": "Ustawienia serwera",
"serverStatus": "Stan serwera",
"sshAccess": "Dostęp SSH",
"sshCloneUrl": "SSH Klon URL",
"sshKeyRequirement": "Kluczowe wymagania SSH",
"sshPort": "Port SSH",
"sshUrlCopied": "SSH URL skopiowane",
"stars": "Gwiazdy",
"startOrStopTheGiteaContainer": "Uruchom lub zatrzymaj kontener Gitea",
"startServer": "Uruchom serwer",
"startTheGitServerToBrowse": "Uruchom serwer Git, aby przeglądać repozytoria.",
"status5": "Status:",
"stopGitServer": "Zatrzymaj serwer Git",
"stopServer": "Zatrzymaj serwer",
"stopTheGitServer": "Zatrzymać serwer Git?",
"stopsGiteaDataIsPreservedUnless": "Zatrzymuje Gitea; dane zostaną zachowane, chyba że zostaną usunięte ręcznie",
"storage": "Składowanie:",
"success": "Sukces",
"switch": "Przełącznik",
"syncDirection": "Kierunek synchronizacji",
"syncs": "Synchronizuje",
"testEventLogged": "Zapisano zdarzenie testowe",
"testFailed": "Test nie powiódł się",
"testFailed2": "Test nie powiódł się: {{message}}",
"thisWillInstall": "To zainstaluje:",
"triggeredBy": "Wywołane przez:",
"uninstall": "Odinstaluj",
"uninstallGitServer": "Odinstaluj serwer Git",
"uninstallTheGiteaServerThisWill": "Odinstalować serwer Gitea? Spowoduje to zatrzymanie kontenera, ale zachowanie danych.",
"uninstallingWillStopTheGiteaContainer": "Odinstalowanie zatrzyma kontener Gitea. Twoje dane zostaną zachowane, chyba że zdecydujesz się je usunąć.",
"updated": "Zaktualizowano",
"updated7d": "Zaktualizowano ≤7d",
"urlCopied": "Skopiowano URL",
"urlPath": "Ścieżka URL",
"useThisSecretWhenConfiguringThe": "Użyj tego sekretu podczas konfigurowania webhooka w ustawieniach repozytorium.",
"useYourSshKey": "Użyj klucza SSH",
"viewLogs": "Wyświetl dzienniki",
"visibility": "Widoczność",
"webBrowserAccess": "Dostęp przez przeglądarkę internetową",
"webInterface": "Interfejs sieciowy",
"webhook": "Webhook",
"webhookCreatedSuccessfully": "Webhook został utworzony pomyślnie",
"webhookDeleted": "Webhook usunięty",
"webhookSecret": "Sekret webhooka",
"webhookUrlCopied": "Skopiowano webhook URL",
"webhooks": "Haki internetowe",
"zeroDowntime": "Zero przestojów",
"zeroDowntimeDeployment": "Wdrożenie bez przestojów"
},
"gitProviders": {
"anyGitRemote": "Dowolny pilot Git",
"bitbucket": "Bitbucketa",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "Migawki"
},
"storagePanel": {
"createdBucket": "Utworzono wiadro „{{name}}”",
"createdDatabase": "Utworzono bazę danych „{{name}}”",
"createdNamespace": "Utworzono przestrzeń nazw „{{title}}”",
"d1Databases": "Bazy danych D1",
"deleted": "Usunięto",
"kvNamespaces": "Przestrzenie nazw KV",
"loadingStorage": "Ładuję pamięć…",
"r2Buckets": "Łyżki R2",
"r2IsS3CompatibleSoA": "R2 jest kompatybilny z S3, więc tutaj wiadro może później tworzyć kopie zapasowe ServerKit.",
"storageUnavailable": "Pamięć niedostępna"
},
"styleGuide": {
"2Found": "Znaleziono 2",
"aNewVersionIsAvailableFor": "Dostępna jest nowa wersja do aktualizacji.",
@@ -6267,28 +5932,6 @@
"arguments": "Argumenty",
"result": "Wynik"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Tunel Cloudflare udostępnia usługę lokalną lub prywatną poprzez krawędź Cloudflare — nie jest wymagany żaden publiczny adres IP ani otwarte porty.",
"cloudflareTunnels": "Tunele Cloudflare (",
"cloudflareTunnelsUnavailable": "Cloudflare Tunele niedostępne",
"copied": "Skopiowano",
"couldNotCopyTheInstallCommand": "Nie można skopiować polecenia instalacji",
"createACloudflareTunnel": "Utwórz tunel Cloudflare",
"createOneAboveToExposeA": "Utwórz go powyżej, aby udostępnić usługę lokalną bez publicznego adresu IP.",
"createdTunnel": "Utworzono tunel „{{name}}”",
"deletedCloudflareTunnel": "Usunięto tunel Cloudflare „{{name}}”",
"hostnameRemoved": "Nazwa hosta została usunięta",
"hostnameRouted": "Nazwa hosta została przekierowana",
"hostnames": "Nazwy hostów",
"installCommand": "Polecenie instalacji",
"loadingCloudflareTunnels": "Ładowanie tuneli Cloudflare…",
"noCloudflareTunnels": "Brak tuneli Cloudflare",
"noInstallCommandAvailable": "Brak dostępnego polecenia instalacji.",
"noPublicHostnamesRoutedYet": "Nie przekierowano jeszcze żadnych publicznych nazw hostów.",
"routeSetButTheDnsRecord": "Trasa została ustawiona, ale rekord DNS nie powiódł się:",
"runThisOnceOnTheMachine": "Uruchom to raz na komputerze obsługującym usługę lokalną. Token łącznika jest wyświetlany dopiero teraz.",
"theHostnameShouldBeInsideThis": "Nazwa hosta powinna znajdować się w domenie tej strefy; automatycznie tworzony jest zastępczy rekord DNS."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "W ciągu dni, w których nowo objęte konto może logować się przy użyciu samego hasła, zanim rejestracja zostanie wymuszona.",
"gracePeriodDays": "Okres karencji (dni)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "Wyszukaj widżety…",
"typesDragToRepositionAfterAdding": "Typy {{length}} · przeciągnij, aby zmienić położenie po dodaniu"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Dołącza proces roboczy do tej domeny, aby pasujący ruch uruchamiał skrypt.",
"code": "Kod",
"deletedWorker": "Usunięty pracownik „{{name}}”",
"deploy": "Wdrożyć",
"deployAWorker": "Oddeleguj Robotnika",
"deployYourFirstEdgeScriptAbove": "Wdróż swój pierwszy skrypt brzegowy powyżej.",
"deployedWorker": "Oddelegowany pracownik „{{name}}”",
"loadingWorkers": "Ładowanie pracowników…",
"noRoutesYetAddARoute": "Nie ma jeszcze tras. Dodaj wzorzec trasy podczas wdrażania, aby wysłać ruch do pracownika.",
"noWorkersDeployed": "Nie rozmieszczono żadnych pracowników",
"routePatternOptional": "Wzór trasy (opcjonalnie)",
"routeRemoved": "Trasa usunięta",
"routes": "Trasy (",
"workerDeployedButTheRouteFailed": "Proces roboczy został wdrożony, ale trasa nie powiodła się:",
"workers": "Pracownicy (",
"workersUnavailable": "Pracownicy niedostępni"
},
"workspaceDetail": {
"accessGranted": "Dostęp przyznany",
"accessRevoked": "Dostęp cofnięty",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Nome do servidor",
"serverProvisioningInitiated": "Provisionamento de servidor iniciado"
},
"cloudflareWafPanel": {
"activeRules": "Regras ativas (",
"addAQuickRuleOrA": "Adicione uma regra rápida ou uma regra personalizada acima.",
"addCustomRule": "Adicionar regra personalizada",
"addRule": "Adicionar regra",
"addedQuickRule": "Regra rápida adicionada: {{label}}",
"applyACuratedCloudflareRuleWithout": "Aplique uma regra selecionada da Cloudflare sem escrever uma expressão à mão.",
"block": "Bloquear",
"blockAdminFromOutsideTheOffice": "Bloquear administrador de fora do escritório",
"chooseAQuickRule": "Escolha uma regra rápida…",
"customRule": "Regra personalizada",
"customRuleAdded": "Regra personalizada adicionada",
"expression": "Expressão",
"firewallRulesUnavailable": "Regras de firewall indisponíveis",
"jsChallenge": "Desafio JS",
"loadingFirewallRules": "Carregando regras de firewall…",
"log": "Registro",
"managedChallenge": "Desafio gerenciado",
"noCustomFirewallRules": "Sem regras de firewall personalizadas",
"quickRules": "Regras rápidas",
"ruleDeleted": "Regra excluída",
"writeYourOwnCloudflareFirewallExpression": "Escreva sua própria expressão de firewall Cloudflare para controle total."
},
"cloudflareZoneSettings": {
"1Day": "1 dia",
"1Month": "1 mês",
"1Week": "1 semana",
"1Year": "1 ano",
"2Years": "2 anos",
"6Months": "6 meses",
"appliedNeedAHigherCloudflarePlan": "{{applied}}/{{total}} aplicado. {{length}} precisa de um plano Cloudflare superior.",
"appliedRecommendedHardeningSettings": "Aplicado endurecimento recomendado (configurações {{applied}})",
"applyRecommendedHardening": "Aplicar o endurecimento recomendado",
"backToDnsZones": "Voltar para zonas DNS",
"changingCloudflareSettingsRequiresAnAdmin": "A alteração das configurações da Cloudflare requer uma conta de administrador.",
"clearCloudflareSCachedCopyOf": "Limpe a cópia em cache do seu site pela Cloudflare para que os visitantes obtenham conteúdo novo. Limpe tudo ou liste URLs específicos abaixo.",
"clearCloudflareSEntireCacheFor": "Limpar todo o cache da Cloudflare para {{value}}?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Configurações do Cloudflare indisponíveis",
"dnsZones": "Zonas DNS",
"enabled": "Habilitado",
"enterAtLeastOneUrlTo": "Insira pelo menos um URL para limpar",
"failedToApplyPreset": "Falha ao aplicar predefinição",
"failedToPurgeCache": "Falha ao limpar o cache",
"failedToPurgeUrls": "Falha ao limpar URLs",
"failedToSaveSetting": "Falha ao salvar a configuração",
"includeSubdomains": "Incluir subdomínios",
"itReCachesThisIsSafe": "ele armazena em cache novamente. Isto é seguro, mas pode aumentar brevemente a carga de origem.",
"lockedByYourCloudflarePlan": "Bloqueado pelo seu plano Cloudflare",
"maxAge": "Idade máxima",
"notAvailableOnThisZone": "Não disponível nesta zona",
"off": "Desligado",
"preload": "Pré-carregar",
"purgeCache": "Limpar cache",
"purgeEntireCache": "Limpe todo o cache",
"purgeEverything": "Limpe tudo",
"purgeSpecificUrlsOnePerLine": "Eliminar URLs específicos (um por linha, até 30)",
"purgeUrls": "Limpar URLs",
"purgedTheEntireCache": "Limpei todo o cache",
"purgedUrlFromCache": "{{n}} URL{{value}} removido do cache",
"setsFullStrictSslAlwaysUse": "Define SSL completo (estrito), sempre usar HTTPS, HSTS (6 meses), um piso TLS 1.2 com TLS 1.3, Brotli, HTTP/3 e um cache de navegador de 4 horas. As configurações que seu plano não permite são ignoradas.",
"settingSaved": "Configuração salva",
"theNextVisitToEachPage": "A próxima visita a cada página será atendida desde sua origem até ",
"tunnels": "Túneis",
"workers": "Trabalhadores"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "Um CNAME para este nome de host será criado no DNS da Cloudflare, apontando para o túnel.",
"askingTheAgentToStartThe": "Pedindo ao agente para iniciar o fluxo de login da Cloudflare…",
@@ -1785,76 +1719,6 @@
"aiSettings": "Configurações de IA",
"serverkitAi": "ServerKit IA"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Um comprovante de capacidade de entrega (PTR, porta 25, RBL) bloqueia o envio de saída antes que o correio possa deixar este host.",
"accountCreated": "Conta criada",
"accountDeleted": "Conta excluída",
"accounts": "Contas",
"addAMailDomainFirst": "Adicione um domínio de e-mail primeiro.",
"addDomain": "Adicionar domínio",
"address": "Endereço",
"component": "Componente",
"couldNotAddDomain": "Não foi possível adicionar o domínio",
"createFailed": "Falha na criação",
"delete3": "Excluir {{email}}?",
"deleteAccount": "Excluir conta",
"deleteAndAllOfItsAccounts": "Excluir {{name}} e todas as suas contas e aliases?",
"deleteDomain": "Excluir domínio",
"deleteFailed": "Falha na exclusão",
"deployDns": "Implantar DNS",
"deployFailedNoLinkedDnsProvider": "Falha na implantação (nenhum provedor DNS vinculado?)",
"dkimSelector": "Seletor DKIM:",
"dnsCheckFailed": "Falha na verificação de DNS",
"dnsChecked": "DNS verificado",
"dnsRecordsDeployedToProvider": "Registros DNS implantados no provedor",
"domainAdded": "Domínio adicionado",
"domainDeleted": "Domínio excluído",
"emailServerInstallStarted": "A instalação do servidor de e-mail foi iniciada",
"enableRelay": "Habilitar relé",
"enableSpamFiltering": "Ativar filtragem de spam",
"failedTo": "Falha ao {{action}} {{component}}",
"failedToDisableRelay": "Falha ao desativar o relé",
"failedToWebmail": "Falha no webmail {{action}}",
"flushFailed": "Falha na liberação",
"flushQueue": "Liberar fila",
"host": "Host",
"installFailed": "Falha na instalação",
"installMailServer": "Instalar servidor de e-mail",
"installWebmail": "Instalar webmail",
"mailServerNotInstalled": "Servidor de email não instalado",
"mailboxPassword": "Senha da caixa de correio",
"mailboxUsername": "Nome de usuário da caixa de correio",
"mostPanelsNeverRunAMail": "A maioria dos painéis nunca executa um servidor de e-mail; instale-o quando precisar.",
"newMailDomain": "Novo domínio de correio",
"noMailDomainsYet": "Ainda não há domínios de e-mail.",
"outboundRelaySmarthost": "Retransmissão de saída (smarthost)",
"queue": "Fila",
"queueFlushed": "Fila liberada",
"quota": "Cota",
"quotaInMb": "Cota em MB",
"quotaMb": "Cota (MB)",
"recipient": "Destinatário",
"relayDisabled": "Relé desativado",
"relayReachable": "Relé acessível",
"relaySaved": "Relé salvo",
"relayTestFailed": "Teste de relé falhou",
"requiredScore": "Pontuação obrigatória",
"roundcubeWebmail": "Webmail Roundcube",
"ruleUpdateFailed": "Falha na atualização da regra",
"ruleUpdateStarted": "A atualização da regra foi iniciada",
"saveFailed": "Falha ao salvar",
"searchAccounts": "Pesquisar contas",
"searchAccountsByAddress": "Pesquisar contas por endereço...",
"searchQueueBySenderRecipientOr": "Fila de pesquisa por remetente, destinatário ou ID…",
"sender": "Remetente",
"spamConfigSaved": "Configuração de spam salva",
"spamassassin": "Spam Assassino",
"updateRules": "Atualizar regras",
"used": "Usado",
"webmail": "E-mail: {{action}}",
"webmailInstallStarted": "Instalação do Webmail iniciada",
"webmailIsNotInstalled": "O webmail não está instalado."
},
"emailProviders": {
"addedButTestFailed": "Adicionado, mas o teste falhou: {{value}}",
"confirm": "Confirmar",
@@ -2404,193 +2268,6 @@
"empty": "Vazio",
"expand": "Expandir"
},
"git": {
"512mbMinimum1gbRecommended": "~ 512 MB no mínimo (1 GB recomendado)",
"5gbForDatabaseRepositories": "~5GB para banco de dados + repositórios",
"access": "Acesso",
"accessInformation": "Informações de acesso",
"accessRepositories": "Acessar repositórios",
"active": "Ativo",
"addAWebhookToSyncRepositories": "Adicione um webhook para sincronizar repositórios de fontes externas.",
"addWebhook": "Adicionar webhook",
"addYourPublicKeyInGitea": "Adicione sua chave pública no Gitea",
"adminEmail": "E-mail do administrador",
"adminEmailIsRequired": "O e-mail do administrador é obrigatório",
"adminPasswordLeaveEmptyToAuto": "Senha de administrador (deixe em branco para gerar automaticamente)",
"adminPasswordSaveThis": "Senha de administrador: {{adminpassword}} (salve!)",
"adminUsername": "Nome de usuário do administrador",
"application": "Aplicação",
"areYouSureYouWantTo": "Tem certeza de que deseja excluir este webhook? Isto não pode ser desfeito.",
"autoGenerateSecurePassword": "Gerar senha segura automaticamente",
"autoSyncOnPushEvents": "Sincronização automática em eventos push",
"automaticallyDeployWhenCodeIsPushed": "Implantar automaticamente quando o código é enviado para a branch",
"bidirectional": "Bidirecional",
"bitbucket": "Bitbucket",
"branch7": "Branch:",
"branches": "Filiais",
"cloneRepositoriesViaSsh": "Clonar repositórios via SSH",
"commit": "Confirmar",
"commit2": "Confirmar:",
"commitMessage": "Confirmar mensagem",
"commits": "Confirmações",
"components": "Componentes:",
"configureAWebhookWithDeployOn": "Configure um webhook com \"Deploy on Push\" habilitado para ver as implantações aqui.",
"configureWebhooks": "Configurar webhooks",
"copySshUrl": "Copiar URL SSH",
"copyUrl": "Copiar URL",
"createYourFirstRepositoryInGitea": "Crie seu primeiro repositório no Gitea.",
"dangerZone": "Zona de perigo",
"defaultBranch": "Branch padrão",
"deleteWebhook": "Excluir webhook",
"deployOnPush": "Implantar por push",
"deployToApplication": "Implantar no aplicativo",
"deployment": "Implantação",
"deploymentHistory": "Histórico de implantação",
"deploymentOutput": "Resultado da implantação",
"deploymentSettings": "Configurações de implantação",
"deploymentStartedV": "Implantação iniciada: v{{version}}",
"deploymentV": "Implantação v{{version}}",
"deploymentVLogs": "Logs de implantação v{{version}}",
"deployments": "Implantações",
"duration": "Duração:",
"error": "Erro",
"error2": "Erro:",
"failedToCreateWebhook": "Falha ao criar webhook",
"failedToCreateWebhook2": "Falha ao criar webhook: {{message}}",
"failedToDelete": "Falha ao excluir: {{message}}",
"failedToDeploy": "Falha ao implantar: {{message}}",
"failedToInstall": "Falha ao instalar: {{message}}",
"failedToLoadDeploymentDetails": "Falha ao carregar detalhes da implantação",
"failedToLoadDirectory": "Falha ao carregar o diretório",
"failedToLoadLogs": "Falha ao carregar registros: {{message}}",
"failedToLoadMoreCommits": "Falha ao carregar mais commits",
"failedToLoadRepositoryDetails": "Falha ao carregar detalhes do repositório",
"failedToStart": "Falha ao iniciar: {{message}}",
"failedToStop": "Falha ao parar: {{message}}",
"failedToSwitchBranch": "Falha ao mudar de branch",
"failedToToggleWebhook": "Falha ao alternar o webhook: {{message}}",
"failedToTriggerDeployment": "Falha ao acionar a implantação",
"failedToUninstall": "Falha ao desinstalar: {{message}}",
"files": "Arquivos",
"forks": "Garfos",
"gitServerInstalledSuccessfully": "Servidor Git instalado com sucesso",
"gitServerStarted": "Servidor Git iniciado",
"gitServerStopped": "Servidor Git parado",
"gitServerUninstalled": "Servidor Git desinstalado",
"giteaGitServer300mbRam": "Gitea (servidor Git) - ~300 MB de RAM",
"giteaPostgresqlDatabase": "Banco de dados Gitea + PostgreSQL",
"giteaServerIsNotRunning": "O servidor Gitea não está funcionando",
"giteaVersion": "Versão Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "Acesso HTTP",
"httpUrl": "URL HTTP",
"iVeSavedIt": "Eu salvei",
"inactive": "Inativo",
"installGitServer": "Instale o servidor Git",
"installGiteaToHostAndManage": "Instale o Gitea para hospedar e gerenciar seus repositórios Git localmente.",
"installationFailed": "Falha na instalação",
"lastResult": "Último resultado",
"lastSync": "Última sincronização",
"leaveEmptyToUseSameName": "Deixe em branco para usar o mesmo nome",
"loadMore": "Carregar mais",
"loadingDeployments": "Carregando implantações",
"loadingGit": "Carregando Git",
"loadingRepositories": "Carregando repositórios",
"loadingWebhooks": "Carregando webhooks",
"localRepositoryNameOptional": "Nome do repositório local (opcional)",
"manual": "Manuais",
"memory": "Memória:",
"myGithubRepo": "Meu repositório GitHub",
"nameAndRepositoryUrlAreRequired": "O nome e o URL do repositório são obrigatórios",
"noDeploymentLogsAvailable": "Nenhum log de implantação disponível.",
"noDeploymentsForThisWebhook": "Nenhuma implantação para este webhook.",
"noDeploymentsMatchThisFilter": "Nenhuma implantação corresponde a este filtro.",
"noDeploymentsYet": "Nenhuma implantação ainda",
"noDescription": "Sem descrição",
"noGitServerInstalled": "Nenhum servidor Git instalado",
"noRepositoriesYet": "Ainda não há repositórios",
"noWebhooksConfigured": "Nenhum webhooks configurado",
"noneSyncOnly": "Nenhum (somente sincronização)",
"openGitea": "Abrir Gitea",
"openInGitea": "Aberto em Gitea",
"optionallyDeployAnApplicationWhenCode": "Opcionalmente, implante um aplicativo quando o código for enviado",
"postDeployScriptOptional": "Script pós-implantação (opcional)",
"postDeploymentScriptOutput": "Saída de script pós-implantação",
"postgresqlDatabase200mbRam": "Banco de dados PostgreSQL - ~200 MB de RAM",
"preDeployScriptOptional": "Script de pré-implantação (opcional)",
"preDeploymentScriptOutput": "Saída de script pré-implantação",
"private": "Privado",
"pullExternalLocal": "Puxar (Externo → Local)",
"pushLocalExternal": "Push (Local → Externo)",
"quickActions": "Ações rápidas",
"recentDeployments": "Implantações recentes",
"repositories": "Repositórios",
"repository": "Repositório",
"repositoryUrl": "URL do repositório",
"repositoryUrlCopied": "URL do repositório copiado",
"requiredForSshClonePush": "Obrigatório para clone/push SSH",
"resourceRequirements": "Requisitos de recursos",
"rollback": "Reverter",
"rollbackCompleted": "Reversão concluída",
"rollbackDeployment": "Implantação de reversão",
"rollbackFailed": "Falha na reversão",
"rollbackFailed2": "Falha na reversão: {{message}}",
"running": "Correndo",
"saveThisSecretItWillNot": "Salve esse segredo! Não será mostrado novamente.",
"secretCopied": "Segredo copiado",
"serverNotRunning": "Servidor não está funcionando",
"serverOverview": "Visão geral do servidor",
"serverPower": "Poder do servidor",
"serverSettings": "Configurações do servidor",
"serverStatus": "Status do servidor",
"sshAccess": "Acesso SSH",
"sshCloneUrl": "URL de clonagem SSH",
"sshKeyRequirement": "Requisito de chave SSH",
"sshPort": "Porta SSH",
"sshUrlCopied": "URL SSH copiado",
"stars": "Estrelas",
"startOrStopTheGiteaContainer": "Iniciar ou parar o contêiner Gitea",
"startServer": "Iniciar servidor",
"startTheGitServerToBrowse": "Inicie o servidor Git para navegar pelos repositórios.",
"status5": "Estado:",
"stopGitServer": "Pare o servidor Git",
"stopServer": "Parar servidor",
"stopTheGitServer": "Parar o servidor Git?",
"stopsGiteaDataIsPreservedUnless": "Pára Gitea; os dados são preservados, a menos que sejam removidos manualmente",
"storage": "Armazenamento:",
"success": "Sucesso",
"switch": "Mudar",
"syncDirection": "Direção de sincronização",
"syncs": "Sincronizações",
"testEventLogged": "Evento de teste registrado",
"testFailed": "Falha no teste",
"testFailed2": "Falha no teste: {{message}}",
"thisWillInstall": "Isso irá instalar:",
"triggeredBy": "Acionado por:",
"uninstall": "Desinstalar",
"uninstallGitServer": "Desinstalar o servidor Git",
"uninstallTheGiteaServerThisWill": "Desinstalar o servidor Gitea? Isso interromperá o contêiner, mas preservará seus dados.",
"uninstallingWillStopTheGiteaContainer": "A desinstalação irá parar o contêiner Gitea. Seus dados serão preservados, a menos que você opte por removê-los.",
"updated": "Atualizado",
"updated7d": "Atualizado ≤7d",
"urlCopied": "URL copiado",
"urlPath": "Caminho do URL",
"useThisSecretWhenConfiguringThe": "Use este segredo ao configurar o webhook nas configurações do seu repositório.",
"useYourSshKey": "Use sua chave SSH",
"viewLogs": "Ver registros",
"visibility": "Visibilidade",
"webBrowserAccess": "Acesso ao navegador da web",
"webInterface": "Interface web",
"webhook": "Webhook",
"webhookCreatedSuccessfully": "Webhook criado com sucesso",
"webhookDeleted": "Webhook excluído",
"webhookSecret": "Segredo do webhook",
"webhookUrlCopied": "URL do webhook copiado",
"webhooks": "Webhooks",
"zeroDowntime": "Tempo de inatividade zero",
"zeroDowntimeDeployment": "Implantação com tempo de inatividade zero"
},
"gitProviders": {
"anyGitRemote": "Qualquer Git remoto",
"bitbucket": "Bitbucket",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "Instantâneos"
},
"storagePanel": {
"createdBucket": "Balde criado \"{{name}}\"",
"createdDatabase": "Banco de dados criado \"{{name}}\"",
"createdNamespace": "Namespace criado \"{{title}}\"",
"d1Databases": "Bancos de dados D1",
"deleted": "Excluído",
"kvNamespaces": "Namespaces KV",
"loadingStorage": "Carregando armazenamento…",
"r2Buckets": "Baldes R2",
"r2IsS3CompatibleSoA": "R2 é compatível com S3, portanto, um bucket aqui pode fazer backup de backups do ServerKit posteriormente.",
"storageUnavailable": "Armazenamento indisponível"
},
"styleGuide": {
"2Found": "2 encontrados",
"aNewVersionIsAvailableFor": "Uma nova versão está disponível para atualização.",
@@ -6267,28 +5932,6 @@
"arguments": "Argumentos",
"result": "Resultado"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Um Cloudflare Tunnel expõe um serviço local ou privado através da borda da Cloudflare — sem necessidade de IP público ou portas abertas.",
"cloudflareTunnels": "Túneis Cloudflare (",
"cloudflareTunnelsUnavailable": "Túneis Cloudflare indisponíveis",
"copied": "Copiado",
"couldNotCopyTheInstallCommand": "Não foi possível copiar o comando de instalação",
"createACloudflareTunnel": "Crie um túnel Cloudflare",
"createOneAboveToExposeA": "Crie um acima para expor um serviço local sem IP público.",
"createdTunnel": "Túnel criado \"{{name}}\"",
"deletedCloudflareTunnel": "Túnel Cloudflare excluído \"{{name}}\"",
"hostnameRemoved": "Nome do host removido",
"hostnameRouted": "Nome do host roteado",
"hostnames": "Nomes de host",
"installCommand": "Comando de instalação",
"loadingCloudflareTunnels": "Carregando túneis Cloudflare…",
"noCloudflareTunnels": "Sem túneis Cloudflare",
"noInstallCommandAvailable": "Nenhum comando de instalação disponível.",
"noPublicHostnamesRoutedYet": "Nenhum nome de host público foi roteado ainda.",
"routeSetButTheDnsRecord": "Rota definida, mas o registro DNS falhou: ",
"runThisOnceOnTheMachine": "Execute isso uma vez na máquina que hospeda seu serviço local. O token do conector é mostrado somente agora.",
"theHostnameShouldBeInsideThis": "O nome do host deve estar dentro do domínio desta zona; um registro DNS com proxy é criado automaticamente."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Dias em que uma conta recém-coberta poderá continuar fazendo login com apenas uma senha antes que a inscrição seja aplicada.",
"gracePeriodDays": "Período de carência (dias)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "Widgets de pesquisa…",
"typesDragToRepositionAfterAdding": "Tipos {{length}} · arraste para reposicionar após adicionar"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Anexa o trabalhador a este domínio para que o tráfego correspondente execute o script.",
"code": "Código",
"deletedWorker": "Trabalhador excluído \"{{name}}\"",
"deploy": "Implantar",
"deployAWorker": "Implantar um trabalhador",
"deployYourFirstEdgeScriptAbove": "Implante seu primeiro script de borda acima.",
"deployedWorker": "Trabalhador implantado \"{{name}}\"",
"loadingWorkers": "Carregando trabalhadores…",
"noRoutesYetAddARoute": "Ainda não há rotas. Adicione um padrão de rota ao implantar para enviar tráfego a um trabalhador.",
"noWorkersDeployed": "Nenhum trabalhador implantado",
"routePatternOptional": "Padrão de rota (opcional)",
"routeRemoved": "Rota removida",
"routes": "Rotas (",
"workerDeployedButTheRouteFailed": "Worker implantado, mas a rota falhou: ",
"workers": "Trabalhadores (",
"workersUnavailable": "Trabalhadores indisponíveis"
},
"workspaceDetail": {
"accessGranted": "Acesso concedido",
"accessRevoked": "Acesso revogado",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Имя сервера",
"serverProvisioningInitiated": "Инициация подготовки сервера началась"
},
"cloudflareWafPanel": {
"activeRules": "Активные правила (",
"addAQuickRuleOrA": "Добавьте быстрое правило или собственное правило выше.",
"addCustomRule": "Добавить специальное правило",
"addRule": "Добавить правило",
"addedQuickRule": "Добавлено быстрое правило: {{label}}.",
"applyACuratedCloudflareRuleWithout": "Примените рекомендованное правило Cloudflare, не записывая выражение вручную.",
"block": "Заблокировать",
"blockAdminFromOutsideTheOffice": "Заблокировать администратора за пределами офиса",
"chooseAQuickRule": "Выберите быстрое правило…",
"customRule": "Пользовательское правило",
"customRuleAdded": "Добавлено специальное правило.",
"expression": "Выражение",
"firewallRulesUnavailable": "Правила брандмауэра недоступны.",
"jsChallenge": "JS-вызов",
"loadingFirewallRules": "Загрузка правил брандмауэра…",
"log": "Бревно",
"managedChallenge": "Управляемый вызов",
"noCustomFirewallRules": "Нет пользовательских правил брандмауэра",
"quickRules": "Быстрые правила",
"ruleDeleted": "Правило удалено.",
"writeYourOwnCloudflareFirewallExpression": "Напишите собственное выражение брандмауэра Cloudflare для полного контроля."
},
"cloudflareZoneSettings": {
"1Day": "1 день",
"1Month": "1 месяц",
"1Week": "1 неделя",
"1Year": "1 год",
"2Years": "2 года",
"6Months": "6 месяцев",
"appliedNeedAHigherCloudflarePlan": "Применено {{applied}}/{{total}}. {{length}} нужен план Cloudflare более высокого уровня.",
"appliedRecommendedHardeningSettings": "Применено рекомендуемое усиление (настройки {{applied}})",
"applyRecommendedHardening": "Примените рекомендуемую закалку",
"backToDnsZones": "Вернуться к DNS-зонам",
"changingCloudflareSettingsRequiresAnAdmin": "Для изменения настроек Cloudflare требуется учетная запись администратора.",
"clearCloudflareSCachedCopyOf": "Очистите кэшированную копию вашего сайта Cloudflare, чтобы посетители могли получать свежий контент. Удалите все или укажите конкретные URL-адреса ниже.",
"clearCloudflareSEntireCacheFor": "Очистить весь кеш Cloudflare для {{value}}?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Настройки Cloudflare недоступны.",
"dnsZones": "DNS-зоны",
"enabled": "Включено",
"enterAtLeastOneUrlTo": "Введите хотя бы один URL для очистки",
"failedToApplyPreset": "Не удалось применить пресет",
"failedToPurgeCache": "Не удалось очистить кэш",
"failedToPurgeUrls": "Не удалось очистить URL-адреса.",
"failedToSaveSetting": "Не удалось сохранить настройку.",
"includeSubdomains": "Включить субдомены",
"itReCachesThisIsSafe": "он повторно кэшируется. Это безопасно, но может ненадолго увеличить исходную нагрузку.",
"lockedByYourCloudflarePlan": "Заблокировано вашим планом Cloudflare",
"maxAge": "Максимальный возраст",
"notAvailableOnThisZone": "Недоступно в этой зоне",
"off": "Выкл.",
"preload": "Предварительная загрузка",
"purgeCache": "Очистить кэш",
"purgeEntireCache": "Очистить весь кеш",
"purgeEverything": "Очистить все",
"purgeSpecificUrlsOnePerLine": "Очистить определенные URL-адреса (по одному в строке, до 30).",
"purgeUrls": "Очистить URL-адреса",
"purgedTheEntireCache": "Очистил весь кэш",
"purgedUrlFromCache": "Удален {{n}} URL{{value}} из кеша.",
"setsFullStrictSslAlwaysUse": "Устанавливает полный (строгий) SSL, всегда использовать HTTPS, HSTS (6 месяцев), нижний уровень TLS 1.2 с TLS 1.3, Brotli, HTTP/3 и 4-часовой кеш браузера. Настройки, которые ваш план не допускает, пропускаются.",
"settingSaved": "Настройка сохранена.",
"theNextVisitToEachPage": "Следующее посещение каждой страницы будет обслуживаться с вашего источника до тех пор, пока",
"tunnels": "Туннели",
"workers": "Рабочие процессы"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "CNAME для этого имени хоста будет создан в DNS Cloudflare и будет указывать на туннель.",
"askingTheAgentToStartThe": "Попросить агента запустить процесс входа в Cloudflare…",
@@ -1785,76 +1719,6 @@
"aiSettings": "Настройки ИИ",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Предварительная проверка доставляемости (PTR, порт 25, RBL) ограничивает исходящую отправку до того, как почта сможет покинуть этот хост.",
"accountCreated": "Аккаунт создан",
"accountDeleted": "Аккаунт удален",
"accounts": "Счета",
"addAMailDomainFirst": "Сначала добавьте почтовый домен.",
"addDomain": "Добавить домен",
"address": "Адрес",
"component": "Компонент",
"couldNotAddDomain": "Не удалось добавить домен",
"createFailed": "Создать не удалось",
"delete3": "Удалить {{email}}?",
"deleteAccount": "Удалить аккаунт",
"deleteAndAllOfItsAccounts": "Удалить {{name}} и все его учетные записи и псевдонимы?",
"deleteDomain": "Удалить домен",
"deleteFailed": "Удаление не удалось",
"deployDns": "Развертывание DNS",
"deployFailedNoLinkedDnsProvider": "Развертывание не удалось (нет связанного поставщика DNS?)",
"dkimSelector": "Селектор DKIM:",
"dnsCheckFailed": "Проверка DNS не удалась",
"dnsChecked": "DNS проверен",
"dnsRecordsDeployedToProvider": "DNS-записи, развернутые у провайдера",
"domainAdded": "Домен добавлен",
"domainDeleted": "Домен удален",
"emailServerInstallStarted": "Установка почтового сервера началась.",
"enableRelay": "Включить реле",
"enableSpamFiltering": "Включить фильтрацию спама",
"failedTo": "Не удалось выполнить {{action}} {{component}}",
"failedToDisableRelay": "Не удалось отключить реле",
"failedToWebmail": "Не удалось отправить {{action}} веб-почту.",
"flushFailed": "Сброс не выполнен.",
"flushQueue": "Очистить очередь",
"host": "Хост",
"installFailed": "Установка не удалась",
"installMailServer": "Установить почтовый сервер",
"installWebmail": "Установить веб-почту",
"mailServerNotInstalled": "Почтовый сервер не установлен",
"mailboxPassword": "Пароль почтового ящика",
"mailboxUsername": "Имя пользователя почтового ящика",
"mostPanelsNeverRunAMail": "На большинстве панелей никогда не работает почтовый сервер, устанавливайте его, когда вам это нужно.",
"newMailDomain": "Новый почтовый домен",
"noMailDomainsYet": "Почтовых доменов пока нет.",
"outboundRelaySmarthost": "Исходящая ретрансляция (умный хост)",
"queue": "Очередь",
"queueFlushed": "Очередь очищена",
"quota": "Квота",
"quotaInMb": "Квота в МБ",
"quotaMb": "Квота (МБ)",
"recipient": "Получатель",
"relayDisabled": "Реле отключено",
"relayReachable": "Реле доступно",
"relaySaved": "Реле сохранено",
"relayTestFailed": "Проверка реле не удалась",
"requiredScore": "Требуемый балл",
"roundcubeWebmail": "Веб-почта Roundcube",
"ruleUpdateFailed": "Обновление правила не выполнено.",
"ruleUpdateStarted": "Обновление правил начато",
"saveFailed": "Сохранить не удалось",
"searchAccounts": "Поиск аккаунтов",
"searchAccountsByAddress": "Поиск аккаунтов по адресу...",
"searchQueueBySenderRecipientOr": "Поиск в очереди по отправителю, получателю или идентификатору…",
"sender": "Отправитель",
"spamConfigSaved": "Конфигурация спама сохранена.",
"spamassassin": "Спам-убийца",
"updateRules": "Обновить правила",
"used": "Использовано",
"webmail": "Веб-почта: {{action}}",
"webmailInstallStarted": "Установка веб-почты началась",
"webmailIsNotInstalled": "Веб-почта не установлена."
},
"emailProviders": {
"addedButTestFailed": "Добавлено, но проверка не удалась: {{value}}",
"confirm": "Подтверждать",
@@ -2404,193 +2268,6 @@
"empty": "Пустой",
"expand": "Расширять"
},
"git": {
"512mbMinimum1gbRecommended": "Минимум ~512 МБ (рекомендуется 1 ГБ)",
"5gbForDatabaseRepositories": "~5 ГБ для базы данных + репозитории",
"access": "Доступ",
"accessInformation": "Доступ к информации",
"accessRepositories": "Доступ к репозиториям",
"active": "Активен",
"addAWebhookToSyncRepositories": "Добавьте вебхук для синхронизации репозиториев из внешних источников.",
"addWebhook": "Добавить вебхук",
"addYourPublicKeyInGitea": "Добавьте свой открытый ключ в Gitea",
"adminEmail": "Электронная почта администратора",
"adminEmailIsRequired": "Требуется адрес электронной почты администратора",
"adminPasswordLeaveEmptyToAuto": "Пароль администратора (оставьте пустым для автоматической генерации)",
"adminPasswordSaveThis": "Пароль администратора: {{adminpassword}} (сохраните!)",
"adminUsername": "Имя пользователя администратора",
"application": "Приложение",
"areYouSureYouWantTo": "Вы уверены, что хотите удалить этот вебхук? Это невозможно отменить.",
"autoGenerateSecurePassword": "Автоматически генерировать безопасный пароль",
"autoSyncOnPushEvents": "Автосинхронизация при push-событиях",
"automaticallyDeployWhenCodeIsPushed": "Автоматическое развертывание при отправке кода в ветку.",
"bidirectional": "Двунаправленный",
"bitbucket": "Битбакет",
"branch7": "Ветвь:",
"branches": "Филиалы",
"cloneRepositoriesViaSsh": "Клонировать репозитории через SSH",
"commit": "Совершить",
"commit2": "Совершить:",
"commitMessage": "Зафиксировать сообщение",
"commits": "фиксирует",
"components": "Компоненты:",
"configureAWebhookWithDeployOn": "Настройте веб-перехватчик с включенной функцией «Развертывание при принудительной отправке», чтобы увидеть развертывания здесь.",
"configureWebhooks": "Настройка вебхуков",
"copySshUrl": "Скопировать URL-адрес SSH",
"copyUrl": "Копировать URL",
"createYourFirstRepositoryInGitea": "Создайте свой первый репозиторий в Gitea.",
"dangerZone": "Опасная зона",
"defaultBranch": "Ветка по умолчанию",
"deleteWebhook": "Удалить вебхук",
"deployOnPush": "Развертывание при нажатии",
"deployToApplication": "Развертывание в приложении",
"deployment": "Развертывание",
"deploymentHistory": "История развертывания",
"deploymentOutput": "Выходные данные развертывания",
"deploymentSettings": "Параметры развертывания",
"deploymentStartedV": "Развертывание началось: v{{version}}",
"deploymentV": "Развертывание v{{version}}",
"deploymentVLogs": "Журналы развертывания v{{version}}",
"deployments": "Развертывания",
"duration": "Продолжительность:",
"error": "Ошибка",
"error2": "Ошибка:",
"failedToCreateWebhook": "Не удалось создать вебхук.",
"failedToCreateWebhook2": "Не удалось создать вебхук: {{message}}",
"failedToDelete": "Не удалось удалить: {{message}}",
"failedToDeploy": "Не удалось развернуть: {{message}}",
"failedToInstall": "Не удалось установить: {{message}}",
"failedToLoadDeploymentDetails": "Не удалось загрузить сведения о развертывании.",
"failedToLoadDirectory": "Не удалось загрузить каталог",
"failedToLoadLogs": "Не удалось загрузить журналы: {{message}}",
"failedToLoadMoreCommits": "Не удалось загрузить больше коммитов.",
"failedToLoadRepositoryDetails": "Не удалось загрузить сведения о репозитории.",
"failedToStart": "Не удалось запустить: {{message}}",
"failedToStop": "Не удалось остановить: {{message}}",
"failedToSwitchBranch": "Не удалось переключить ветку",
"failedToToggleWebhook": "Не удалось переключить вебхук: {{message}}.",
"failedToTriggerDeployment": "Не удалось запустить развертывание.",
"failedToUninstall": "Не удалось удалить: {{message}}",
"files": "Файлы",
"forks": "Вилки",
"gitServerInstalledSuccessfully": "Git server installed successfully",
"gitServerStarted": "Git server started",
"gitServerStopped": "Git server stopped",
"gitServerUninstalled": "Git server uninstalled",
"giteaGitServer300mbRam": "Gitea (сервер Git) — ~300 МБ ОЗУ",
"giteaPostgresqlDatabase": "База данных Gitea + PostgreSQL",
"giteaServerIsNotRunning": "Сервер Gitea не работает",
"giteaVersion": "Версия Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "HTTP-доступ",
"httpUrl": "URL-адрес HTTP",
"iVeSavedIt": "Я сохранил это",
"inactive": "Неактивный",
"installGitServer": "Установить Git-сервер",
"installGiteaToHostAndManage": "Установите Gitea для локального размещения и управления репозиториями Git.",
"installationFailed": "Установка не удалась",
"lastResult": "Последний результат",
"lastSync": "Последняя синхронизация",
"leaveEmptyToUseSameName": "Оставьте пустым, чтобы использовать то же имя",
"loadMore": "Загрузить больше",
"loadingDeployments": "Загрузка развертываний",
"loadingGit": "Загрузка Git",
"loadingRepositories": "Загрузка репозиториев",
"loadingWebhooks": "Загрузка вебхуков",
"localRepositoryNameOptional": "Имя локального репозитория (необязательно)",
"manual": "Руководство",
"memory": "Память:",
"myGithubRepo": "Мой репозиторий на GitHub",
"nameAndRepositoryUrlAreRequired": "Укажите имя и URL репозитория.",
"noDeploymentLogsAvailable": "Журналы развертывания недоступны.",
"noDeploymentsForThisWebhook": "Для этого веб-перехватчика нет развертываний.",
"noDeploymentsMatchThisFilter": "Ни одно развертывание не соответствует этому фильтру.",
"noDeploymentsYet": "Развертываний пока нет",
"noDescription": "Нет описания",
"noGitServerInstalled": "Сервер Git не установлен",
"noRepositoriesYet": "Репозиториев пока нет",
"noWebhooksConfigured": "Вебхуки не настроены",
"noneSyncOnly": "Нет (только синхронизация)",
"openGitea": "Открыть Gitea",
"openInGitea": "Открыть в Gitea",
"optionallyDeployAnApplicationWhenCode": "При необходимости разверните приложение при отправке кода.",
"postDeployScriptOptional": "Скрипт после развертывания (необязательно)",
"postDeploymentScriptOutput": "Вывод сценария после развертывания",
"postgresqlDatabase200mbRam": "База данных PostgreSQL — ~200 МБ ОЗУ",
"preDeployScriptOptional": "Скрипт предварительного развертывания (необязательно)",
"preDeploymentScriptOutput": "Вывод сценария перед развертыванием",
"private": "Частный",
"pullExternalLocal": "Pull (Внешний → Локальный)",
"pushLocalExternal": "Push (локальный → внешний)",
"quickActions": "Быстрые действия",
"recentDeployments": "Недавние развертывания",
"repositories": "Репозитории",
"repository": "Репозиторий",
"repositoryUrl": "URL-адрес репозитория",
"repositoryUrlCopied": "URL-адрес репозитория скопирован.",
"requiredForSshClonePush": "Требуется для клонирования/отправки SSH",
"resourceRequirements": "Требования к ресурсам",
"rollback": "Откат",
"rollbackCompleted": "Откат завершен",
"rollbackDeployment": "Откат развертывания",
"rollbackFailed": "Откат не удался",
"rollbackFailed2": "Откат не удался: {{message}}",
"running": "Работает",
"saveThisSecretItWillNot": "Сохраните этот секрет! Оно больше не будет показано.",
"secretCopied": "Секрет скопирован.",
"serverNotRunning": "Сервер не работает",
"serverOverview": "Обзор сервера",
"serverPower": "Мощность сервера",
"serverSettings": "Настройки сервера",
"serverStatus": "Статус сервера",
"sshAccess": "SSH Access",
"sshCloneUrl": "SSH Clone URL",
"sshKeyRequirement": "SSH key requirement",
"sshPort": "SSH port",
"sshUrlCopied": "SSH URL copied",
"stars": "Звезды",
"startOrStopTheGiteaContainer": "Запустить или остановить контейнер Gitea",
"startServer": "Запустить сервер",
"startTheGitServerToBrowse": "Запустите сервер Git для просмотра репозиториев.",
"status5": "Статус:",
"stopGitServer": "Остановить Git-сервер",
"stopServer": "Остановить сервер",
"stopTheGitServer": "Остановить сервер Git?",
"stopsGiteaDataIsPreservedUnless": "Останавливает Gitea; данные сохраняются, пока не будут удалены вручную",
"storage": "Хранилище:",
"success": "Успешно",
"switch": "Выключатель",
"syncDirection": "Направление синхронизации",
"syncs": "Синхронизация",
"testEventLogged": "Тестовое событие зарегистрировано",
"testFailed": "Тест не пройден",
"testFailed2": "Тест не пройден: {{message}}",
"thisWillInstall": "Это установит:",
"triggeredBy": "Спровоцировано:",
"uninstall": "Удалить",
"uninstallGitServer": "Удалить Git-сервер",
"uninstallTheGiteaServerThisWill": "Удалить сервер Gitea? Это остановит контейнер, но сохранит ваши данные.",
"uninstallingWillStopTheGiteaContainer": "Удаление остановит работу контейнера Gitea. Ваши данные будут сохранены, если вы не решите их удалить.",
"updated": "Обновлено",
"updated7d": "Обновлено ≤7 дней",
"urlCopied": "URL-адрес скопирован",
"urlPath": "URL-путь",
"useThisSecretWhenConfiguringThe": "Используйте этот секрет при настройке веб-перехватчика в настройках вашего репозитория.",
"useYourSshKey": "Используйте свой SSH-ключ",
"viewLogs": "Просмотр журналов",
"visibility": "Видимость",
"webBrowserAccess": "Доступ через веб-браузер",
"webInterface": "Веб-интерфейс",
"webhook": "Вебхук",
"webhookCreatedSuccessfully": "Вебхук успешно создан",
"webhookDeleted": "Вебхук удален.",
"webhookSecret": "Секрет вебхука",
"webhookUrlCopied": "URL-адрес вебхука скопирован.",
"webhooks": "Вебхуки",
"zeroDowntime": "Нулевое время простоя",
"zeroDowntimeDeployment": "Развертывание без простоев"
},
"gitProviders": {
"anyGitRemote": "Любой удаленный Git",
"bitbucket": "Битбакет",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "Снимки"
},
"storagePanel": {
"createdBucket": "Создан сегмент \"{{name}}\"",
"createdDatabase": "Создана база данных \"{{name}}\"",
"createdNamespace": "Создано пространство имен \"{{title}}\"",
"d1Databases": "Базы данных D1",
"deleted": "Удалено",
"kvNamespaces": "Пространства имен KV",
"loadingStorage": "Загрузка хранилища…",
"r2Buckets": "Ведра R2",
"r2IsS3CompatibleSoA": "R2 совместим с S3, поэтому корзина здесь может позже создать резервные копии ServerKit.",
"storageUnavailable": "Хранилище недоступно"
},
"styleGuide": {
"2Found": "2 найдено",
"aNewVersionIsAvailableFor": "Новая версия доступна для обновления.",
@@ -6267,28 +5932,6 @@
"arguments": "Аргументы",
"result": "Результат"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Туннель Cloudflare предоставляет доступ к локальному или частному сервису через границу Cloudflare — публичный IP-адрес или открытые порты не требуются.",
"cloudflareTunnels": "Туннели Cloudflare (",
"cloudflareTunnelsUnavailable": "Туннели Cloudflare недоступны.",
"copied": "Скопировано",
"couldNotCopyTheInstallCommand": "Не удалось скопировать команду установки",
"createACloudflareTunnel": "Создайте туннель Cloudflare",
"createOneAboveToExposeA": "Создайте его выше, чтобы предоставить доступ к локальной службе без общедоступного IP-адреса.",
"createdTunnel": "Создан туннель \"{{name}}\"",
"deletedCloudflareTunnel": "Удален туннель Cloudflare \"{{name}}\"",
"hostnameRemoved": "Имя хоста удалено",
"hostnameRouted": "Имя хоста маршрутизировано",
"hostnames": "Имена хостов",
"installCommand": "Команда установки",
"loadingCloudflareTunnels": "Загрузка туннелей Cloudflare…",
"noCloudflareTunnels": "Никаких туннелей Cloudflare",
"noInstallCommandAvailable": "Команда установки недоступна.",
"noPublicHostnamesRoutedYet": "Публичные имена хостов пока не маршрутизированы.",
"routeSetButTheDnsRecord": "Маршрут установлен, но DNS-запись не удалась:",
"runThisOnceOnTheMachine": "Запустите это один раз на компьютере, на котором размещена ваша локальная служба. Токен коннектора отображается только сейчас.",
"theHostnameShouldBeInsideThis": "Имя хоста должно находиться внутри домена этой зоны; прокси-запись DNS создается автоматически."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Количество дней, в течение которых новая учетная запись может продолжать входить в систему, используя только пароль, прежде чем регистрация будет применена принудительно.",
"gracePeriodDays": "Льготный период (дни)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "Виджеты поиска…",
"typesDragToRepositionAfterAdding": "Типы {{length}} · перетащите, чтобы изменить положение после добавления"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Прикрепляет работника к этому домену, чтобы соответствующий трафик запускал сценарий.",
"code": "Код",
"deletedWorker": "Удален работник \"{{name}}\"",
"deploy": "Развернуть",
"deployAWorker": "Развертывание работника",
"deployYourFirstEdgeScriptAbove": "Разверните свой первый пограничный скрипт, описанный выше.",
"deployedWorker": "Развернутый работник \"{{name}}\"",
"loadingWorkers": "Загрузка рабочих…",
"noRoutesYetAddARoute": "Маршрутов пока нет. Добавьте шаблон маршрута при развертывании для отправки трафика работнику.",
"noWorkersDeployed": "Рабочие не задействованы",
"routePatternOptional": "Схема маршрута (необязательно)",
"routeRemoved": "Маршрут удален.",
"routes": "Маршруты (",
"workerDeployedButTheRouteFailed": "Worker развернут, но маршрут не пройден:",
"workers": "Рабочие (",
"workersUnavailable": "Рабочие недоступны"
},
"workspaceDetail": {
"accessGranted": "Доступ предоставлен",
"accessRevoked": "Доступ отозван",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "ชื่อเซิร์ฟเวอร์",
"serverProvisioningInitiated": "เริ่มต้นการจัดเตรียมเซิร์ฟเวอร์แล้ว"
},
"cloudflareWafPanel": {
"activeRules": "กฎที่ใช้งานอยู่ (",
"addAQuickRuleOrA": "เพิ่มกฎด่วนหรือกฎที่กำหนดเองด้านบน",
"addCustomRule": "เพิ่มกฎที่กำหนดเอง",
"addRule": "เพิ่มกฎ",
"addedQuickRule": "เพิ่มกฎด่วน: {{label}}",
"applyACuratedCloudflareRuleWithout": "ใช้กฎ Cloudflare ที่ดูแลจัดการโดยไม่ต้องเขียนนิพจน์ด้วยมือ",
"block": "ปิดกั้น",
"blockAdminFromOutsideTheOffice": "บล็อกผู้ดูแลระบบจากภายนอกสำนักงาน",
"chooseAQuickRule": "เลือกกฎด่วน...",
"customRule": "กฎที่กำหนดเอง",
"customRuleAdded": "เพิ่มกฎที่กำหนดเองแล้ว",
"expression": "การแสดงออก",
"firewallRulesUnavailable": "กฎไฟร์วอลล์ไม่พร้อมใช้งาน",
"jsChallenge": "เจเอส ท้าทาย",
"loadingFirewallRules": "กำลังโหลดกฎไฟร์วอลล์...",
"log": "บันทึก",
"managedChallenge": "ความท้าทายที่มีการจัดการ",
"noCustomFirewallRules": "ไม่มีกฎไฟร์วอลล์ที่กำหนดเอง",
"quickRules": "กฎด่วน",
"ruleDeleted": "ลบกฎแล้ว",
"writeYourOwnCloudflareFirewallExpression": "เขียนนิพจน์ไฟร์วอลล์ Cloudflare ของคุณเองเพื่อการควบคุมเต็มรูปแบบ"
},
"cloudflareZoneSettings": {
"1Day": "1 วัน",
"1Month": "1 เดือน",
"1Week": "1 สัปดาห์",
"1Year": "1 ปี",
"2Years": "2 ปี",
"6Months": "6 เดือน",
"appliedNeedAHigherCloudflarePlan": "ใช้ {{applied}}/{{total}} {{length}} ต้องการแผน Cloudflare ที่สูงกว่า",
"appliedRecommendedHardeningSettings": "ใช้การชุบแข็งที่แนะนำ (การตั้งค่า {{applied}})",
"applyRecommendedHardening": "ใช้การชุบแข็งที่แนะนำ",
"backToDnsZones": "กลับไปที่โซน DNS",
"changingCloudflareSettingsRequiresAnAdmin": "การเปลี่ยนการตั้งค่า Cloudflare จำเป็นต้องมีบัญชีผู้ดูแลระบบ",
"clearCloudflareSCachedCopyOf": "ล้างสำเนาแคชของไซต์ของคุณ Cloudflare เพื่อให้ผู้เยี่ยมชมได้รับเนื้อหาใหม่ ล้างข้อมูลทั้งหมด หรือระบุ URL เฉพาะด้านล่าง",
"clearCloudflareSEntireCacheFor": "ล้างแคชทั้งหมดของ Cloudflare สำหรับ {{value}} หรือไม่",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "การตั้งค่า Cloudflare ไม่พร้อมใช้งาน",
"dnsZones": "โซน DNS",
"enabled": "เปิดใช้งานแล้ว",
"enterAtLeastOneUrlTo": "ป้อน URL อย่างน้อยหนึ่งรายการเพื่อล้างข้อมูล",
"failedToApplyPreset": "ล้มเหลวในการใช้การตั้งค่าล่วงหน้า",
"failedToPurgeCache": "ล้างแคชไม่สำเร็จ",
"failedToPurgeUrls": "ไม่สามารถล้างข้อมูล URLs",
"failedToSaveSetting": "ไม่สามารถบันทึกการตั้งค่า",
"includeSubdomains": "รวมโดเมนย่อย",
"itReCachesThisIsSafe": "มันแคชอีกครั้ง ซึ่งปลอดภัยแต่สามารถเพิ่มภาระต้นทางได้ในช่วงสั้นๆ",
"lockedByYourCloudflarePlan": "ล็อคโดยแผน Cloudflare ของคุณ",
"maxAge": "อายุสูงสุด",
"notAvailableOnThisZone": "ไม่มีให้บริการในโซนนี้",
"off": "ปิด",
"preload": "โหลดล่วงหน้า",
"purgeCache": "ล้างแคช",
"purgeEntireCache": "ล้างแคชทั้งหมด",
"purgeEverything": "ล้างทุกอย่าง",
"purgeSpecificUrlsOnePerLine": "ล้างข้อมูล URL เฉพาะ (หนึ่งรายการต่อบรรทัด สูงสุด 30 รายการ)",
"purgeUrls": "ล้าง URL",
"purgedTheEntireCache": "ล้างแคชทั้งหมดแล้ว",
"purgedUrlFromCache": "ล้างข้อมูล {{n}} URL{{value}} จากแคชแล้ว",
"setsFullStrictSslAlwaysUse": "ตั้งค่า SSL แบบเต็ม (เข้มงวด) ใช้ HTTPS เสมอ HSTS (6 เดือน) พื้น TLS 1.2 พร้อมด้วย TLS 1.3, Brotli, HTTP/3 และแคชเบราว์เซอร์ 4 ชั่วโมง การตั้งค่าที่แผนของคุณไม่อนุญาตจะถูกข้ามไป",
"settingSaved": "บันทึกการตั้งค่าแล้ว",
"theNextVisitToEachPage": "การเข้าชมครั้งต่อไปแต่ละหน้าจะให้บริการตั้งแต่ต้นทางจนถึง",
"tunnels": "อุโมงค์",
"workers": "คนงาน"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "CNAME สำหรับชื่อโฮสต์นี้จะถูกสร้างขึ้นใน Cloudflare DNS โดยชี้ไปที่อุโมงค์",
"askingTheAgentToStartThe": "กำลังขอให้ตัวแทนเริ่มขั้นตอนการเข้าสู่ระบบ Cloudflare...",
@@ -1785,76 +1719,6 @@
"aiSettings": "การตั้งค่าเอไอ",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "preflight ความสามารถในการจัดส่ง (PTR, พอร์ต 25, RBL) จะเกตการส่งขาออกก่อนที่เมลจะออกจากโฮสต์นี้",
"accountCreated": "สร้างบัญชีแล้ว",
"accountDeleted": "ลบบัญชีแล้ว",
"accounts": "บัญชี",
"addAMailDomainFirst": "เพิ่มโดเมนอีเมลก่อน",
"addDomain": "เพิ่มโดเมน",
"address": "ที่อยู่",
"component": "ส่วนประกอบ",
"couldNotAddDomain": "ไม่สามารถเพิ่มโดเมนได้",
"createFailed": "สร้างไม่สำเร็จ",
"delete3": "ลบ {{email}} หรือไม่",
"deleteAccount": "ลบบัญชี",
"deleteAndAllOfItsAccounts": "ลบ {{name}} รวมถึงบัญชีและนามแฝงทั้งหมดหรือไม่",
"deleteDomain": "ลบโดเมน",
"deleteFailed": "ลบไม่สำเร็จ",
"deployDns": "ปรับใช้ DNS",
"deployFailedNoLinkedDnsProvider": "การปรับใช้ล้มเหลว (ไม่มีผู้ให้บริการ DNS ที่เชื่อมโยงหรือไม่)",
"dkimSelector": "ตัวเลือก DKIM:",
"dnsCheckFailed": "การตรวจสอบ DNS ล้มเหลว",
"dnsChecked": "ตรวจสอบ DNS แล้ว",
"dnsRecordsDeployedToProvider": "บันทึก DNS ถูกปรับใช้กับผู้ให้บริการ",
"domainAdded": "เพิ่มโดเมนแล้ว",
"domainDeleted": "ลบโดเมนแล้ว",
"emailServerInstallStarted": "เริ่มการติดตั้งเซิร์ฟเวอร์อีเมลแล้ว",
"enableRelay": "เปิดใช้งานรีเลย์",
"enableSpamFiltering": "เปิดใช้งานการกรองสแปม",
"failedTo": "ล้มเหลวในการ {{action}} {{component}}",
"failedToDisableRelay": "ไม่สามารถปิดใช้งานรีเลย์",
"failedToWebmail": "ไม่สามารถส่งเว็บเมล {{action}}",
"flushFailed": "ฟลัชล้มเหลว",
"flushQueue": "คิวฟลัช",
"host": "เจ้าภาพ",
"installFailed": "การติดตั้งล้มเหลว",
"installMailServer": "ติดตั้งเมลเซิร์ฟเวอร์",
"installWebmail": "ติดตั้งเว็บเมล",
"mailServerNotInstalled": "ไม่ได้ติดตั้งเมลเซิร์ฟเวอร์",
"mailboxPassword": "รหัสผ่านกล่องจดหมาย",
"mailboxUsername": "ชื่อผู้ใช้กล่องจดหมาย",
"mostPanelsNeverRunAMail": "แผงส่วนใหญ่ไม่เคยใช้เมลเซิร์ฟเวอร์ ติดตั้งเมื่อคุณต้องการ",
"newMailDomain": "โดเมนเมลใหม่",
"noMailDomainsYet": "ยังไม่มีโดเมนเมล",
"outboundRelaySmarthost": "รีเลย์ขาออก (สมาร์ทโฮสต์)",
"queue": "คิว",
"queueFlushed": "คิวถูกล้าง",
"quota": "โควต้า",
"quotaInMb": "โควต้าเป็น MB",
"quotaMb": "โควต้า (MB)",
"recipient": "ผู้รับ",
"relayDisabled": "รีเลย์ปิดการใช้งาน",
"relayReachable": "เข้าถึงรีเลย์ได้",
"relaySaved": "บันทึกรีเลย์แล้ว",
"relayTestFailed": "การทดสอบรีเลย์ล้มเหลว",
"requiredScore": "คะแนนที่ต้องการ",
"roundcubeWebmail": "เว็บเมล Roundcube",
"ruleUpdateFailed": "การอัปเดตกฎล้มเหลว",
"ruleUpdateStarted": "เริ่มการอัปเดตกฎแล้ว",
"saveFailed": "บันทึกล้มเหลว",
"searchAccounts": "ค้นหาบัญชี",
"searchAccountsByAddress": "ค้นหาบัญชีตามที่อยู่...",
"searchQueueBySenderRecipientOr": "ค้นหาคิวตามผู้ส่ง ผู้รับ หรือ ID...",
"sender": "ผู้ส่ง",
"spamConfigSaved": "บันทึกการกำหนดค่าสแปมแล้ว",
"spamassassin": "สแปมนักฆ่า",
"updateRules": "กฎการอัพเดท",
"used": "ใช้แล้ว",
"webmail": "เว็บเมล: {{action}}",
"webmailInstallStarted": "เริ่มต้นการติดตั้งเว็บเมล",
"webmailIsNotInstalled": "ไม่ได้ติดตั้งเว็บเมล"
},
"emailProviders": {
"addedButTestFailed": "เพิ่มแล้ว แต่การทดสอบล้มเหลว: {{value}}",
"confirm": "ยืนยัน",
@@ -2404,193 +2268,6 @@
"empty": "ว่างเปล่า",
"expand": "ขยาย"
},
"git": {
"512mbMinimum1gbRecommended": "ขั้นต่ำ ~512MB (แนะนำ 1GB)",
"5gbForDatabaseRepositories": "~5GB สำหรับฐานข้อมูล + ที่เก็บข้อมูล",
"access": "เข้าถึง",
"accessInformation": "เข้าถึงข้อมูล",
"accessRepositories": "เข้าถึงที่เก็บข้อมูล",
"active": "คล่องแคล่ว",
"addAWebhookToSyncRepositories": "เพิ่มเว็บฮุคเพื่อซิงค์ที่เก็บจากแหล่งภายนอก",
"addWebhook": "เพิ่มเว็บฮุค",
"addYourPublicKeyInGitea": "เพิ่มคีย์สาธารณะของคุณใน Gitea",
"adminEmail": "อีเมล์ผู้ดูแลระบบ",
"adminEmailIsRequired": "จำเป็นต้องมีอีเมลผู้ดูแลระบบ",
"adminPasswordLeaveEmptyToAuto": "รหัสผ่านผู้ดูแลระบบ (เว้นว่างไว้เพื่อสร้างอัตโนมัติ)",
"adminPasswordSaveThis": "รหัสผ่านผู้ดูแลระบบ: {{adminpassword}} (บันทึกนี้!)",
"adminUsername": "ชื่อผู้ใช้ของผู้ดูแลระบบ",
"application": "แอปพลิเคชัน",
"areYouSureYouWantTo": "คุณแน่ใจหรือไม่ว่าต้องการลบเว็บฮุคนี้ สิ่งนี้ไม่สามารถยกเลิกได้",
"autoGenerateSecurePassword": "สร้างรหัสผ่านที่ปลอดภัยโดยอัตโนมัติ",
"autoSyncOnPushEvents": "ซิงค์อัตโนมัติกับเหตุการณ์พุช",
"automaticallyDeployWhenCodeIsPushed": "ปรับใช้โดยอัตโนมัติเมื่อมีการพุชโค้ดไปที่สาขา",
"bidirectional": "แบบสองทิศทาง",
"bitbucket": "บิทบัคเก็ต",
"branch7": "สาขา:",
"branches": "สาขา",
"cloneRepositoriesViaSsh": "โคลนที่เก็บผ่าน SSH",
"commit": "ให้สัญญา",
"commit2": "ให้สัญญา:",
"commitMessage": "ส่งข้อความยืนยัน",
"commits": "มุ่งมั่น",
"components": "ส่วนประกอบ:",
"configureAWebhookWithDeployOn": "กำหนดค่า webhook โดยเปิดใช้งาน \"Deploy on Push\" เพื่อดูการใช้งานที่นี่",
"configureWebhooks": "กำหนดค่า Webhooks",
"copySshUrl": "คัดลอก SSH URL",
"copyUrl": "คัดลอก URL",
"createYourFirstRepositoryInGitea": "สร้างที่เก็บแรกของคุณใน Gitea",
"dangerZone": "โซนอันตราย",
"defaultBranch": "สาขาเริ่มต้น",
"deleteWebhook": "ลบเว็บฮุค",
"deployOnPush": "ปรับใช้เมื่อกด",
"deployToApplication": "ปรับใช้กับแอปพลิเคชัน",
"deployment": "การปรับใช้",
"deploymentHistory": "ประวัติการทำให้ใช้งานได้",
"deploymentOutput": "เอาท์พุตการปรับใช้",
"deploymentSettings": "การตั้งค่าการปรับใช้",
"deploymentStartedV": "เริ่มต้นการปรับใช้: v{{version}}",
"deploymentV": "การปรับใช้ v{{version}}",
"deploymentVLogs": "บันทึกการใช้งาน v{{version}}",
"deployments": "การปรับใช้",
"duration": "ระยะเวลา:",
"error": "ข้อผิดพลาด",
"error2": "ข้อผิดพลาด:",
"failedToCreateWebhook": "สร้างเว็บฮุคไม่สำเร็จ",
"failedToCreateWebhook2": "ไม่สามารถสร้าง webhook: {{message}}",
"failedToDelete": "ไม่สามารถลบ: {{message}}",
"failedToDeploy": "ไม่สามารถปรับใช้: {{message}}",
"failedToInstall": "ติดตั้งไม่สำเร็จ: {{message}}",
"failedToLoadDeploymentDetails": "โหลดรายละเอียดการทำให้ใช้งานได้ไม่สำเร็จ",
"failedToLoadDirectory": "ไม่สามารถโหลดไดเร็กทอรี",
"failedToLoadLogs": "ไม่สามารถโหลดบันทึก: {{message}}",
"failedToLoadMoreCommits": "โหลดการคอมมิตเพิ่มเติมไม่สำเร็จ",
"failedToLoadRepositoryDetails": "โหลดรายละเอียดที่เก็บไม่สำเร็จ",
"failedToStart": "ไม่สามารถเริ่มต้น: {{message}}",
"failedToStop": "ไม่สามารถหยุด: {{message}}",
"failedToSwitchBranch": "ไม่สามารถเปลี่ยนสาขาได้",
"failedToToggleWebhook": "ไม่สามารถสลับ webhook: {{message}}",
"failedToTriggerDeployment": "ทริกเกอร์การทำให้ใช้งานได้ล้มเหลว",
"failedToUninstall": "ไม่สามารถถอนการติดตั้ง: {{message}}",
"files": "ไฟล์",
"forks": "ส้อม",
"gitServerInstalledSuccessfully": "ติดตั้งเซิร์ฟเวอร์ Git สำเร็จแล้ว",
"gitServerStarted": "เซิร์ฟเวอร์ Git เริ่มทำงาน",
"gitServerStopped": "เซิร์ฟเวอร์ Git หยุดทำงาน",
"gitServerUninstalled": "ถอนการติดตั้งเซิร์ฟเวอร์ Git แล้ว",
"giteaGitServer300mbRam": "Gitea (เซิร์ฟเวอร์ Git) - ~ RAM 300MB",
"giteaPostgresqlDatabase": "ฐานข้อมูล Gitea + PostgreSQL",
"giteaServerIsNotRunning": "เซิร์ฟเวอร์ Gitea ไม่ทำงาน",
"giteaVersion": "รุ่น Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "การเข้าถึง HTTP",
"httpUrl": "HTTP URL",
"iVeSavedIt": "ฉันบันทึกไว้แล้ว",
"inactive": "ไม่ได้ใช้งาน",
"installGitServer": "ติดตั้งเซิร์ฟเวอร์ Git",
"installGiteaToHostAndManage": "ติดตั้ง Gitea เพื่อโฮสต์และจัดการที่เก็บ Git ของคุณภายในเครื่อง",
"installationFailed": "การติดตั้งล้มเหลว",
"lastResult": "ผลลัพธ์สุดท้าย",
"lastSync": "การซิงค์ครั้งล่าสุด",
"leaveEmptyToUseSameName": "เว้นว่างไว้เพื่อใช้ชื่อเดียวกัน",
"loadMore": "โหลดเพิ่มเติม",
"loadingDeployments": "กำลังโหลดการทำให้ใช้งานได้",
"loadingGit": "กำลังโหลด Git",
"loadingRepositories": "กำลังโหลดที่เก็บข้อมูล",
"loadingWebhooks": "กำลังโหลดเว็บฮุค",
"localRepositoryNameOptional": "ชื่อที่เก็บในเครื่อง (ไม่บังคับ)",
"manual": "คู่มือ",
"memory": "หน่วยความจำ:",
"myGithubRepo": "การซื้อ GitHub ของฉัน",
"nameAndRepositoryUrlAreRequired": "ต้องมีชื่อและที่เก็บ URL",
"noDeploymentLogsAvailable": "ไม่มีบันทึกการใช้งาน",
"noDeploymentsForThisWebhook": "ไม่มีการปรับใช้สำหรับเว็บฮุคนี้",
"noDeploymentsMatchThisFilter": "ไม่มีการปรับใช้ที่ตรงกับตัวกรองนี้",
"noDeploymentsYet": "ยังไม่มีการทำให้ใช้งานได้",
"noDescription": "ไม่มีคำอธิบาย",
"noGitServerInstalled": "ไม่มีการติดตั้งเซิร์ฟเวอร์ Git",
"noRepositoriesYet": "ยังไม่มีที่เก็บข้อมูล",
"noWebhooksConfigured": "ไม่มีการกำหนดค่าเว็บฮุค",
"noneSyncOnly": "ไม่มี (ซิงค์เท่านั้น)",
"openGitea": "เปิด Gitea",
"openInGitea": "เปิดใน Gitea",
"optionallyDeployAnApplicationWhenCode": "เลือกใช้แอปพลิเคชันเมื่อมีการพุชโค้ด",
"postDeployScriptOptional": "สคริปต์หลังปรับใช้ (ไม่บังคับ)",
"postDeploymentScriptOutput": "เอาต์พุตสคริปต์หลังการปรับใช้",
"postgresqlDatabase200mbRam": "ฐานข้อมูล PostgreSQL - ~200MB RAM",
"preDeployScriptOptional": "สคริปต์ก่อนปรับใช้ (ไม่บังคับ)",
"preDeploymentScriptOutput": "เอาต์พุตสคริปต์ก่อนการปรับใช้",
"private": "ส่วนตัว",
"pullExternalLocal": "ดึง (ภายนอก → ท้องถิ่น)",
"pushLocalExternal": "พุช (ท้องถิ่น → ภายนอก)",
"quickActions": "การดำเนินการด่วน",
"recentDeployments": "การใช้งานล่าสุด",
"repositories": "ที่เก็บ",
"repository": "พื้นที่เก็บข้อมูล",
"repositoryUrl": "พื้นที่เก็บข้อมูล URL",
"repositoryUrlCopied": "คัดลอกที่เก็บ URL แล้ว",
"requiredForSshClonePush": "จำเป็นสำหรับโคลน/พุช SSH",
"resourceRequirements": "ความต้องการทรัพยากร",
"rollback": "ย้อนกลับ",
"rollbackCompleted": "ย้อนกลับเสร็จแล้ว",
"rollbackDeployment": "การปรับใช้แบบย้อนกลับ",
"rollbackFailed": "การย้อนกลับล้มเหลว",
"rollbackFailed2": "การย้อนกลับล้มเหลว: {{message}}",
"running": "วิ่ง",
"saveThisSecretItWillNot": "บันทึกความลับนี้! มันจะไม่แสดงอีก",
"secretCopied": "คัดลอกข้อมูลลับแล้ว",
"serverNotRunning": "เซิร์ฟเวอร์ไม่ทำงาน",
"serverOverview": "ภาพรวมเซิร์ฟเวอร์",
"serverPower": "พลังเซิร์ฟเวอร์",
"serverSettings": "การตั้งค่าเซิร์ฟเวอร์",
"serverStatus": "สถานะเซิร์ฟเวอร์",
"sshAccess": "การเข้าถึง SSH",
"sshCloneUrl": "SSH โคลน URL",
"sshKeyRequirement": "ข้อกำหนดคีย์ SSH",
"sshPort": "พอร์ต SSH",
"sshUrlCopied": "SSH URL คัดลอกแล้ว",
"stars": "ดาว",
"startOrStopTheGiteaContainer": "เริ่มต้นหรือหยุดคอนเทนเนอร์ Gitea",
"startServer": "เริ่มเซิร์ฟเวอร์",
"startTheGitServerToBrowse": "เริ่มเซิร์ฟเวอร์ Git เพื่อเรียกดูที่เก็บข้อมูล",
"status5": "สถานะ:",
"stopGitServer": "หยุดเซิร์ฟเวอร์ Git",
"stopServer": "หยุดเซิร์ฟเวอร์",
"stopTheGitServer": "หยุดเซิร์ฟเวอร์ Git หรือไม่",
"stopsGiteaDataIsPreservedUnless": "หยุด Gitea; ข้อมูลจะถูกเก็บรักษาไว้เว้นแต่จะถูกลบออกด้วยตนเอง",
"storage": "พื้นที่จัดเก็บ:",
"success": "ความสำเร็จ",
"switch": "สวิตช์",
"syncDirection": "ทิศทางการซิงโครไนซ์",
"syncs": "ซิงค์",
"testEventLogged": "บันทึกกิจกรรมการทดสอบแล้ว",
"testFailed": "การทดสอบล้มเหลว",
"testFailed2": "การทดสอบล้มเหลว: {{message}}",
"thisWillInstall": "สิ่งนี้จะติดตั้ง:",
"triggeredBy": "กระตุ้นโดย:",
"uninstall": "ถอนการติดตั้ง",
"uninstallGitServer": "ถอนการติดตั้งเซิร์ฟเวอร์ Git",
"uninstallTheGiteaServerThisWill": "ถอนการติดตั้งเซิร์ฟเวอร์ Gitea หรือไม่ การดำเนินการนี้จะหยุดคอนเทนเนอร์แต่จะเก็บข้อมูลของคุณไว้",
"uninstallingWillStopTheGiteaContainer": "การถอนการติดตั้งจะหยุดคอนเทนเนอร์ Gitea ข้อมูลของคุณจะถูกเก็บรักษาไว้เว้นแต่คุณจะเลือกที่จะลบออก",
"updated": "อัปเดตแล้ว",
"updated7d": "อัปเดต ≤7d",
"urlCopied": "คัดลอก URL แล้ว",
"urlPath": "เส้นทาง URL",
"useThisSecretWhenConfiguringThe": "ใช้ข้อมูลลับนี้เมื่อกำหนดค่าเว็บฮุคในการตั้งค่าที่เก็บของคุณ",
"useYourSshKey": "ใช้คีย์ SSH ของคุณ",
"viewLogs": "ดูบันทึก",
"visibility": "ทัศนวิสัย",
"webBrowserAccess": "การเข้าถึงเว็บเบราว์เซอร์",
"webInterface": "เว็บอินเตอร์เฟส",
"webhook": "เว็บฮุค",
"webhookCreatedSuccessfully": "สร้าง Webhook สำเร็จแล้ว",
"webhookDeleted": "ลบเว็บฮุคแล้ว",
"webhookSecret": "ความลับของเว็บฮุค",
"webhookUrlCopied": "คัดลอก Webhook URL แล้ว",
"webhooks": "เว็บฮุค",
"zeroDowntime": "หยุดทำงานเป็นศูนย์",
"zeroDowntimeDeployment": "การใช้งานแบบไม่ต้องหยุดทำงาน"
},
"gitProviders": {
"anyGitRemote": "รีโมท Git ใด ๆ",
"bitbucket": "บิทบัคเก็ต",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "สแนปชอต"
},
"storagePanel": {
"createdBucket": "สร้างที่เก็บข้อมูล \"{{name}}\"",
"createdDatabase": "สร้างฐานข้อมูล \"{{name}}\"",
"createdNamespace": "สร้างเนมสเปซ \"{{title}}\"",
"d1Databases": "ฐานข้อมูล D1",
"deleted": "ลบแล้ว",
"kvNamespaces": "เนมสเปซ KV",
"loadingStorage": "กำลังโหลดพื้นที่เก็บข้อมูล...",
"r2Buckets": "ถัง R2",
"r2IsS3CompatibleSoA": "R2 เข้ากันได้กับ S3 ดังนั้นบัคเก็ตที่นี่จึงสามารถสำรองการสำรองข้อมูล ServerKit ได้ในภายหลัง",
"storageUnavailable": "พื้นที่เก็บข้อมูลไม่พร้อมใช้งาน"
},
"styleGuide": {
"2Found": "พบ 2 รายการ",
"aNewVersionIsAvailableFor": "มีเวอร์ชันใหม่ให้อัปเดตแล้ว",
@@ -6267,28 +5932,6 @@
"arguments": "ข้อโต้แย้ง",
"result": "ผลลัพธ์"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "อุโมงค์ Cloudflare เปิดเผยบริการท้องถิ่นหรือส่วนตัวผ่าน Edge ของ Cloudflare โดยไม่ต้องใช้ IP สาธารณะหรือพอร์ตเปิด",
"cloudflareTunnels": "อุโมงค์ Cloudflare (",
"cloudflareTunnelsUnavailable": "อุโมงค์ Cloudflare ไม่พร้อมใช้งาน",
"copied": "คัดลอกแล้ว",
"couldNotCopyTheInstallCommand": "ไม่สามารถคัดลอกคำสั่งติดตั้งได้",
"createACloudflareTunnel": "สร้างอุโมงค์ Cloudflare",
"createOneAboveToExposeA": "สร้างรายการด้านบนเพื่อแสดงบริการในพื้นที่โดยไม่มี IP สาธารณะ",
"createdTunnel": "สร้างอุโมงค์ \"{{name}}\"",
"deletedCloudflareTunnel": "ลบอุโมงค์ Cloudflare \"{{name}}\" แล้ว",
"hostnameRemoved": "ลบชื่อโฮสต์แล้ว",
"hostnameRouted": "กำหนดเส้นทางชื่อโฮสต์แล้ว",
"hostnames": "ชื่อโฮสต์",
"installCommand": "คำสั่งติดตั้ง",
"loadingCloudflareTunnels": "กำลังโหลดอุโมงค์ Cloudflare...",
"noCloudflareTunnels": "ไม่มีอุโมงค์ Cloudflare",
"noInstallCommandAvailable": "ไม่มีคำสั่งการติดตั้ง",
"noPublicHostnamesRoutedYet": "ยังไม่มีการกำหนดเส้นทางชื่อโฮสต์สาธารณะ",
"routeSetButTheDnsRecord": "ตั้งค่าเส้นทางแล้ว แต่บันทึก DNS ล้มเหลว:",
"runThisOnceOnTheMachine": "รันสิ่งนี้หนึ่งครั้งบนเครื่องที่โฮสต์บริการในพื้นที่ของคุณ โทเค็นตัวเชื่อมต่อจะแสดงเฉพาะตอนนี้เท่านั้น",
"theHostnameShouldBeInsideThis": "ชื่อโฮสต์ควรอยู่ภายในโดเมนของโซนนี้ บันทึก DNS พร็อกซีจะถูกสร้างขึ้นโดยอัตโนมัติ"
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "วันที่บัญชีที่เพิ่งครอบคลุมอาจลงชื่อเข้าใช้ด้วยรหัสผ่านเพียงอย่างเดียวก่อนที่จะบังคับใช้การลงทะเบียน",
"gracePeriodDays": "ระยะเวลาผ่อนผัน (วัน)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "วิดเจ็ตการค้นหา...",
"typesDragToRepositionAfterAdding": "ประเภท {{length}} · ลากเพื่อเปลี่ยนตำแหน่งหลังจากเพิ่ม"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "แนบผู้ปฏิบัติงานเข้ากับโดเมนนี้เพื่อให้การรับส่งข้อมูลที่ตรงกันเรียกใช้สคริปต์",
"code": "รหัส",
"deletedWorker": "ผู้ปฏิบัติงานที่ถูกลบ \"{{name}}\"",
"deploy": "ปรับใช้",
"deployAWorker": "ปรับใช้คนงาน",
"deployYourFirstEdgeScriptAbove": "ปรับใช้สคริปต์ Edge แรกของคุณด้านบน",
"deployedWorker": "ผู้ปฏิบัติงานที่ปรับใช้ \"{{name}}\"",
"loadingWorkers": "กำลังโหลดคนงาน...",
"noRoutesYetAddARoute": "ยังไม่มีเส้นทาง เพิ่มรูปแบบเส้นทางเมื่อใช้งานเพื่อส่งการรับส่งข้อมูลไปยังผู้ปฏิบัติงาน",
"noWorkersDeployed": "ไม่มีการจัดวางคนงาน",
"routePatternOptional": "รูปแบบเส้นทาง (ไม่บังคับ)",
"routeRemoved": "ลบเส้นทางแล้ว",
"routes": "เส้นทาง (",
"workerDeployedButTheRouteFailed": "ผู้ปฏิบัติงานปรับใช้แล้ว แต่เส้นทางล้มเหลว:",
"workers": "คนงาน (",
"workersUnavailable": "คนงานไม่ว่าง"
},
"workspaceDetail": {
"accessGranted": "ให้สิทธิ์การเข้าถึงแล้ว",
"accessRevoked": "การเข้าถึงถูกเพิกถอน",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Sunucu Adı",
"serverProvisioningInitiated": "Sunucu temel hazırlığı başlatıldı"
},
"cloudflareWafPanel": {
"activeRules": "Etkin kurallar (",
"addAQuickRuleOrA": "Yukarıya hızlı bir kural veya özel bir kural ekleyin.",
"addCustomRule": "Özel kural ekle",
"addRule": "Kural ekle",
"addedQuickRule": "Hızlı kural eklendi: {{label}}",
"applyACuratedCloudflareRuleWithout": "Elle bir ifade yazmadan seçilmiş bir Cloudflare kuralını uygulayın.",
"block": "Engellemek",
"blockAdminFromOutsideTheOffice": "Yöneticinin ofis dışından engellenmesi",
"chooseAQuickRule": "Hızlı bir kural seçin…",
"customRule": "Özel kural",
"customRuleAdded": "Özel kural eklendi",
"expression": "İfade",
"firewallRulesUnavailable": "Güvenlik duvarı kuralları kullanılamıyor",
"jsChallenge": "JS Mücadelesi",
"loadingFirewallRules": "Güvenlik duvarı kuralları yükleniyor…",
"log": "Kayıt",
"managedChallenge": "Yönetilen Mücadele",
"noCustomFirewallRules": "Özel güvenlik duvarı kuralı yok",
"quickRules": "Hızlı kurallar",
"ruleDeleted": "Kural silindi",
"writeYourOwnCloudflareFirewallExpression": "Tam kontrol için kendi Cloudflare güvenlik duvarı ifadenizi yazın."
},
"cloudflareZoneSettings": {
"1Day": "1 gün",
"1Month": "1 ay",
"1Week": "1 hafta",
"1Year": "1 yıl",
"2Years": "2 yıl",
"6Months": "6 ay",
"appliedNeedAHigherCloudflarePlan": "Uygulanan {{applied}}/{{total}}. {{length}} daha yüksek bir Cloudflare planına ihtiyaç duyar.",
"appliedRecommendedHardeningSettings": "Önerilen sertleştirme uygulandı ({{applied}} ayarları)",
"applyRecommendedHardening": "Önerilen sertleştirmeyi uygulayın",
"backToDnsZones": "DNS Bölgelerine Geri Dön",
"changingCloudflareSettingsRequiresAnAdmin": "Cloudflare ayarlarının değiştirilmesi bir yönetici hesabı gerektirir.",
"clearCloudflareSCachedCopyOf": "Ziyaretçilerin yeni içeriğe ulaşması için Cloudflare'nin sitenizin önbelleğe alınmış kopyasını temizleyin. Her şeyi temizleyin veya belirli URL'leri aşağıda listeleyin.",
"clearCloudflareSEntireCacheFor": "Cloudflare'nin {{value}} için önbelleğinin tamamı temizlensin mi?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Cloudflare ayarları kullanılamıyor",
"dnsZones": "DNS Bölgeleri",
"enabled": "Etkinleştirilmiş",
"enterAtLeastOneUrlTo": "Temizlemek için en az bir URL girin",
"failedToApplyPreset": "Ön ayar uygulanamadı",
"failedToPurgeCache": "Önbellek temizlenemedi",
"failedToPurgeUrls": "URL'ler temizlenemedi",
"failedToSaveSetting": "Ayar kaydedilemedi",
"includeSubdomains": "Alt alanları dahil et",
"itReCachesThisIsSafe": "yeniden önbelleğe alır. Bu güvenlidir ancak başlangıç yükünü kısa süreliğine artırabilir.",
"lockedByYourCloudflarePlan": "Cloudflare planınız tarafından kilitlendi",
"maxAge": "Maksimum yaş",
"notAvailableOnThisZone": "Bu bölgede mevcut değil",
"off": "Kapalı",
"preload": "Ön yükleme",
"purgeCache": "Önbelleği temizle",
"purgeEntireCache": "Önbelleğin tamamını temizle",
"purgeEverything": "Her şeyi temizle",
"purgeSpecificUrlsOnePerLine": "Belirli URL'leri temizleyin (satır başına bir tane, en fazla 30)",
"purgeUrls": "URL'leri temizle",
"purgedTheEntireCache": "Önbelleğin tamamını temizledim",
"purgedUrlFromCache": "{{n}} URL{{value}} önbellekten temizlendi",
"setsFullStrictSslAlwaysUse": "Tam (katı) SSL, Her Zaman HTTPS Kullan, HSTS (6 ay), TLS 1.3 ile TLS 1.2 kat, Brotli, HTTP/3 ve 4 saatlik tarayıcı önbelleği. Planınızın izin vermediği ayarlar atlanır.",
"settingSaved": "Ayar kaydedildi",
"theNextVisitToEachPage": "Her sayfaya yapılacak bir sonraki ziyaret, kaynağınızdan şu ana kadar gerçekleştirilecektir:",
"tunnels": "Tüneller",
"workers": "İşçiler"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "Bu ana bilgisayar adı için Cloudflare DNS'de tüneli işaret edecek şekilde bir CNAME oluşturulacaktır.",
"askingTheAgentToStartThe": "Temsilciden Cloudflare oturum açma akışını başlatması isteniyor…",
@@ -1785,76 +1719,6 @@
"aiSettings": "Yapay zeka ayarları",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Teslim edilebilirlik ön kontrolü (PTR, bağlantı noktası 25, RBL), postanın bu ana bilgisayardan ayrılmadan önce giden gönderimini engeller.",
"accountCreated": "Hesap oluşturuldu",
"accountDeleted": "Hesap silindi",
"accounts": "Hesaplar",
"addAMailDomainFirst": "Önce bir posta alanı ekleyin.",
"addDomain": "Alan ekle",
"address": "Adres",
"component": "Bileşen",
"couldNotAddDomain": "Etki alanı eklenemedi",
"createFailed": "Oluşturma başarısız oldu",
"delete3": "{{email}} silinsin mi?",
"deleteAccount": "Hesabı sil",
"deleteAndAllOfItsAccounts": "{{name}} ve tüm hesapları ile takma adları silinsin mi?",
"deleteDomain": "Alanı sil",
"deleteFailed": "Silinemedi",
"deployDns": "DNS'yi dağıtın",
"deployFailedNoLinkedDnsProvider": "Dağıtım başarısız oldu (bağlı DNS sağlayıcısı yok mu?)",
"dkimSelector": "DKIM seçici:",
"dnsCheckFailed": "DNS kontrolü başarısız oldu",
"dnsChecked": "DNS kontrol edildi",
"dnsRecordsDeployedToProvider": "Sağlayıcıya dağıtılan DNS kayıtları",
"domainAdded": "Alan adı eklendi",
"domainDeleted": "Alan adı silindi",
"emailServerInstallStarted": "E-posta sunucusu kurulumu başlatıldı",
"enableRelay": "Geçişi etkinleştir",
"enableSpamFiltering": "Spam filtrelemeyi etkinleştir",
"failedTo": "{{action}} {{component}} başarısız oldu",
"failedToDisableRelay": "Aktarma devre dışı bırakılamadı",
"failedToWebmail": "{{action}} web postası başarısız oldu",
"flushFailed": "Yıkama başarısız oldu",
"flushQueue": "Yıkama kuyruğu",
"host": "Ev sahibi",
"installFailed": "Yükleme başarısız oldu",
"installMailServer": "Posta sunucusunu yükle",
"installWebmail": "Web postasını yükle",
"mailServerNotInstalled": "Posta sunucusu yüklü değil",
"mailboxPassword": "Posta kutusu şifresi",
"mailboxUsername": "Posta kutusu kullanıcı adı",
"mostPanelsNeverRunAMail": "Çoğu panel hiçbir zaman bir posta sunucusu çalıştırmaz; ihtiyacınız olduğunda kurun.",
"newMailDomain": "Yeni posta alanı",
"noMailDomainsYet": "Henüz posta alan adı yok.",
"outboundRelaySmarthost": "Giden geçiş (akıllı ana bilgisayar)",
"queue": "Sıra",
"queueFlushed": "Sıra temizlendi",
"quota": "Kota",
"quotaInMb": "MB cinsinden kota",
"quotaMb": "Kota (MB)",
"recipient": "Alıcı",
"relayDisabled": "Röle devre dışı",
"relayReachable": "Röle ulaşılabilir",
"relaySaved": "Röle kaydedildi",
"relayTestFailed": "Röle testi başarısız oldu",
"requiredScore": "Gerekli puan",
"roundcubeWebmail": "Yuvarlak küp web postası",
"ruleUpdateFailed": "Kural güncellemesi başarısız oldu",
"ruleUpdateStarted": "Kural güncellemesi başladı",
"saveFailed": "Kaydetme başarısız oldu",
"searchAccounts": "Hesapları arayın",
"searchAccountsByAddress": "Hesapları adrese göre arayın...",
"searchQueueBySenderRecipientOr": "Sırayı gönderene, alıcıya veya kimliğe göre arayın…",
"sender": "Gönderen",
"spamConfigSaved": "Spam yapılandırması kaydedildi",
"spamassassin": "Spam Suikastçısı",
"updateRules": "Kuralları güncelle",
"used": "Kullanılmış",
"webmail": "Web postası: {{action}}",
"webmailInstallStarted": "Web posta yüklemesi başlatıldı",
"webmailIsNotInstalled": "Web postası yüklü değil."
},
"emailProviders": {
"addedButTestFailed": "Eklendi ancak test başarısız oldu: {{value}}",
"confirm": "Onaylamak",
@@ -2404,193 +2268,6 @@
"empty": "Boş",
"expand": "Genişletmek"
},
"git": {
"512mbMinimum1gbRecommended": "~512 MB minimum (1 GB önerilir)",
"5gbForDatabaseRepositories": "~5GB veritabanı + depolar için",
"access": "Erişim",
"accessInformation": "Bilgilere erişim",
"accessRepositories": "Kod depolarına erişim",
"active": "Aktif",
"addAWebhookToSyncRepositories": "Harici kaynaklardan veri havuzlarını senkronize etmek için bir web kancası ekleyin.",
"addWebhook": "Web Kancası Ekle",
"addYourPublicKeyInGitea": "Genel anahtarınızı Gitea'ye ekleyin",
"adminEmail": "Yönetici E-postası",
"adminEmailIsRequired": "Yönetici e-postası gerekli",
"adminPasswordLeaveEmptyToAuto": "Yönetici Şifresi (otomatik olarak oluşturmak için boş bırakın)",
"adminPasswordSaveThis": "Yönetici şifresi: {{adminpassword}} (bunu kaydedin!)",
"adminUsername": "Yönetici Kullanıcı Adı",
"application": "Başvuru",
"areYouSureYouWantTo": "Bu web kancasını silmek istediğinizden emin misiniz? Bu geri alınamaz.",
"autoGenerateSecurePassword": "Otomatik olarak güvenli şifre oluştur",
"autoSyncOnPushEvents": "Push etkinliklerinde otomatik senkronizasyon",
"automaticallyDeployWhenCodeIsPushed": "Kod şubeye gönderildiğinde otomatik olarak konuşlandır",
"bidirectional": "Çift yönlü",
"bitbucket": "Bit kovası",
"branch7": "Dal:",
"branches": "Şubeler",
"cloneRepositoriesViaSsh": "Depoları SSH aracılığıyla klonlayın",
"commit": "İşlemek",
"commit2": "İşlemek:",
"commitMessage": "Mesajı tamamla",
"commits": "Taahhütler",
"components": "Bileşenler:",
"configureAWebhookWithDeployOn": "Buradaki dağıtımları görmek için \"Push ile Dağıt\" özelliğinin etkin olduğu bir web kancası yapılandırın.",
"configureWebhooks": "Web Kancalarını Yapılandırma",
"copySshUrl": "Kopyala SSH URL",
"copyUrl": "URL'yi kopyala",
"createYourFirstRepositoryInGitea": "Gitea'de ilk deponuzu oluşturun.",
"dangerZone": "Tehlikeli Bölge",
"defaultBranch": "Varsayılan şube",
"deleteWebhook": "Web Kancasını Sil",
"deployOnPush": "Anında dağıtma",
"deployToApplication": "Uygulamaya Dağıt",
"deployment": "Dağıtım",
"deploymentHistory": "Dağıtım geçmişi",
"deploymentOutput": "Dağıtım Çıkışı",
"deploymentSettings": "Dağıtım Ayarları",
"deploymentStartedV": "Dağıtım başladı: v{{version}}",
"deploymentV": "Dağıtım v{{version}}",
"deploymentVLogs": "Dağıtım v{{version}} Günlükleri",
"deployments": "Dağıtımlar",
"duration": "Süre:",
"error": "Hata",
"error2": "Hata:",
"failedToCreateWebhook": "Web kancası oluşturulamadı",
"failedToCreateWebhook2": "Web kancası oluşturulamadı: {{message}}",
"failedToDelete": "Silinemedi: {{message}}",
"failedToDeploy": "Dağıtım başarısız oldu: {{message}}",
"failedToInstall": "Yükleme başarısız oldu: {{message}}",
"failedToLoadDeploymentDetails": "Dağıtım ayrıntıları yüklenemedi",
"failedToLoadDirectory": "Dizin yüklenemedi",
"failedToLoadLogs": "Günlükler yüklenemedi: {{message}}",
"failedToLoadMoreCommits": "Daha fazla taahhüt yüklenemedi",
"failedToLoadRepositoryDetails": "Depo ayrıntıları yüklenemedi",
"failedToStart": "Başlatılamadı: {{message}}",
"failedToStop": "Durdurulamadı: {{message}}",
"failedToSwitchBranch": "Şube değiştirilemedi",
"failedToToggleWebhook": "Web kancası değiştirilemedi: {{message}}",
"failedToTriggerDeployment": "Dağıtım tetiklenemedi",
"failedToUninstall": "Kaldırma işlemi başarısız oldu: {{message}}",
"files": "Dosyalar",
"forks": "Çatallar",
"gitServerInstalledSuccessfully": "Git sunucusu başarıyla kuruldu",
"gitServerStarted": "Git sunucusu başlatıldı",
"gitServerStopped": "Git sunucusu durduruldu",
"gitServerUninstalled": "Git sunucusu kaldırıldı",
"giteaGitServer300mbRam": "Gitea (Git sunucu) - ~300MB RAM",
"giteaPostgresqlDatabase": "Gitea + PostgreSQL veritabanı",
"giteaServerIsNotRunning": "Gitea sunucusu çalışmıyor",
"giteaVersion": "Gitea versiyonu",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "HTTP Erişim",
"httpUrl": "HTTP URL",
"iVeSavedIt": "Onu Kaydettim",
"inactive": "Etkin değil",
"installGitServer": "Git Sunucusunu yükleyin",
"installGiteaToHostAndManage": "Git depolarınızı yerel olarak barındırmak ve yönetmek için Gitea'yi yükleyin.",
"installationFailed": "Kurulum başarısız oldu",
"lastResult": "Son sonuç",
"lastSync": "Son senkronizasyon",
"leaveEmptyToUseSameName": "Aynı adı kullanmak için boş bırakın",
"loadMore": "Daha Fazla Yükle",
"loadingDeployments": "Dağıtımlar yükleniyor",
"loadingGit": "Git yükleniyor",
"loadingRepositories": "Depolar yükleniyor",
"loadingWebhooks": "Web kancaları yükleniyor",
"localRepositoryNameOptional": "Yerel Depo Adı (isteğe bağlı)",
"manual": "Manuel",
"memory": "Hafıza:",
"myGithubRepo": "Benim GitHub Depom",
"nameAndRepositoryUrlAreRequired": "Ad ve kod deposu URL gerekli",
"noDeploymentLogsAvailable": "Dağıtım günlüğü yok.",
"noDeploymentsForThisWebhook": "Bu web kancası için dağıtım yok.",
"noDeploymentsMatchThisFilter": "Bu filtreyle eşleşen dağıtım yok.",
"noDeploymentsYet": "Henüz dağıtım yok",
"noDescription": "Açıklama yok",
"noGitServerInstalled": "Git Sunucusu Yüklü Değil",
"noRepositoriesYet": "Henüz depo yok",
"noWebhooksConfigured": "Hiçbir web kancası yapılandırılmadı",
"noneSyncOnly": "Yok (yalnızca senkronizasyon)",
"openGitea": "Gitea'yi açın",
"openInGitea": "Gitea'de aç",
"optionallyDeployAnApplicationWhenCode": "İsteğe bağlı olarak kod gönderildiğinde bir uygulamayı dağıtın",
"postDeployScriptOptional": "Dağıtım sonrası Komut Dosyası (isteğe bağlı)",
"postDeploymentScriptOutput": "Dağıtım Sonrası Komut Dosyası Çıktısı",
"postgresqlDatabase200mbRam": "PostgreSQL veritabanı - ~200MB RAM",
"preDeployScriptOptional": "Komut Dosyasını Ön Dağıtım (isteğe bağlı)",
"preDeploymentScriptOutput": "Dağıtım Öncesi Komut Dosyası Çıktısı",
"private": "Özel",
"pullExternalLocal": "Çekme (Harici → Yerel)",
"pushLocalExternal": "İtme (Yerel → Harici)",
"quickActions": "Hızlı eylemler",
"recentDeployments": "Son dağıtımlar",
"repositories": "Depolar",
"repository": "Depo",
"repositoryUrl": "Depo URL",
"repositoryUrlCopied": "Depo URL kopyalandı",
"requiredForSshClonePush": "SSH klonlama/itme için gereklidir",
"resourceRequirements": "Kaynak Gereksinimleri",
"rollback": "Geri alma",
"rollbackCompleted": "Geri alma tamamlandı",
"rollbackDeployment": "Geri Alma Dağıtımı",
"rollbackFailed": "Geri alma başarısız oldu",
"rollbackFailed2": "Geri alma başarısız oldu: {{message}}",
"running": "Koşma",
"saveThisSecretItWillNot": "Bu sırrı sakla! Bir daha gösterilmeyecek.",
"secretCopied": "Gizli kopya kopyalandı",
"serverNotRunning": "Sunucu çalışmıyor",
"serverOverview": "Sunucuya genel bakış",
"serverPower": "Sunucu gücü",
"serverSettings": "Sunucu ayarları",
"serverStatus": "Sunucu durumu",
"sshAccess": "SSH Erişim",
"sshCloneUrl": "SSH Klon URL",
"sshKeyRequirement": "SSH anahtar gereksinimi",
"sshPort": "SSH bağlantı noktası",
"sshUrlCopied": "SSH URL kopyalandı",
"stars": "Yıldızlar",
"startOrStopTheGiteaContainer": "Gitea kapsayıcısını başlatın veya durdurun",
"startServer": "Sunucuyu Başlat",
"startTheGitServerToBrowse": "Depolara göz atmak için Git sunucusunu başlatın.",
"status5": "Durum:",
"stopGitServer": "Git Sunucusunu Durdurun",
"stopServer": "Sunucuyu Durdur",
"stopTheGitServer": "Git sunucusu durdurulsun mu?",
"stopsGiteaDataIsPreservedUnless": "Gitea'yi durdurur; manuel olarak kaldırılmadığı sürece veriler korunur",
"storage": "Depolamak:",
"success": "Başarı",
"switch": "Anahtar",
"syncDirection": "Senkronizasyon Yönü",
"syncs": "Senkronizasyonlar",
"testEventLogged": "Test olayı günlüğe kaydedildi",
"testFailed": "Test başarısız oldu",
"testFailed2": "Test başarısız oldu: {{message}}",
"thisWillInstall": "Bu şunları yükleyecektir:",
"triggeredBy": "Şunun tarafından tetiklendi:",
"uninstall": "Kaldır",
"uninstallGitServer": "Git Sunucusunu Kaldırma",
"uninstallTheGiteaServerThisWill": "Gitea sunucusu kaldırılsın mı? Bu, kapsayıcıyı durduracak ancak verilerinizi koruyacaktır.",
"uninstallingWillStopTheGiteaContainer": "Kaldırma işlemi Gitea kapsayıcısını durduracaktır. Siz kaldırmayı seçmediğiniz sürece verileriniz korunacaktır.",
"updated": "Güncellendi",
"updated7d": "≤7 gün güncellendi",
"urlCopied": "URL kopyalandı",
"urlPath": "URL yolu",
"useThisSecretWhenConfiguringThe": "Depo ayarlarınızda web kancasını yapılandırırken bu sırrı kullanın.",
"useYourSshKey": "SSH anahtarınızı kullanın",
"viewLogs": "Günlükleri Görüntüle",
"visibility": "Görünürlük",
"webBrowserAccess": "Web tarayıcı erişimi",
"webInterface": "Web arayüzü",
"webhook": "Web kancası",
"webhookCreatedSuccessfully": "Web kancası başarıyla oluşturuldu",
"webhookDeleted": "Web kancası silindi",
"webhookSecret": "Web Kancası Sırrı",
"webhookUrlCopied": "Web kancası URL kopyalandı",
"webhooks": "Web kancaları",
"zeroDowntime": "Sıfır kesinti süresi",
"zeroDowntimeDeployment": "Sıfır kesinti süreli dağıtım"
},
"gitProviders": {
"anyGitRemote": "Herhangi bir Git uzaktan kumanda",
"bitbucket": "Bit kovası",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "Anlık görüntüler"
},
"storagePanel": {
"createdBucket": "\"{{name}}\" paketi oluşturuldu",
"createdDatabase": "\"{{name}}\" veritabanı oluşturuldu",
"createdNamespace": "\"{{title}}\" ad alanı oluşturuldu",
"d1Databases": "D1 veritabanları",
"deleted": "Silindi",
"kvNamespaces": "KV ad alanları",
"loadingStorage": "Depolama alanı yükleniyor…",
"r2Buckets": "R2 kovaları",
"r2IsS3CompatibleSoA": "R2, S3 uyumludur, dolayısıyla buradaki bir paket ServerKit yedeklemelerini daha sonra yedekleyebilir.",
"storageUnavailable": "Depolama alanı kullanılamıyor"
},
"styleGuide": {
"2Found": "2 bulundu",
"aNewVersionIsAvailableFor": "Güncelleme için yeni bir sürüm mevcut.",
@@ -6267,28 +5932,6 @@
"arguments": "Argümanlar",
"result": "Sonuç"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Cloudflare Tüneli, Cloudflare'nin kenarı aracılığıyla yerel veya özel bir hizmeti açığa çıkarır; genel IP veya açık bağlantı noktası gerekmez.",
"cloudflareTunnels": "Cloudflare Tüneller (",
"cloudflareTunnelsUnavailable": "Cloudflare Tüneller kullanılamıyor",
"copied": "Kopyalandı",
"couldNotCopyTheInstallCommand": "Yükleme komutu kopyalanamadı",
"createACloudflareTunnel": "Cloudflare Tüneli Oluşturun",
"createOneAboveToExposeA": "Genel IP'si olmayan yerel bir hizmeti açığa çıkarmak için yukarıda bir tane oluşturun.",
"createdTunnel": "\"{{name}}\" tüneli oluşturuldu",
"deletedCloudflareTunnel": "Silinmiş Cloudflare Tüneli \"{{name}}\"",
"hostnameRemoved": "Ana makine adı kaldırıldı",
"hostnameRouted": "Ana makine adı yönlendirildi",
"hostnames": "Ana makine adları",
"installCommand": "Komutu yükle",
"loadingCloudflareTunnels": "Cloudflare Tüneller Yükleniyor…",
"noCloudflareTunnels": "Cloudflare Tünel Yok",
"noInstallCommandAvailable": "Kurulum komutu mevcut değil.",
"noPublicHostnamesRoutedYet": "Henüz yönlendirilen genel ana bilgisayar adı yok.",
"routeSetButTheDnsRecord": "Rota ayarlandı ancak DNS kaydı başarısız oldu:",
"runThisOnceOnTheMachine": "Bunu yerel hizmetinizi barındıran makinede bir kez çalıştırın. Bağlayıcı jetonu yalnızca şimdi gösteriliyor.",
"theHostnameShouldBeInsideThis": "Ana makine adı bu bölgenin etki alanı içinde olmalıdır; proxy edilmiş bir DNS kaydı otomatik olarak oluşturulur."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Yeni kapsanan bir hesabın, kayıt zorunlu kılınmadan önce yalnızca bir şifreyle oturum açmaya devam edebileceği günler.",
"gracePeriodDays": "Ek süre (gün)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "Widget'ları arayın…",
"typesDragToRepositionAfterAdding": "{{length}} türleri · ekledikten sonra yeniden konumlandırmak için sürükleyin"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Eşleşen trafiğin betiği çalıştırması için çalışanı bu etki alanına ekler.",
"code": "Kod",
"deletedWorker": "Silinen çalışan \"{{name}}\"",
"deploy": "Dağıt",
"deployAWorker": "Bir Çalışanı Dağıtın",
"deployYourFirstEdgeScriptAbove": "Yukarıdaki ilk Edge betiğinizi dağıtın.",
"deployedWorker": "Görevlendirilen çalışan \"{{name}}\"",
"loadingWorkers": "İşçiler yükleniyor…",
"noRoutesYetAddARoute": "Henüz rota yok. Bir çalışana trafik göndermek için dağıtım yaparken bir rota modeli ekleyin.",
"noWorkersDeployed": "Görevlendirilen İşçi yok",
"routePatternOptional": "Rota düzeni (isteğe bağlı)",
"routeRemoved": "Rota kaldırıldı",
"routes": "Rotalar (",
"workerDeployedButTheRouteFailed": "İşçi konuşlandırıldı ancak rota başarısız oldu:",
"workers": "İşçiler (",
"workersUnavailable": "İşçiler mevcut değil"
},
"workspaceDetail": {
"accessGranted": "Erişim verildi",
"accessRevoked": "Erişim iptal edildi",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "Tên máy chủ",
"serverProvisioningInitiated": "Đã bắt đầu cung cấp máy chủ"
},
"cloudflareWafPanel": {
"activeRules": "Quy tắc hoạt động (",
"addAQuickRuleOrA": "Thêm quy tắc nhanh hoặc quy tắc tùy chỉnh ở trên.",
"addCustomRule": "Thêm quy tắc tùy chỉnh",
"addRule": "Thêm quy tắc",
"addedQuickRule": "Đã thêm quy tắc nhanh: {{label}}",
"applyACuratedCloudflareRuleWithout": "Áp dụng quy tắc Cloudflare được tuyển chọn mà không cần viết biểu thức bằng tay.",
"block": "Khối",
"blockAdminFromOutsideTheOffice": "Chặn quản trị viên từ bên ngoài văn phòng",
"chooseAQuickRule": "Chọn một quy tắc nhanh chóng…",
"customRule": "Quy tắc tùy chỉnh",
"customRuleAdded": "Đã thêm quy tắc tùy chỉnh",
"expression": "Sự biểu lộ",
"firewallRulesUnavailable": "Quy tắc tường lửa không có sẵn",
"jsChallenge": "Thử thách JS",
"loadingFirewallRules": "Đang tải quy tắc tường lửa…",
"log": "Nhật ký",
"managedChallenge": "Thử thách được quản lý",
"noCustomFirewallRules": "Không có quy tắc tường lửa tùy chỉnh",
"quickRules": "Quy tắc nhanh",
"ruleDeleted": "Đã xóa quy tắc",
"writeYourOwnCloudflareFirewallExpression": "Viết biểu thức tường lửa Cloudflare của riêng bạn để có toàn quyền kiểm soát."
},
"cloudflareZoneSettings": {
"1Day": "1 ngày",
"1Month": "1 tháng",
"1Week": "1 tuần",
"1Year": "1 năm",
"2Years": "2 năm",
"6Months": "6 tháng",
"appliedNeedAHigherCloudflarePlan": "Đã áp dụng {{applied}}/{{total}}. {{length}} cần gói Cloudflare cao hơn.",
"appliedRecommendedHardeningSettings": "Áp dụng độ cứng được khuyến nghị (cài đặt {{applied}})",
"applyRecommendedHardening": "Áp dụng độ cứng khuyến nghị",
"backToDnsZones": "Quay lại vùng DNS",
"changingCloudflareSettingsRequiresAnAdmin": "Thay đổi cài đặt Cloudflare yêu cầu tài khoản quản trị viên.",
"clearCloudflareSCachedCopyOf": "Xóa bản sao trang web của bạn được lưu trong bộ nhớ đệm của Cloudflare để khách truy cập có được nội dung mới. Lọc mọi thứ hoặc liệt kê các URL cụ thể bên dưới.",
"clearCloudflareSEntireCacheFor": "Xóa toàn bộ bộ nhớ đệm của Cloudflare cho {{value}}?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Không có cài đặt Cloudflare",
"dnsZones": "Vùng DNS",
"enabled": "Đã bật",
"enterAtLeastOneUrlTo": "Nhập ít nhất một URL để lọc",
"failedToApplyPreset": "Không áp dụng được giá trị đặt trước",
"failedToPurgeCache": "Không thể xóa bộ nhớ đệm",
"failedToPurgeUrls": "Không thể lọc URL",
"failedToSaveSetting": "Không lưu được cài đặt",
"includeSubdomains": "Bao gồm tên miền phụ",
"itReCachesThisIsSafe": "nó lưu lại bộ nhớ đệm. Điều này là an toàn nhưng có thể tăng tải gốc trong thời gian ngắn.",
"lockedByYourCloudflarePlan": "Bị khóa bởi gói Cloudflare của bạn",
"maxAge": "Tuổi tối đa",
"notAvailableOnThisZone": "Không có sẵn trên khu vực này",
"off": "Tắt",
"preload": "Tải trước",
"purgeCache": "Xóa bộ nhớ đệm",
"purgeEntireCache": "Xóa toàn bộ bộ đệm",
"purgeEverything": "Dọn dẹp mọi thứ",
"purgeSpecificUrlsOnePerLine": "Lọc các URL cụ thể (một trên mỗi dòng, tối đa 30)",
"purgeUrls": "Thanh lọc URL",
"purgedTheEntireCache": "Đã xóa toàn bộ bộ đệm",
"purgedUrlFromCache": "Đã xóa {{n}} URL{{value}} khỏi bộ đệm",
"setsFullStrictSslAlwaysUse": "Đặt SSL đầy đủ (nghiêm ngặt), Luôn sử dụng HTTPS, HSTS (6 tháng), TLS 1.2 tầng với TLS 1.3, Brotli, HTTP/3 và bộ đệm trình duyệt 4 giờ. Các cài đặt mà gói của bạn không cho phép sẽ bị bỏ qua.",
"settingSaved": "Đã lưu cài đặt",
"theNextVisitToEachPage": "Lần truy cập tiếp theo vào mỗi trang sẽ được phục vụ từ điểm gốc của bạn cho đến khi",
"tunnels": "Đường hầm",
"workers": "Công nhân"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "CNAME cho tên máy chủ này sẽ được tạo trong Cloudflare DNS, trỏ vào đường hầm.",
"askingTheAgentToStartThe": "Đang yêu cầu đại lý bắt đầu luồng đăng nhập Cloudflare…",
@@ -1785,76 +1719,6 @@
"aiSettings": "cài đặt AI",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "Cổng gửi đi trước khả năng gửi (PTR, cổng 25, RBL) trước khi thư có thể rời khỏi máy chủ này.",
"accountCreated": "Đã tạo tài khoản",
"accountDeleted": "Đã xóa tài khoản",
"accounts": "Tài khoản",
"addAMailDomainFirst": "Trước tiên hãy thêm miền thư.",
"addDomain": "Thêm tên miền",
"address": "Địa chỉ",
"component": "Thành phần",
"couldNotAddDomain": "Không thể thêm miền",
"createFailed": "Tạo không thành công",
"delete3": "Xóa {{email}}?",
"deleteAccount": "Xóa tài khoản",
"deleteAndAllOfItsAccounts": "Xóa {{name}} cũng như tất cả tài khoản và bí danh của nó?",
"deleteDomain": "Xóa tên miền",
"deleteFailed": "Xóa không thành công",
"deployDns": "Triển khai DNS",
"deployFailedNoLinkedDnsProvider": "Triển khai không thành công (không có nhà cung cấp DNS được liên kết?)",
"dkimSelector": "Bộ chọn DKIM:",
"dnsCheckFailed": "Kiểm tra DNS không thành công",
"dnsChecked": "Đã kiểm tra DNS",
"dnsRecordsDeployedToProvider": "Bản ghi DNS được triển khai cho nhà cung cấp",
"domainAdded": "Đã thêm tên miền",
"domainDeleted": "Đã xóa tên miền",
"emailServerInstallStarted": "Đã bắt đầu cài đặt máy chủ email",
"enableRelay": "Kích hoạt chuyển tiếp",
"enableSpamFiltering": "Bật lọc thư rác",
"failedTo": "Không thành công với {{action}} {{component}}",
"failedToDisableRelay": "Không thể tắt rơle",
"failedToWebmail": "Không truy cập được webmail {{action}}",
"flushFailed": "Xả không thành công",
"flushQueue": "Xóa hàng đợi",
"host": "Chủ nhà",
"installFailed": "Cài đặt không thành công",
"installMailServer": "Cài đặt máy chủ thư",
"installWebmail": "Cài đặt webmail",
"mailServerNotInstalled": "Máy chủ thư chưa được cài đặt",
"mailboxPassword": "Mật khẩu hộp thư",
"mailboxUsername": "Tên người dùng hộp thư",
"mostPanelsNeverRunAMail": "Hầu hết các bảng không bao giờ chạy máy chủ thư, hãy cài đặt nó khi bạn cần.",
"newMailDomain": "Tên miền thư mới",
"noMailDomainsYet": "Chưa có miền thư.",
"outboundRelaySmarthost": "Rơle gửi đi (smarthost)",
"queue": "Hàng đợi",
"queueFlushed": "Đã xóa hàng đợi",
"quota": "hạn ngạch",
"quotaInMb": "Hạn ngạch tính bằng MB",
"quotaMb": "Hạn ngạch (MB)",
"recipient": "Người nhận",
"relayDisabled": "Rơle bị vô hiệu hóa",
"relayReachable": "Có thể tiếp cận rơle",
"relaySaved": "Đã lưu rơle",
"relayTestFailed": "Kiểm tra rơle không thành công",
"requiredScore": "Điểm yêu cầu",
"roundcubeWebmail": "Webmail hình tròn",
"ruleUpdateFailed": "Cập nhật quy tắc không thành công",
"ruleUpdateStarted": "Đã bắt đầu cập nhật quy tắc",
"saveFailed": "Lưu không thành công",
"searchAccounts": "Tìm kiếm tài khoản",
"searchAccountsByAddress": "Tìm kiếm tài khoản theo địa chỉ...",
"searchQueueBySenderRecipientOr": "Hàng đợi tìm kiếm theo người gửi, người nhận hoặc ID…",
"sender": "Người gửi",
"spamConfigSaved": "Đã lưu cấu hình thư rác",
"spamassassin": "SpamAsassin",
"updateRules": "Cập nhật quy tắc",
"used": "Đã sử dụng",
"webmail": "Thư trực tuyến: {{action}}",
"webmailInstallStarted": "Đã bắt đầu cài đặt webmail",
"webmailIsNotInstalled": "Webmail chưa được cài đặt."
},
"emailProviders": {
"addedButTestFailed": "Đã thêm nhưng kiểm tra không thành công: {{value}}",
"confirm": "Xác nhận",
@@ -2404,193 +2268,6 @@
"empty": "Trống",
"expand": "Mở rộng"
},
"git": {
"512mbMinimum1gbRecommended": "~512MB tối thiểu (khuyến nghị 1GB)",
"5gbForDatabaseRepositories": "~5GB cho cơ sở dữ liệu + kho lưu trữ",
"access": "Truy cập",
"accessInformation": "Truy cập thông tin",
"accessRepositories": "Truy cập kho lưu trữ",
"active": "Tích cực",
"addAWebhookToSyncRepositories": "Thêm webhook để đồng bộ hóa kho lưu trữ từ các nguồn bên ngoài.",
"addWebhook": "Thêm Webhook",
"addYourPublicKeyInGitea": "Thêm khóa công khai của bạn vào Gitea",
"adminEmail": "Email quản trị",
"adminEmailIsRequired": "Cần có email quản trị viên",
"adminPasswordLeaveEmptyToAuto": "Mật khẩu quản trị viên (để trống để tự động tạo)",
"adminPasswordSaveThis": "Mật khẩu quản trị viên: {{adminpassword}} (lưu cái này!)",
"adminUsername": "Tên người dùng quản trị viên",
"application": "Ứng dụng",
"areYouSureYouWantTo": "Bạn có chắc chắn muốn xóa webhook này không? Điều này không thể hoàn tác được.",
"autoGenerateSecurePassword": "Tự động tạo mật khẩu an toàn",
"autoSyncOnPushEvents": "Tự động đồng bộ hóa trên các sự kiện đẩy",
"automaticallyDeployWhenCodeIsPushed": "Tự động triển khai khi code được đẩy lên nhánh",
"bidirectional": "hai chiều",
"bitbucket": "Bitbucket",
"branch7": "Chi nhánh:",
"branches": "Chi nhánh",
"cloneRepositoriesViaSsh": "Sao chép kho lưu trữ qua SSH",
"commit": "Làm",
"commit2": "Làm:",
"commitMessage": "Thông báo cam kết",
"commits": "Cam kết",
"components": "thành phần:",
"configureAWebhookWithDeployOn": "Định cấu hình webhook có bật \"Triển khai khi đẩy\" để xem các hoạt động triển khai tại đây.",
"configureWebhooks": "Định cấu hình Webhooks",
"copySshUrl": "Sao chép SSH URL",
"copyUrl": "Sao chép URL",
"createYourFirstRepositoryInGitea": "Tạo kho lưu trữ đầu tiên của bạn trong Gitea.",
"dangerZone": "Vùng nguy hiểm",
"defaultBranch": "Nhánh mặc định",
"deleteWebhook": "Xóa Webhook",
"deployOnPush": "Triển khai khi đẩy",
"deployToApplication": "Triển khai vào ứng dụng",
"deployment": "Triển khai",
"deploymentHistory": "Lịch sử triển khai",
"deploymentOutput": "Đầu ra triển khai",
"deploymentSettings": "Cài đặt triển khai",
"deploymentStartedV": "Đã bắt đầu triển khai: v{{version}}",
"deploymentV": "Triển khai v{{version}}",
"deploymentVLogs": "Nhật ký triển khai v{{version}}",
"deployments": "Triển khai",
"duration": "Khoảng thời gian:",
"error": "Lỗi",
"error2": "Lỗi:",
"failedToCreateWebhook": "Không tạo được webhook",
"failedToCreateWebhook2": "Không tạo được webhook: {{message}}",
"failedToDelete": "Không xóa được: {{message}}",
"failedToDeploy": "Không triển khai được: {{message}}",
"failedToInstall": "Không cài đặt được: {{message}}",
"failedToLoadDeploymentDetails": "Không tải được chi tiết triển khai",
"failedToLoadDirectory": "Không tải được thư mục",
"failedToLoadLogs": "Không tải được nhật ký: {{message}}",
"failedToLoadMoreCommits": "Không thể tải thêm cam kết",
"failedToLoadRepositoryDetails": "Không thể tải chi tiết kho lưu trữ",
"failedToStart": "Không khởi động được: {{message}}",
"failedToStop": "Không dừng được: {{message}}",
"failedToSwitchBranch": "Chuyển chi nhánh không thành công",
"failedToToggleWebhook": "Không chuyển đổi được webhook: {{message}}",
"failedToTriggerDeployment": "Không thể kích hoạt triển khai",
"failedToUninstall": "Không thể gỡ cài đặt: {{message}}",
"files": "Tập tin",
"forks": "Nĩa",
"gitServerInstalledSuccessfully": "Máy chủ Git được cài đặt thành công",
"gitServerStarted": "Máy chủ Git đã khởi động",
"gitServerStopped": "Máy chủ Git đã dừng",
"gitServerUninstalled": "Đã gỡ cài đặt máy chủ Git",
"giteaGitServer300mbRam": "Gitea (máy chủ Git) - ~300MB RAM",
"giteaPostgresqlDatabase": "Cơ sở dữ liệu Gitea + PostgreSQL",
"giteaServerIsNotRunning": "Máy chủ Gitea không chạy",
"giteaVersion": "Phiên bản Gitea",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "Truy cập HTTP",
"httpUrl": "HTTP URL",
"iVeSavedIt": "Tôi đã lưu nó",
"inactive": "Không hoạt động",
"installGitServer": "Cài đặt máy chủ Git",
"installGiteaToHostAndManage": "Cài đặt Gitea để lưu trữ và quản lý cục bộ kho lưu trữ Git của bạn.",
"installationFailed": "Cài đặt không thành công",
"lastResult": "Kết quả cuối cùng",
"lastSync": "Đồng bộ hóa lần cuối",
"leaveEmptyToUseSameName": "Để trống để sử dụng cùng tên",
"loadMore": "Tải thêm",
"loadingDeployments": "Đang tải triển khai",
"loadingGit": "Đang tải Git",
"loadingRepositories": "Đang tải kho lưu trữ",
"loadingWebhooks": "Đang tải webhook",
"localRepositoryNameOptional": "Tên kho lưu trữ cục bộ (tùy chọn)",
"manual": "Thủ công",
"memory": "Ký ức:",
"myGithubRepo": "Kho lưu trữ GitHub của tôi",
"nameAndRepositoryUrlAreRequired": "Tên và kho lưu trữ URL là bắt buộc",
"noDeploymentLogsAvailable": "Không có nhật ký triển khai.",
"noDeploymentsForThisWebhook": "Không có triển khai nào cho webhook này.",
"noDeploymentsMatchThisFilter": "Không có triển khai nào phù hợp với bộ lọc này.",
"noDeploymentsYet": "Chưa có đợt triển khai nào",
"noDescription": "Không có mô tả",
"noGitServerInstalled": "Không có máy chủ Git nào được cài đặt",
"noRepositoriesYet": "Chưa có kho lưu trữ",
"noWebhooksConfigured": "Không có webhook nào được định cấu hình",
"noneSyncOnly": "Không có (chỉ đồng bộ hóa)",
"openGitea": "Mở Gitea",
"openInGitea": "Mở trong Gitea",
"optionallyDeployAnApplicationWhenCode": "Tùy chọn triển khai ứng dụng khi mã được đẩy",
"postDeployScriptOptional": "Tập lệnh sau triển khai (tùy chọn)",
"postDeploymentScriptOutput": "Đầu ra tập lệnh sau triển khai",
"postgresqlDatabase200mbRam": "Cơ sở dữ liệu PostgreSQL - RAM ~200 MB",
"preDeployScriptOptional": "Tập lệnh triển khai trước (tùy chọn)",
"preDeploymentScriptOutput": "Đầu ra tập lệnh trước khi triển khai",
"private": "Riêng tư",
"pullExternalLocal": "Kéo (Bên ngoài → Cục bộ)",
"pushLocalExternal": "Đẩy (Cục bộ → Bên ngoài)",
"quickActions": "Hành động nhanh",
"recentDeployments": "Triển khai gần đây",
"repositories": "Kho lưu trữ",
"repository": "Kho lưu trữ",
"repositoryUrl": "Kho lưu trữ URL",
"repositoryUrlCopied": "Kho lưu trữ URL đã sao chép",
"requiredForSshClonePush": "Cần thiết cho bản sao/đẩy SSH",
"resourceRequirements": "Yêu cầu về nguồn lực",
"rollback": "Khôi phục",
"rollbackCompleted": "Hoàn tất khôi phục",
"rollbackDeployment": "Triển khai khôi phục",
"rollbackFailed": "Khôi phục không thành công",
"rollbackFailed2": "Khôi phục không thành công: {{message}}",
"running": "Đang chạy",
"saveThisSecretItWillNot": "Hãy lưu giữ bí mật này! Nó sẽ không được hiển thị lại.",
"secretCopied": "Sao chép bí mật",
"serverNotRunning": "Máy chủ không chạy",
"serverOverview": "Tổng quan về máy chủ",
"serverPower": "Sức mạnh máy chủ",
"serverSettings": "Cài đặt máy chủ",
"serverStatus": "Trạng thái máy chủ",
"sshAccess": "Truy cập SSH",
"sshCloneUrl": "SSH Bản sao URL",
"sshKeyRequirement": "Yêu cầu khóa SSH",
"sshPort": "Cổng SSH",
"sshUrlCopied": "SSH URL đã sao chép",
"stars": "Ngôi sao",
"startOrStopTheGiteaContainer": "Bắt đầu hoặc dừng vùng chứa Gitea",
"startServer": "Khởi động máy chủ",
"startTheGitServerToBrowse": "Khởi động máy chủ Git để duyệt các kho lưu trữ.",
"status5": "Trạng thái:",
"stopGitServer": "Dừng máy chủ Git",
"stopServer": "Dừng máy chủ",
"stopTheGitServer": "Dừng máy chủ Git?",
"stopsGiteaDataIsPreservedUnless": "Dừng Gitea; dữ liệu được bảo tồn trừ khi bị xóa thủ công",
"storage": "Kho:",
"success": "Thành công",
"switch": "Công tắc",
"syncDirection": "Hướng đồng bộ",
"syncs": "Đồng bộ hóa",
"testEventLogged": "Sự kiện thử nghiệm đã được ghi lại",
"testFailed": "Kiểm tra không thành công",
"testFailed2": "Kiểm tra thất bại: {{message}}",
"thisWillInstall": "Điều này sẽ cài đặt:",
"triggeredBy": "Kích hoạt bởi:",
"uninstall": "Gỡ cài đặt",
"uninstallGitServer": "Gỡ cài đặt máy chủ Git",
"uninstallTheGiteaServerThisWill": "Gỡ cài đặt máy chủ Gitea? Điều này sẽ dừng vùng chứa nhưng vẫn bảo toàn dữ liệu của bạn.",
"uninstallingWillStopTheGiteaContainer": "Việc gỡ cài đặt sẽ dừng vùng chứa Gitea. Dữ liệu của bạn sẽ được bảo toàn trừ khi bạn chọn xóa nó.",
"updated": "Đã cập nhật",
"updated7d": "Đã cập nhật 7 ngày",
"urlCopied": "URL đã sao chép",
"urlPath": "Đường dẫn URL",
"useThisSecretWhenConfiguringThe": "Hãy sử dụng bí mật này khi định cấu hình webhook trong cài đặt kho lưu trữ của bạn.",
"useYourSshKey": "Sử dụng khóa SSH của bạn",
"viewLogs": "Xem nhật ký",
"visibility": "khả năng hiển thị",
"webBrowserAccess": "Truy cập trình duyệt web",
"webInterface": "Giao diện web",
"webhook": "Webhook",
"webhookCreatedSuccessfully": "Đã tạo webhook thành công",
"webhookDeleted": "Đã xóa webhook",
"webhookSecret": "Bí mật webhook",
"webhookUrlCopied": "Webhook URL đã sao chép",
"webhooks": "Webhook",
"zeroDowntime": "Không có thời gian chết",
"zeroDowntimeDeployment": "Triển khai không có thời gian ngừng hoạt động"
},
"gitProviders": {
"anyGitRemote": "Bất kỳ điều khiển từ xa Git nào",
"bitbucket": "Bitbucket",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "Ảnh chụp nhanh"
},
"storagePanel": {
"createdBucket": "Đã tạo nhóm \"{{name}}\"",
"createdDatabase": "Đã tạo cơ sở dữ liệu \"{{name}}\"",
"createdNamespace": "Đã tạo không gian tên \"{{title}}\"",
"d1Databases": "Cơ sở dữ liệu D1",
"deleted": "Đã xóa",
"kvNamespaces": "Không gian tên KV",
"loadingStorage": "Đang tải bộ nhớ…",
"r2Buckets": "Xô R2",
"r2IsS3CompatibleSoA": "R2 tương thích với S3, do đó, một nhóm ở đây có thể sao lưu các bản sao lưu ServerKit sau này.",
"storageUnavailable": "Không có bộ nhớ"
},
"styleGuide": {
"2Found": "2 được tìm thấy",
"aNewVersionIsAvailableFor": "Một phiên bản mới có sẵn để cập nhật.",
@@ -6267,28 +5932,6 @@
"arguments": "Đối số",
"result": "Kết quả"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Đường hầm Cloudflare hiển thị dịch vụ cục bộ hoặc riêng tư thông qua biên của Cloudflare — không cần IP công cộng hoặc cổng mở.",
"cloudflareTunnels": "Đường hầm Cloudflare (",
"cloudflareTunnelsUnavailable": "Đường hầm Cloudflare không có sẵn",
"copied": "Đã sao chép",
"couldNotCopyTheInstallCommand": "Không thể sao chép lệnh cài đặt",
"createACloudflareTunnel": "Tạo đường hầm Cloudflare",
"createOneAboveToExposeA": "Tạo một dịch vụ ở trên để hiển thị một dịch vụ cục bộ không có IP công cộng.",
"createdTunnel": "Đã tạo đường hầm \"{{name}}\"",
"deletedCloudflareTunnel": "Đã xóa Đường hầm Cloudflare \"{{name}}\"",
"hostnameRemoved": "Đã xóa tên máy chủ",
"hostnameRouted": "Tên máy chủ được định tuyến",
"hostnames": "Tên máy chủ",
"installCommand": "Lệnh cài đặt",
"loadingCloudflareTunnels": "Đang tải đường hầm Cloudflare…",
"noCloudflareTunnels": "Không có đường hầm Cloudflare",
"noInstallCommandAvailable": "Không có lệnh cài đặt.",
"noPublicHostnamesRoutedYet": "Chưa có tên máy chủ công cộng nào được định tuyến.",
"routeSetButTheDnsRecord": "Đã đặt lộ trình nhưng bản ghi DNS không thành công:",
"runThisOnceOnTheMachine": "Chạy cái này một lần trên máy lưu trữ dịch vụ cục bộ của bạn. Mã thông báo kết nối bây giờ chỉ được hiển thị.",
"theHostnameShouldBeInsideThis": "Tên máy chủ phải nằm trong miền của vùng này; bản ghi DNS được ủy quyền sẽ được tạo tự động."
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "Số ngày mà tài khoản mới được bảo hiểm có thể tiếp tục đăng nhập chỉ bằng mật khẩu trước khi việc đăng ký được thực thi.",
"gracePeriodDays": "Thời gian ân hạn (ngày)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "Tìm kiếm tiện ích…",
"typesDragToRepositionAfterAdding": "Các loại {{length}} · kéo để định vị lại sau khi thêm"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "Gắn nhân viên vào miền này để lưu lượng truy cập phù hợp sẽ chạy tập lệnh.",
"code": "Mã số",
"deletedWorker": "Đã xóa nhân viên \"{{name}}\"",
"deploy": "Triển khai",
"deployAWorker": "Triển khai một công nhân",
"deployYourFirstEdgeScriptAbove": "Triển khai tập lệnh cạnh đầu tiên của bạn ở trên.",
"deployedWorker": "Nhân viên đã triển khai \"{{name}}\"",
"loadingWorkers": "Đang tải công nhân…",
"noRoutesYetAddARoute": "Chưa có tuyến đường nào. Thêm mẫu tuyến đường khi triển khai để gửi lưu lượng truy cập cho nhân viên.",
"noWorkersDeployed": "Không có công nhân nào được triển khai",
"routePatternOptional": "Mẫu lộ trình (tùy chọn)",
"routeRemoved": "Đã xóa tuyến đường",
"routes": "Tuyến đường (",
"workerDeployedButTheRouteFailed": "Công nhân đã triển khai nhưng tuyến đường không thành công:",
"workers": "Công nhân (",
"workersUnavailable": "Công nhân không có sẵn"
},
"workspaceDetail": {
"accessGranted": "Đã cấp quyền truy cập",
"accessRevoked": "Quyền truy cập bị thu hồi",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "服务器名称",
"serverProvisioningInitiated": "已启动服务器配置"
},
"cloudflareWafPanel": {
"activeRules": "活动规则(",
"addAQuickRuleOrA": "在上面添加快速规则或自定义规则。",
"addCustomRule": "添加自定义规则",
"addRule": "添加规则",
"addedQuickRule": "已添加快速规则:{{label}}",
"applyACuratedCloudflareRuleWithout": "应用策划的 Cloudflare 规则,无需手动编写表达式。",
"block": "阻止",
"blockAdminFromOutsideTheOffice": "阻止管理员在办公室外访问",
"chooseAQuickRule": "选择一个快速规则...",
"customRule": "自定义规则",
"customRuleAdded": "添加了自定义规则",
"expression": "表达",
"firewallRulesUnavailable": "防火墙规则不可用",
"jsChallenge": "JS 挑战",
"loadingFirewallRules": "正在加载防火墙规则...",
"log": "日志",
"managedChallenge": "管理挑战",
"noCustomFirewallRules": "没有自定义防火墙规则",
"quickRules": "快速规则",
"ruleDeleted": "规则已删除",
"writeYourOwnCloudflareFirewallExpression": "编写您自己的 Cloudflare 防火墙表达式以实现完全控制。"
},
"cloudflareZoneSettings": {
"1Day": "1天",
"1Month": "1个月",
"1Week": "1周",
"1Year": "1年",
"2Years": "2年",
"6Months": "6个月",
"appliedNeedAHigherCloudflarePlan": "已应用的{{applied}}/{{total}}。 {{length}} 需要更高的 Cloudflare 计划。",
"appliedRecommendedHardeningSettings": "应用建议的强化({{applied}} 设置)",
"applyRecommendedHardening": "应用推荐的硬化处理",
"backToDnsZones": "返回 DNS 区域",
"changingCloudflareSettingsRequiresAnAdmin": "更改 Cloudflare 设置需要管理员帐户。",
"clearCloudflareSCachedCopyOf": "清除 Cloudflare 的网站缓存副本,以便访问者获得最新内容。清除所有内容,或在下面列出特定 URL。",
"clearCloudflareSEntireCacheFor": "清除 Cloudflare 的 {{value}} 的整个缓存?",
"cloudflare": "云耀",
"cloudflareSettingsUnavailable": "Cloudflare 设置不可用",
"dnsZones": "DNS 区域",
"enabled": "已启用",
"enterAtLeastOneUrlTo": "输入至少一个要清除的 URL",
"failedToApplyPreset": "无法应用预设",
"failedToPurgeCache": "清除缓存失败",
"failedToPurgeUrls": "清除 URL 失败",
"failedToSaveSetting": "保存设置失败",
"includeSubdomains": "包括子域",
"itReCachesThisIsSafe": "它重新缓存。这是安全的,但会短暂增加原始负载。",
"lockedByYourCloudflarePlan": "被您的 Cloudflare 计划锁定",
"maxAge": "最大年龄",
"notAvailableOnThisZone": "在此区域不可用",
"off": "关闭",
"preload": "预载",
"purgeCache": "清除缓存",
"purgeEntireCache": "清除整个缓存",
"purgeEverything": "清除一切",
"purgeSpecificUrlsOnePerLine": "清除特定 URL(每行一个,最多 30 个)",
"purgeUrls": "清除 URL",
"purgedTheEntireCache": "清除整个缓存",
"purgedUrlFromCache": "从缓存中清除了 {{n}} URL{{value}}",
"setsFullStrictSslAlwaysUse": "设置完整(严格)SSL、始终使用 HTTPS、HSTS(6 个月)、带有 TLS 1.3 的 TLS 1.2 层、Brotli、HTTP/3 和 4 小时浏览器缓存。您的计划不允许的设置将被跳过。",
"settingSaved": "设置已保存",
"theNextVisitToEachPage": "下一次对每个页面的访问将从您的起始位置开始,直到",
"tunnels": "隧道",
"workers": "工作进程"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "将在 Cloudflare DNS 中创建该主机名的 CNAME,指向隧道。",
"askingTheAgentToStartThe": "要求代理启动 Cloudflare 登录流程...",
@@ -1785,76 +1719,6 @@
"aiSettings": "人工智能设置",
"serverkitAi": "ServerKit人工智能"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "在邮件离开此主机之前,可传递性预检(PTR、端口 25、RBL)会控制出站发送。",
"accountCreated": "帐户已创建",
"accountDeleted": "帐户已删除",
"accounts": "账户",
"addAMailDomainFirst": "首先添加邮件域。",
"addDomain": "添加域名",
"address": "地址",
"component": "成分",
"couldNotAddDomain": "无法添加域",
"createFailed": "创建失败",
"delete3": "删除{{email}}?",
"deleteAccount": "删除帐户",
"deleteAndAllOfItsAccounts": "删除 {{name}} 及其所有帐户和别名?",
"deleteDomain": "删除域名",
"deleteFailed": "删除失败",
"deployDns": "部署DNS",
"deployFailedNoLinkedDnsProvider": "部署失败(没有链接的 DNS 提供商?)",
"dkimSelector": "DKIM 选择器:",
"dnsCheckFailed": "DNS 检查失败",
"dnsChecked": "DNS 已检查",
"dnsRecordsDeployedToProvider": "部署到提供商的 DNS 记录",
"domainAdded": "已添加域名",
"domainDeleted": "域名已删除",
"emailServerInstallStarted": "电子邮件服务器安装开始",
"enableRelay": "启用中继",
"enableSpamFiltering": "启用垃圾邮件过滤",
"failedTo": "无法执行 {{action}} {{component}}",
"failedToDisableRelay": "禁用中继失败",
"failedToWebmail": "无法发送 {{action}} 网络邮件",
"flushFailed": "刷新失败",
"flushQueue": "刷新队列",
"host": "主机",
"installFailed": "安装失败",
"installMailServer": "安装邮件服务器",
"installWebmail": "安装网络邮件",
"mailServerNotInstalled": "邮件服务器未安装",
"mailboxPassword": "邮箱密码",
"mailboxUsername": "邮箱用户名",
"mostPanelsNeverRunAMail": "大多数面板从不运行邮件服务器,请在需要时安装它。",
"newMailDomain": "新邮件域",
"noMailDomainsYet": "还没有邮件域。",
"outboundRelaySmarthost": "出站中继(智能主机)",
"queue": "队列",
"queueFlushed": "队列已刷新",
"quota": "配额",
"quotaInMb": "配额(MB)",
"quotaMb": "配额(MB)",
"recipient": "接受者",
"relayDisabled": "继电器禁用",
"relayReachable": "中继可达",
"relaySaved": "已保存中继",
"relayTestFailed": "继电器测试失败",
"requiredScore": "所需分数",
"roundcubeWebmail": "Roundcube 网络邮件",
"ruleUpdateFailed": "规则更新失败",
"ruleUpdateStarted": "规则更新开始",
"saveFailed": "保存失败",
"searchAccounts": "搜索账户",
"searchAccountsByAddress": "按地址搜索帐户...",
"searchQueueBySenderRecipientOr": "按发件人、收件人或 ID 搜索队列...",
"sender": "发件人",
"spamConfigSaved": "垃圾邮件配置已保存",
"spamassassin": "垃圾邮件刺客",
"updateRules": "更新规则",
"used": "已使用",
"webmail": "网络邮件:{{action}}",
"webmailInstallStarted": "网络邮件安装开始",
"webmailIsNotInstalled": "未安装网络邮件。"
},
"emailProviders": {
"addedButTestFailed": "已添加,但测试失败:{{value}}",
"confirm": "确认",
@@ -2404,193 +2268,6 @@
"empty": "空的",
"expand": "扩张"
},
"git": {
"512mbMinimum1gbRecommended": "至少 512MB(建议 1GB)",
"5gbForDatabaseRepositories": "~5GB 用于数据库 + 存储库",
"access": "使用权",
"accessInformation": "获取信息",
"accessRepositories": "访问存储库",
"active": "活跃",
"addAWebhookToSyncRepositories": "添加 Webhook 以同步外部源的存储库。",
"addWebhook": "添加 Webhook",
"addYourPublicKeyInGitea": "在 Gitea 中添加您的公钥",
"adminEmail": "管理员电子邮件",
"adminEmailIsRequired": "需要管理员电子邮件",
"adminPasswordLeaveEmptyToAuto": "管理员密码(留空以自动生成)",
"adminPasswordSaveThis": "管理员密码:{{adminpassword}}(保存!)",
"adminUsername": "管理员用户名",
"application": "应用程序",
"areYouSureYouWantTo": "您确定要删除此 Webhook 吗?此操作无法撤消。",
"autoGenerateSecurePassword": "自动生成安全密码",
"autoSyncOnPushEvents": "自动同步推送事件",
"automaticallyDeployWhenCodeIsPushed": "当代码推送到分支时自动部署",
"bidirectional": "双向",
"bitbucket": "位桶",
"branch7": "分支:",
"branches": "分支机构",
"cloneRepositoriesViaSsh": "通过 SSH 克隆存储库",
"commit": "犯罪",
"commit2": "犯罪:",
"commitMessage": "提交消息",
"commits": "提交",
"components": "成分:",
"configureAWebhookWithDeployOn": "配置启用“推送时部署”的 Webhook,以在此处查看部署。",
"configureWebhooks": "配置Webhook",
"copySshUrl": "复制 SSH URL",
"copyUrl": "复制 URL",
"createYourFirstRepositoryInGitea": "在 Gitea 中创建您的第一个存储库。",
"dangerZone": "危险区",
"defaultBranch": "默认分支",
"deleteWebhook": "删除Webhook",
"deployOnPush": "推送部署",
"deployToApplication": "部署到应用程序",
"deployment": "部署",
"deploymentHistory": "部署历史",
"deploymentOutput": "部署输出",
"deploymentSettings": "部署设置",
"deploymentStartedV": "部署开始:v{{version}}",
"deploymentV": "部署 v{{version}}",
"deploymentVLogs": "部署 v{{version}} 日志",
"deployments": "部署",
"duration": "期间:",
"error": "错误",
"error2": "错误:",
"failedToCreateWebhook": "创建Webhook失败",
"failedToCreateWebhook2": "无法创建Webhook:{{message}}",
"failedToDelete": "删除失败:{{message}}",
"failedToDeploy": "部署失败:{{message}}",
"failedToInstall": "安装失败:{{message}}",
"failedToLoadDeploymentDetails": "无法加载部署详细信息",
"failedToLoadDirectory": "加载目录失败",
"failedToLoadLogs": "无法加载日志:{{message}}",
"failedToLoadMoreCommits": "无法加载更多提交",
"failedToLoadRepositoryDetails": "无法加载存储库详细信息",
"failedToStart": "启动失败:{{message}}",
"failedToStop": "停止失败:{{message}}",
"failedToSwitchBranch": "切换分支失败",
"failedToToggleWebhook": "无法切换Webhook:{{message}}",
"failedToTriggerDeployment": "触发部署失败",
"failedToUninstall": "卸载失败:{{message}}",
"files": "文件",
"forks": "叉子",
"gitServerInstalledSuccessfully": "Git服务器安装成功",
"gitServerStarted": "Git 服务器已启动",
"gitServerStopped": "Git 服务器已停止",
"gitServerUninstalled": "Git 服务器已卸载",
"giteaGitServer300mbRam": "Gitea(Git 服务器)- ~300MB RAM",
"giteaPostgresqlDatabase": "Gitea + PostgreSQL 数据库",
"giteaServerIsNotRunning": "Gitea 服务器未运行",
"giteaVersion": "吉泰亚版本",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "HTTP 访问",
"httpUrl": "HTTP 网址",
"iVeSavedIt": "我已经保存了",
"inactive": "不活跃",
"installGitServer": "安装 Git 服务器",
"installGiteaToHostAndManage": "安装 Gitea 以在本地托管和管理您的 Git 存储库。",
"installationFailed": "安装失败",
"lastResult": "最后结果",
"lastSync": "上次同步",
"leaveEmptyToUseSameName": "留空以使用相同的名称",
"loadMore": "加载更多",
"loadingDeployments": "加载部署",
"loadingGit": "加载 Git",
"loadingRepositories": "加载存储库",
"loadingWebhooks": "加载Webhook",
"localRepositoryNameOptional": "本地存储库名称(可选)",
"manual": "手动的",
"memory": "记忆:",
"myGithubRepo": "我的 GitHub 存储库",
"nameAndRepositoryUrlAreRequired": "名称和存储库 URL 是必需的",
"noDeploymentLogsAvailable": "没有可用的部署日志。",
"noDeploymentsForThisWebhook": "此 Webhook 没有部署。",
"noDeploymentsMatchThisFilter": "没有部署与此过滤器匹配。",
"noDeploymentsYet": "尚未部署",
"noDescription": "无描述",
"noGitServerInstalled": "未安装 Git 服务器",
"noRepositoriesYet": "还没有存储库",
"noWebhooksConfigured": "没有配置 webhook",
"noneSyncOnly": "无(仅同步)",
"openGitea": "打开 Gitea",
"openInGitea": "在吉泰亚开放",
"optionallyDeployAnApplicationWhenCode": "推送代码时可选择部署应用程序",
"postDeployScriptOptional": "部署后脚本(可选)",
"postDeploymentScriptOutput": "部署后脚本输出",
"postgresqlDatabase200mbRam": "PostgreSQL 数据库 - ~200MB RAM",
"preDeployScriptOptional": "预部署脚本(可选)",
"preDeploymentScriptOutput": "预部署脚本输出",
"private": "私人的",
"pullExternalLocal": "拉动(外部→本地)",
"pushLocalExternal": "推送(本地 → 外部)",
"quickActions": "快速行动",
"recentDeployments": "最近的部署",
"repositories": "仓库",
"repository": "仓库",
"repositoryUrl": "仓库 URL",
"repositoryUrlCopied": "存储库 URL 已复制",
"requiredForSshClonePush": "SSH 克隆/推送所需",
"resourceRequirements": "资源要求",
"rollback": "回滚",
"rollbackCompleted": "回滚完成",
"rollbackDeployment": "回滚部署",
"rollbackFailed": "回滚失败",
"rollbackFailed2": "回滚失败:{{message}}",
"running": "运行中",
"saveThisSecretItWillNot": "保存这个秘密!它将不会再次显示。",
"secretCopied": "秘密已复制",
"serverNotRunning": "服务器未运行",
"serverOverview": "服务器概览",
"serverPower": "服务器电源",
"serverSettings": "服务器设置",
"serverStatus": "服务器状态",
"sshAccess": "SSH 访问",
"sshCloneUrl": "SSH 克隆 URL",
"sshKeyRequirement": "SSH 密钥要求",
"sshPort": "SSH端口",
"sshUrlCopied": "已复制 SSH URL",
"stars": "星星",
"startOrStopTheGiteaContainer": "启动或停止 Gitea 容器",
"startServer": "启动服务器",
"startTheGitServerToBrowse": "启动 Git 服务器来浏览存储库。",
"status5": "状态:",
"stopGitServer": "停止 Git 服务器",
"stopServer": "停止服务器",
"stopTheGitServer": "停止 Git 服务器?",
"stopsGiteaDataIsPreservedUnless": "停止 Gitea;除非手动删除,否则数据将被保留",
"storage": "贮存:",
"success": "成功",
"switch": "转变",
"syncDirection": "同步方向",
"syncs": "同步",
"testEventLogged": "记录测试事件",
"testFailed": "测试失败",
"testFailed2": "测试失败:{{message}}",
"thisWillInstall": "这将安装:",
"triggeredBy": "触发方式:",
"uninstall": "卸载",
"uninstallGitServer": "卸载 Git 服务器",
"uninstallTheGiteaServerThisWill": "卸载Gitea服务器?这将停止容器但保留您的数据。",
"uninstallingWillStopTheGiteaContainer": "卸载将停止 Gitea 容器。除非您选择删除,否则您的数据将被保留。",
"updated": "已更新",
"updated7d": "更新时间≤7天",
"urlCopied": "已复制网址",
"urlPath": "网址路径",
"useThisSecretWhenConfiguringThe": "在存储库设置中配置 Webhook 时使用此密钥。",
"useYourSshKey": "使用您的 SSH 密钥",
"viewLogs": "查看日志",
"visibility": "能见度",
"webBrowserAccess": "网络浏览器访问",
"webInterface": "网页界面",
"webhook": "Webhook",
"webhookCreatedSuccessfully": "Webhook 创建成功",
"webhookDeleted": "Webhook已删除",
"webhookSecret": "Webhook 秘密",
"webhookUrlCopied": "已复制 Webhook URL",
"webhooks": "Webhook",
"zeroDowntime": "零停机时间",
"zeroDowntimeDeployment": "零停机部署"
},
"gitProviders": {
"anyGitRemote": "任何 Git 远程",
"bitbucket": "位桶",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "快照"
},
"storagePanel": {
"createdBucket": "创建存储桶“{{name}}”",
"createdDatabase": "创建数据库“{{name}}”",
"createdNamespace": "创建命名空间“{{title}}”",
"d1Databases": "D1数据库",
"deleted": "已删除",
"kvNamespaces": "KV命名空间",
"loadingStorage": "正在加载存储...",
"r2Buckets": "R2铲斗",
"r2IsS3CompatibleSoA": "R2 与 S3 兼容,因此此处的存储桶可以稍后支持 ServerKit 备份。",
"storageUnavailable": "存储不可用"
},
"styleGuide": {
"2Found": "2 找到",
"aNewVersionIsAvailableFor": "有新版本可供更新。",
@@ -6267,28 +5932,6 @@
"arguments": "论点",
"result": "结果"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Cloudflare 隧道通过 Cloudflare 的边缘公开本地或私有服务 - 不需要公共 IP 或开放端口。",
"cloudflareTunnels": "Cloudflare 隧道 (",
"cloudflareTunnelsUnavailable": "Cloudflare 隧道不可用",
"copied": "已复制",
"couldNotCopyTheInstallCommand": "无法复制安装命令",
"createACloudflareTunnel": "创建 Cloudflare 隧道",
"createOneAboveToExposeA": "在上面创建一个以公开没有公共 IP 的本地服务。",
"createdTunnel": "创建隧道“{{name}}”",
"deletedCloudflareTunnel": "已删除 Cloudflare 隧道“{{name}}”",
"hostnameRemoved": "主机名已删除",
"hostnameRouted": "主机名路由",
"hostnames": "主机名",
"installCommand": "安装命令",
"loadingCloudflareTunnels": "正在加载 Cloudflare 隧道...",
"noCloudflareTunnels": "没有 Cloudflare 隧道",
"noInstallCommandAvailable": "没有可用的安装命令。",
"noPublicHostnamesRoutedYet": "尚未路由公共主机名。",
"routeSetButTheDnsRecord": "路由已设置,但 DNS 记录失败:",
"runThisOnceOnTheMachine": "在托管本地服务的计算机上运行一次。连接器令牌现在才显示。",
"theHostnameShouldBeInsideThis": "主机名应位于该区域的域内;自动创建代理 DNS 记录。"
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "在强制注册之前,新覆盖的帐户可以仅使用密码登录的天数。",
"gracePeriodDays": "宽限期(天)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "搜索小部件...",
"typesDragToRepositionAfterAdding": "{{length}}类型·添加后拖动重新定位"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "将工作线程附加到该域,以便匹配的流量运行脚本。",
"code": "代码",
"deletedWorker": "已删除工作人员“{{name}}”",
"deploy": "部署",
"deployAWorker": "部署一个Worker",
"deployYourFirstEdgeScriptAbove": "部署上面的第一个边缘脚本。",
"deployedWorker": "已部署的工作人员“{{name}}”",
"loadingWorkers": "正在加载工人...",
"noRoutesYetAddARoute": "还没有路线。部署时添加路由模式以将流量发送给工作人员。",
"noWorkersDeployed": "没有部署工人",
"routePatternOptional": "路线模式(可选)",
"routeRemoved": "路线已删除",
"routes": "路线(",
"workerDeployedButTheRouteFailed": "Worker 已部署,但路由失败:",
"workers": "工人(",
"workersUnavailable": "没有工人"
},
"workspaceDetail": {
"accessGranted": "已授予访问权限",
"accessRevoked": "访问权限被撤销",
-375
View File
@@ -586,72 +586,6 @@
"serverName": "伺服器名稱",
"serverProvisioningInitiated": "已啟動伺服器配置"
},
"cloudflareWafPanel": {
"activeRules": "活動規則(",
"addAQuickRuleOrA": "在上面新增快速規則或自訂規則。",
"addCustomRule": "新增自訂規則",
"addRule": "新增規則",
"addedQuickRule": "新增快速規則:{{label}}",
"applyACuratedCloudflareRuleWithout": "應用程式規劃的 Cloudflare 規則,無需手動編寫表達式。",
"block": "堵塞",
"blockAdminFromOutsideTheOffice": "阻止管理員在辦公室外訪問",
"chooseAQuickRule": "選擇一個快速規則...",
"customRule": "自訂規則",
"customRuleAdded": "新增了自訂規則",
"expression": "表達",
"firewallRulesUnavailable": "防火牆規則不可用",
"jsChallenge": "JS 挑戰",
"loadingFirewallRules": "正在載入防火牆規則...",
"log": "紀錄",
"managedChallenge": "管理挑戰",
"noCustomFirewallRules": "沒有自訂防火牆規則",
"quickRules": "快速規則",
"ruleDeleted": "規則已刪除",
"writeYourOwnCloudflareFirewallExpression": "編寫您自己的 Cloudflare 防火牆表達式以實現完全控制。"
},
"cloudflareZoneSettings": {
"1Day": "1天",
"1Month": "1個月",
"1Week": "1週",
"1Year": "1年",
"2Years": "2年",
"6Months": "6個月",
"appliedNeedAHigherCloudflarePlan": "適用{{applied}}/{{total}}。 {{length}}需要更高的Cloudflare方案。",
"appliedRecommendedHardeningSettings": "應用建議的增強功能({{applied}} 設定)",
"applyRecommendedHardening": "應用推薦的硬化處理",
"backToDnsZones": "返回 DNS 區域",
"changingCloudflareSettingsRequiresAnAdmin": "更改 Cloudflare 設定需要管理員帳戶。",
"clearCloudflareSCachedCopyOf": "清除 Cloudflare 網站的快取副本,以便訪客獲得新鮮內容。清除所有內容,或在下面列出特定的 URL。",
"clearCloudflareSEntireCacheFor": "清除 Cloudflare 的 {{value}} 的整個快取嗎?",
"cloudflare": "Cloudflare",
"cloudflareSettingsUnavailable": "Cloudflare 設定不可用",
"dnsZones": "DNS 區域",
"enabled": "啟用",
"enterAtLeastOneUrlTo": "輸入至少一台URL進行清除",
"failedToApplyPreset": "無法套用預設",
"failedToPurgeCache": "清除快取失敗",
"failedToPurgeUrls": "清除 URL 失敗",
"failedToSaveSetting": "儲存設定失敗",
"includeSubdomains": "包括子域",
"itReCachesThisIsSafe": "它重新緩存。這是安全的,但會短暫增加原始負載。",
"lockedByYourCloudflarePlan": "被您的 Cloudflare 計劃鎖定",
"maxAge": "最大年齡",
"notAvailableOnThisZone": "在此區域不可用",
"off": "離開",
"preload": "預載",
"purgeCache": "清除快取",
"purgeEntireCache": "清除整個快取",
"purgeEverything": "清除一切",
"purgeSpecificUrlsOnePerLine": "清除特定的 URL(每行一個,最多 30 個)",
"purgeUrls": "淨化 URLs",
"purgedTheEntireCache": "清除整個快取",
"purgedUrlFromCache": "從快取中清除 {{n}} URL{{value}}",
"setsFullStrictSslAlwaysUse": "設定完整(嚴格)SSL、始終使用 HTTPS、HSTS(6 個月)、最低 TLS 1.2(支援 TLS 1.3)、Brotli、HTTP/3,以及 4 小時的瀏覽器快取。您的方案不允許的設定將會略過。",
"settingSaved": "設定已儲存",
"theNextVisitToEachPage": "下一次對每個頁面的訪問將從您的起始位置開始,直到",
"tunnels": "隧道",
"workers": "勞工"
},
"cloudflaredTab": {
"aCnameForThisHostnameWill": "將在 Cloudflare DNS 中建立該主機名稱的 CNAME,指向隧道。",
"askingTheAgentToStartThe": "請代理程式啟動 Cloudflare 登入流程...",
@@ -1785,76 +1719,6 @@
"aiSettings": "人工智慧設定",
"serverkitAi": "ServerKit AI"
},
"email": {
"aDeliverabilityPreflightPtrPort25": "在郵件離開此主機之前,可傳遞性預檢(PTR、連接埠 25、RBL)會控制出站傳送。",
"accountCreated": "帳戶已建立",
"accountDeleted": "帳戶已刪除",
"accounts": "帳戶",
"addAMailDomainFirst": "首先新增郵件網域。",
"addDomain": "新增域名",
"address": "地址",
"component": "成分",
"couldNotAddDomain": "無法新增網域",
"createFailed": "創建失敗",
"delete3": "刪除{{email}}?",
"deleteAccount": "刪除帳戶",
"deleteAndAllOfItsAccounts": "刪除 {{name}} 及其所有帳戶和別名?",
"deleteDomain": "刪除域名",
"deleteFailed": "刪除失敗",
"deployDns": "部署DNS",
"deployFailedNoLinkedDnsProvider": "部署失敗(沒有連結的 DNS 提供者?)",
"dkimSelector": "DKIM 選擇器:",
"dnsCheckFailed": "DNS 檢查失敗",
"dnsChecked": "DNS 已檢查",
"dnsRecordsDeployedToProvider": "DNS 記錄部署到提供者",
"domainAdded": "已新增域名",
"domainDeleted": "網域名稱已刪除",
"emailServerInstallStarted": "電子郵件伺服器安裝開始",
"enableRelay": "啟用中繼",
"enableSpamFiltering": "啟用垃圾郵件過濾",
"failedTo": "失敗 {{action}} {{component}}",
"failedToDisableRelay": "停用中繼失敗",
"failedToWebmail": "無法傳送 {{action}} 網路郵件",
"flushFailed": "刷新失敗",
"flushQueue": "刷新佇列",
"host": "主持人",
"installFailed": "安裝失敗",
"installMailServer": "安裝郵件伺服器",
"installWebmail": "安裝網路郵件",
"mailServerNotInstalled": "郵件伺服器未安裝",
"mailboxPassword": "信箱密碼",
"mailboxUsername": "信箱使用者名稱",
"mostPanelsNeverRunAMail": "大多數面板從不運行郵件伺服器,請在需要時安裝它。",
"newMailDomain": "新郵件網域",
"noMailDomainsYet": "還沒有郵件網域。",
"outboundRelaySmarthost": "出站中繼(智慧主機)",
"queue": "佇列",
"queueFlushed": "隊列已刷新",
"quota": "配額",
"quotaInMb": "配額(MB)",
"quotaMb": "配額(MB)",
"recipient": "接受者",
"relayDisabled": "繼電器停用",
"relayReachable": "中繼可達",
"relaySaved": "已保存中繼",
"relayTestFailed": "繼電器測試失敗",
"requiredScore": "所需分數",
"roundcubeWebmail": "Roundcube 網路郵件",
"ruleUpdateFailed": "規則更新失敗",
"ruleUpdateStarted": "規則更新開始",
"saveFailed": "保存失敗",
"searchAccounts": "搜尋帳戶",
"searchAccountsByAddress": "按地址搜尋帳戶...",
"searchQueueBySenderRecipientOr": "按寄件者、收件者或 ID 搜尋佇列...",
"sender": "寄件人",
"spamConfigSaved": "垃圾郵件配置已儲存",
"spamassassin": "垃圾郵件刺客",
"updateRules": "更新規則",
"used": "用過的",
"webmail": "網路信箱:{{action}}",
"webmailInstallStarted": "網路郵件安裝開始",
"webmailIsNotInstalled": "未安裝網路郵件。"
},
"emailProviders": {
"addedButTestFailed": "加,但測試失敗:{{value}}",
"confirm": "確認",
@@ -2404,193 +2268,6 @@
"empty": "空的",
"expand": "擴張"
},
"git": {
"512mbMinimum1gbRecommended": "至少 512MB(建議 1GB)",
"5gbForDatabaseRepositories": "~5GB 用於資料庫 + 儲存庫",
"access": "使用權",
"accessInformation": "獲取資訊",
"accessRepositories": "存取儲存庫",
"active": "積極的",
"addAWebhookToSyncRepositories": "新增 Webhook 以同步外部來源的儲存庫。",
"addWebhook": "添加網路鉤子",
"addYourPublicKeyInGitea": "在 Gitea 中加入您的公鑰",
"adminEmail": "管理員信箱",
"adminEmailIsRequired": "需要管理員電子郵件",
"adminPasswordLeaveEmptyToAuto": "管理員密碼(留空以自動產生)",
"adminPasswordSaveThis": "管理員密碼:{{adminpassword}}(儲存!)",
"adminUsername": "管理員使用者名稱",
"application": "應用",
"areYouSureYouWantTo": "您確定要刪除此 Webhook 嗎?此操作無法撤銷。",
"autoGenerateSecurePassword": "自動產生安全密碼",
"autoSyncOnPushEvents": "自動同步推播事件",
"automaticallyDeployWhenCodeIsPushed": "當程式碼推送到分支時自動部署",
"bidirectional": "雙向",
"bitbucket": "位元桶",
"branch7": "分支:",
"branches": "分公司",
"cloneRepositoriesViaSsh": "透過 SSH 克隆儲存庫",
"commit": "犯罪",
"commit2": "犯罪:",
"commitMessage": "提交訊息",
"commits": "提交",
"components": "成分:",
"configureAWebhookWithDeployOn": "配置啟用「推送時部署」的 Webhook,以在此處查看部署。",
"configureWebhooks": "配置網路鉤子",
"copySshUrl": "複製 SSH URL",
"copyUrl": "複製 URL",
"createYourFirstRepositoryInGitea": "在 Gitea 中建立您的第一個儲存庫。",
"dangerZone": "危險區",
"defaultBranch": "預設分支",
"deleteWebhook": "刪除網路鉤子",
"deployOnPush": "推送部署",
"deployToApplication": "部署到應用程式",
"deployment": "部署",
"deploymentHistory": "部署歷史",
"deploymentOutput": "部署輸出",
"deploymentSettings": "部署設定",
"deploymentStartedV": "部署開始:v{{version}}",
"deploymentV": "部署 v{{version}}",
"deploymentVLogs": "部署 v{{version}} 日誌",
"deployments": "部署",
"duration": "期間:",
"error": "錯誤",
"error2": "錯誤:",
"failedToCreateWebhook": "建立網路鉤子失敗",
"failedToCreateWebhook2": "建立 Webhook 失敗:{{message}}",
"failedToDelete": "刪除失敗:{{message}}",
"failedToDeploy": "部署失敗:{{message}}",
"failedToInstall": "安裝失敗:{{message}}",
"failedToLoadDeploymentDetails": "無法加載部署詳細信息",
"failedToLoadDirectory": "載入目錄失敗",
"failedToLoadLogs": "無法載入日誌:{{message}}",
"failedToLoadMoreCommits": "無法加載更多提交",
"failedToLoadRepositoryDetails": "無法加載存儲庫詳細信息",
"failedToStart": "啟動失敗:{{message}}",
"failedToStop": "停止失敗:{{message}}",
"failedToSwitchBranch": "切換分支失敗",
"failedToToggleWebhook": "無法切換 Webhook:{{message}}",
"failedToTriggerDeployment": "觸發部署失敗",
"failedToUninstall": "卸載失敗:{{message}}",
"files": "文件",
"forks": "叉子",
"gitServerInstalledSuccessfully": "Git伺服器安裝成功",
"gitServerStarted": "Git 伺服器啟動",
"gitServerStopped": "Git 伺服器已停止",
"gitServerUninstalled": "Git 伺服器已卸載",
"giteaGitServer300mbRam": "Gitea(Git 伺服器)- ~300MB RAM",
"giteaPostgresqlDatabase": "Gitea + PostgreSQL 資料庫",
"giteaServerIsNotRunning": "Gitea 伺服器未運行",
"giteaVersion": "Gitea版本",
"github": "GitHub",
"gitlab": "GitLab",
"httpAccess": "HTTP 接入",
"httpUrl": "HTTP URL",
"iVeSavedIt": "我已經保存了",
"inactive": "不活躍",
"installGitServer": "安裝Git伺服器",
"installGiteaToHostAndManage": "安裝 Gitea 以在本機上託管和管理您的 Git 儲存庫。",
"installationFailed": "安裝失敗",
"lastResult": "最後結果",
"lastSync": "上次同步",
"leaveEmptyToUseSameName": "留空以使用相同的名稱",
"loadMore": "加載更多",
"loadingDeployments": "載入部署",
"loadingGit": "正在載入Git",
"loadingRepositories": "載入儲存庫",
"loadingWebhooks": "加載網路鉤子",
"localRepositoryNameOptional": "本機儲存庫名稱(可選)",
"manual": "手動的",
"memory": "記憶:",
"myGithubRepo": "我的 GitHub 倉庫",
"nameAndRepositoryUrlAreRequired": "名稱和儲存庫 URL 為必填項",
"noDeploymentLogsAvailable": "沒有可用的部署日誌。",
"noDeploymentsForThisWebhook": "此 Webhook 沒有部署。",
"noDeploymentsMatchThisFilter": "沒有部署與此過濾器相符。",
"noDeploymentsYet": "尚未部署",
"noDescription": "無描述",
"noGitServerInstalled": "未安裝 Git 伺服器",
"noRepositoriesYet": "還沒有儲存庫",
"noWebhooksConfigured": "沒有配置 webhook",
"noneSyncOnly": "無(僅同步)",
"openGitea": "開啟Gitea",
"openInGitea": "在Gitea中打開",
"optionallyDeployAnApplicationWhenCode": "推送程式碼時可選擇部署應用程式",
"postDeployScriptOptional": "部署後腳本(可選)",
"postDeploymentScriptOutput": "部署後腳本輸出",
"postgresqlDatabase200mbRam": "PostgreSQL 資料庫 - ~200MB RAM",
"preDeployScriptOptional": "預先部署腳本(選用)",
"preDeploymentScriptOutput": "預先部署腳本輸出",
"private": "私人的",
"pullExternalLocal": "拉動(外部→本地)",
"pushLocalExternal": "推送(本地 → 外部)",
"quickActions": "快速行動",
"recentDeployments": "最近的部署",
"repositories": "儲存庫",
"repository": "儲存庫",
"repositoryUrl": "儲存庫 URL",
"repositoryUrlCopied": "已複製儲存庫 URL",
"requiredForSshClonePush": "SSH 克隆/推播所需",
"resourceRequirements": "資源需求",
"rollback": "復原",
"rollbackCompleted": "復原完成",
"rollbackDeployment": "復原部署",
"rollbackFailed": "復原失敗",
"rollbackFailed2": "回滾失敗:{{message}}",
"running": "跑步",
"saveThisSecretItWillNot": "保存這個秘密!它將不會再次顯示。",
"secretCopied": "秘密已複製",
"serverNotRunning": "伺服器未運行",
"serverOverview": "伺服器概覽",
"serverPower": "伺服器電源",
"serverSettings": "伺服器設定",
"serverStatus": "伺服器狀態",
"sshAccess": "SSH 接入",
"sshCloneUrl": "SSH 克隆 URL",
"sshKeyRequirement": "SSH 關鍵要求",
"sshPort": "SSH端口",
"sshUrlCopied": "SSH URL 已複製",
"stars": "星星",
"startOrStopTheGiteaContainer": "啟動或停止Gitea容器",
"startServer": "啟動伺服器",
"startTheGitServerToBrowse": "啟動Git伺服器來瀏覽儲存庫。",
"status5": "地位:",
"stopGitServer": "停止Git伺服器",
"stopServer": "停止伺服器",
"stopTheGitServer": "停止Git伺服器?",
"stopsGiteaDataIsPreservedUnless": "停止Gitea;除非手動刪除,否則資料將保留",
"storage": "貯存:",
"success": "成功",
"switch": "轉變",
"syncDirection": "同步方向",
"syncs": "同步",
"testEventLogged": "記錄測試事件",
"testFailed": "測試失敗",
"testFailed2": "測試失敗:{{message}}",
"thisWillInstall": "這將安裝:",
"triggeredBy": "觸發方式:",
"uninstall": "解除安裝",
"uninstallGitServer": "卸載Git伺服器",
"uninstallTheGiteaServerThisWill": "卸載Gitea伺服器?這將停止容器但保留您的資料。",
"uninstallingWillStopTheGiteaContainer": "卸載將停止 Gitea 容器。除非您選擇刪除,否則您的資料將被保留。",
"updated": "已更新",
"updated7d": "更新時間≤7天",
"urlCopied": "URL 已複製",
"urlPath": "URL路徑",
"useThisSecretWhenConfiguringThe": "在儲存庫設定中設定 Webhook 時使用此金鑰。",
"useYourSshKey": "使用您的 SSH 金鑰",
"viewLogs": "查看日誌",
"visibility": "能見度",
"webBrowserAccess": "網路瀏覽器訪問",
"webInterface": "網頁介面",
"webhook": "網路鉤子",
"webhookCreatedSuccessfully": "Webhook 建立成功",
"webhookDeleted": "網路鉤子已刪除",
"webhookSecret": "Webhook 秘密",
"webhookUrlCopied": "Webhook URL 已複製",
"webhooks": "網路鉤子",
"zeroDowntime": "零停機時間",
"zeroDowntimeDeployment": "零停機部署"
},
"gitProviders": {
"anyGitRemote": "任意Git遙控器",
"bitbucket": "位元桶",
@@ -5581,18 +5258,6 @@
"storageDestinations": {
"snapshots": "快照"
},
"storagePanel": {
"createdBucket": "建立儲存桶“{{name}}”",
"createdDatabase": "建立資料庫“{{name}}”",
"createdNamespace": "建立命名空間“{{title}}”",
"d1Databases": "D1資料庫",
"deleted": "已刪除",
"kvNamespaces": "KV命名空間",
"loadingStorage": "正在加載存儲...",
"r2Buckets": "R2鏟鬥",
"r2IsS3CompatibleSoA": "R2 與 S3 相容,因此此處的儲存桶可以稍後支援 ServerKit 備份。",
"storageUnavailable": "儲存不可用"
},
"styleGuide": {
"2Found": "2 找到",
"aNewVersionIsAvailableFor": "有新版本可供更新。",
@@ -6267,28 +5932,6 @@
"arguments": "論點",
"result": "結果"
},
"tunnelsPanel": {
"aCloudflareTunnelExposesALocal": "Cloudflare 隧道透過 Cloudflare 的邊緣公開本地或專用服務 - 不需要公共 IP 或開放連接埠。",
"cloudflareTunnels": "Cloudflare 隧道(",
"cloudflareTunnelsUnavailable": "Cloudflare 隧道不可用",
"copied": "已複製",
"couldNotCopyTheInstallCommand": "無法複製安裝指令",
"createACloudflareTunnel": "創建Cloudflare隧道",
"createOneAboveToExposeA": "在上面建立一個以公開沒有公共 IP 的本地服務。",
"createdTunnel": "建立隧道“{{name}}”",
"deletedCloudflareTunnel": "刪除了 Cloudflare 隧道“{{name}}”",
"hostnameRemoved": "主機名稱已刪除",
"hostnameRouted": "主機名稱路由",
"hostnames": "主機名稱",
"installCommand": "安裝命令",
"loadingCloudflareTunnels": "正在載入 Cloudflare 隧道...",
"noCloudflareTunnels": "無 Cloudflare 隧道",
"noInstallCommandAvailable": "沒有可用的安裝命令。",
"noPublicHostnamesRoutedYet": "尚未路由公共主機名。",
"routeSetButTheDnsRecord": "路由已設置,但DNS記錄失敗:",
"runThisOnceOnTheMachine": "在託管本機服務的電腦上執行一次。連接器令牌現在才顯示。",
"theHostnameShouldBeInsideThis": "主機名稱應位於該區域的域內;自動建立代理 DNS 記錄。"
},
"twoFactorPolicyCard": {
"daysANewlyCoveredAccountMay": "在強制註冊之前,新覆蓋的帳戶可以僅使用密碼登入的天數。",
"gracePeriodDays": "寬限期(天)",
@@ -6602,24 +6245,6 @@
"searchWidgets": "搜尋小部件...",
"typesDragToRepositionAfterAdding": "{{length}}類型·新增後拖曳重新定位"
},
"workersPanel": {
"attachesTheWorkerToThisDomain": "將工作執行緒附加到該網域,以便匹配的流量運行腳本。",
"code": "程式碼",
"deletedWorker": "已刪除工人“{{name}}”",
"deploy": "部署",
"deployAWorker": "部署一個Worker",
"deployYourFirstEdgeScriptAbove": "部署上面的第一個邊緣腳本。",
"deployedWorker": "部署的工作人員“{{name}}”",
"loadingWorkers": "正在加載工人...",
"noRoutesYetAddARoute": "還沒有路線。部署時新增路由模式以將流量傳送給工作人員。",
"noWorkersDeployed": "沒有部署工人",
"routePatternOptional": "路線模式(可選)",
"routeRemoved": "路線已刪除",
"routes": "路線(",
"workerDeployedButTheRouteFailed": "Worker 已部署,但路由失敗:",
"workers": "工人(",
"workersUnavailable": "沒有工人"
},
"workspaceDetail": {
"accessGranted": "已授予存取權限",
"accessRevoked": "存取權限被撤銷",
+8
View File
@@ -845,6 +845,11 @@ const InstallModal = ({ template, onClose, onSuccess, renderIcon }) => {
if (isRepo) {
try {
const repo = template.repo || {};
// Forward the build inputs the template already declares. The
// endpoint has always accepted these; the drawer just never sent
// them, so a repo template's build_command/start_command were
// decorative and anything without a `start` script in its
// package.json built an image that could not boot.
const result = await api.createAppFromRepository({
name: appName,
template_id: template.id,
@@ -853,6 +858,9 @@ const InstallModal = ({ template, onClose, onSuccess, renderIcon }) => {
app_type: repo.app_type || 'auto',
build_method: repo.build_method || 'auto',
port: repo.port ? Number(repo.port) : null,
dockerfile_path: repo.dockerfile_path || null,
custom_build_cmd: repo.build_command || null,
custom_start_cmd: repo.start_command || null,
auto_deploy: true,
ingress_plane: 'nginx',
});
+1 -10
View File
@@ -1,12 +1,3 @@
{
"plugins": [
{
"name": "serverkit-cloudflare-ops",
"slug": "serverkit-cloudflare-ops",
"display_name": "Cloudflare Zone Ops",
"version": "1.0.0",
"frontend_entry": "",
"path": "serverkit-cloudflare-ops"
}
]
"plugins": []
}
@@ -1,335 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { ShieldAlert } from 'lucide-react';
import api from '../services/cloudflare';
import {
Button, Input, Textarea, Switch, Badge,
Select, SelectTrigger, SelectContent, SelectItem, SelectValue,
EmptyState, useToast,
} from 'serverkit-sdk';
import { useTranslation } from 'react-i18next';
const ACTION_OPTIONS = [
{ value: 'block', labelKey: 'app.cloudflareWafPanel.block', label: 'Block' },
{ value: 'managed_challenge', labelKey: 'app.cloudflareWafPanel.managedChallenge', label: 'Managed Challenge' },
{ value: 'js_challenge', labelKey: 'app.cloudflareWafPanel.jsChallenge', label: 'JS Challenge' },
{ value: 'log', labelKey: 'app.cloudflareWafPanel.log', label: 'Log' },
];
const EXPRESSION_PLACEHOLDER = '(http.request.uri.path contains "/admin")';
export default function CloudflareWafPanel({ zoneId, isAdmin }) {
const { t } = useTranslation();
const toast = useToast();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [rulesetId, setRulesetId] = useState(null);
const [rules, setRules] = useState([]);
const [presets, setPresets] = useState([]);
// Preset form state
const [selectedPreset, setSelectedPreset] = useState('');
const [presetParams, setPresetParams] = useState({});
// Custom rule form state
const [customDescription, setCustomDescription] = useState('');
const [customExpression, setCustomExpression] = useState('');
const [customAction, setCustomAction] = useState('block');
// Tracks any in-flight write so buttons can disable
const [applying, setApplying] = useState(false);
const loadRules = useCallback(async () => {
try {
const data = await api.getCloudflareWafRules(zoneId);
setRulesetId(data.ruleset_id ?? null);
setRules(data.rules || []);
setPresets(data.presets || []);
setError(null);
} catch (err) {
setError(err.message);
}
}, [zoneId]);
useEffect(() => {
let active = true;
setLoading(true);
(async () => {
try {
const data = await api.getCloudflareWafRules(zoneId);
if (!active) return;
setRulesetId(data.ruleset_id ?? null);
setRules(data.rules || []);
setPresets(data.presets || []);
setError(null);
} catch (err) {
if (active) setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [zoneId]);
const activePreset = presets.find((p) => p.key === selectedPreset) || null;
const presetParamDefs = activePreset?.params || [];
const handleSelectPreset = (key) => {
setSelectedPreset(key);
setPresetParams({});
};
const handleParamChange = (key, value) => {
setPresetParams((prev) => ({ ...prev, [key]: value }));
};
const handleApplyPreset = async () => {
if (!activePreset) return;
setApplying(true);
try {
await api.applyCloudflareWafPreset(zoneId, activePreset.key, presetParams);
await loadRules();
setPresetParams({});
toast.success(t('app.cloudflareWafPanel.addedQuickRule', 'Added quick rule: {{label}}', { label: activePreset.label }));
} catch (err) {
toast.error(err.message);
} finally {
setApplying(false);
}
};
const handleAddCustomRule = async () => {
setApplying(true);
try {
await api.addCloudflareWafRule(zoneId, {
description: customDescription,
expression: customExpression,
action: customAction,
enabled: true,
});
await loadRules();
setCustomDescription('');
setCustomExpression('');
setCustomAction('block');
toast.success(t('app.cloudflareWafPanel.customRuleAdded', 'Custom rule added'));
} catch (err) {
toast.error(err.message);
} finally {
setApplying(false);
}
};
const handleToggleRule = async (rule) => {
setApplying(true);
try {
await api.updateCloudflareWafRule(zoneId, rulesetId, rule.id, {
enabled: !rule.enabled,
});
await loadRules();
} catch (err) {
toast.error(err.message);
} finally {
setApplying(false);
}
};
const handleDeleteRule = async (rule) => {
setApplying(true);
try {
await api.deleteCloudflareWafRule(zoneId, rulesetId, rule.id);
await loadRules();
toast.success(t('app.cloudflareWafPanel.ruleDeleted', 'Rule deleted'));
} catch (err) {
toast.error(err.message);
} finally {
setApplying(false);
}
};
if (loading) {
return <div className="cf-waf__loading">{t('app.cloudflareWafPanel.loadingFirewallRules', 'Loading firewall rules…')}</div>;
}
if (error) {
return (
<EmptyState
icon={ShieldAlert}
title={t('app.cloudflareWafPanel.firewallRulesUnavailable', 'Firewall rules unavailable')}
description={error}
/>
);
}
const writeDisabled = !isAdmin || applying;
return (
<div className="cf-waf">
{/* Presets / quick rules */}
<section className="cf-waf__section">
<h3 className="cf-waf__heading">{t('app.cloudflareWafPanel.quickRules', 'Quick rules')}</h3>
<p className="cf-waf__hint">
{t('app.cloudflareWafPanel.applyACuratedCloudflareRuleWithout', 'Apply a curated Cloudflare rule without writing an expression by hand.')}
</p>
<div className="cf-waf__field">
<Select
value={selectedPreset}
onValueChange={handleSelectPreset}
disabled={writeDisabled}
>
<SelectTrigger className="cf-waf__select">
<SelectValue placeholder={t('app.cloudflareWafPanel.chooseAQuickRule', 'Choose a quick rule…')} />
</SelectTrigger>
<SelectContent>
{presets.map((preset) => (
<SelectItem key={preset.key} value={preset.key}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{activePreset && (
<p className="cf-waf__preset-desc">{activePreset.description}</p>
)}
{presetParamDefs.length > 0 && (
<div className="cf-waf__params">
{presetParamDefs.map((param) => (
<div className="cf-waf__field" key={param.key}>
<label className="cf-waf__label">{param.label}</label>
<Input
value={presetParams[param.key] || ''}
placeholder={param.placeholder}
onChange={(e) => handleParamChange(param.key, e.target.value)}
disabled={writeDisabled}
/>
</div>
))}
</div>
)}
<div className="cf-waf__actions">
<Button
size="sm"
onClick={handleApplyPreset}
disabled={writeDisabled || !activePreset}
>
{t('app.cloudflareWafPanel.addRule', 'Add rule')}
</Button>
</div>
</section>
{/* Custom rule */}
<section className="cf-waf__section">
<h3 className="cf-waf__heading">{t('app.cloudflareWafPanel.customRule', 'Custom rule')}</h3>
<p className="cf-waf__hint">
{t('app.cloudflareWafPanel.writeYourOwnCloudflareFirewallExpression', 'Write your own Cloudflare firewall expression for full control.')}
</p>
<div className="cf-waf__field">
<label className="cf-waf__label">{t('common.labels.description', 'Description')}</label>
<Input
value={customDescription}
placeholder={t('app.cloudflareWafPanel.blockAdminFromOutsideTheOffice', 'Block admin from outside the office')}
onChange={(e) => setCustomDescription(e.target.value)}
disabled={writeDisabled}
/>
</div>
<div className="cf-waf__field">
<label className="cf-waf__label">{t('app.cloudflareWafPanel.expression', 'Expression')}</label>
<Textarea
rows={3}
value={customExpression}
placeholder={EXPRESSION_PLACEHOLDER}
onChange={(e) => setCustomExpression(e.target.value)}
disabled={writeDisabled}
/>
</div>
<div className="cf-waf__field">
<label className="cf-waf__label">{t('common.labels.action', 'Action')}</label>
<Select
value={customAction}
onValueChange={setCustomAction}
disabled={writeDisabled}
>
<SelectTrigger className="cf-waf__select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="cf-waf__actions">
<Button
size="sm"
onClick={handleAddCustomRule}
disabled={writeDisabled || !customExpression.trim()}
>
{t('app.cloudflareWafPanel.addCustomRule', 'Add custom rule')}
</Button>
</div>
</section>
{/* Active rules list */}
<section className="cf-waf__section">
<h3 className="cf-waf__heading">{t('app.cloudflareWafPanel.activeRules', 'Active rules (')}{rules.length})</h3>
{rules.length === 0 ? (
<EmptyState
icon={ShieldAlert}
title={t('app.cloudflareWafPanel.noCustomFirewallRules', 'No custom firewall rules')}
description={t('app.cloudflareWafPanel.addAQuickRuleOrA', 'Add a quick rule or a custom rule above.')}
/>
) : (
<ul className="cf-waf__list">
{rules.map((rule) => (
<li className="cf-waf__rule" key={rule.id}>
<div className="cf-waf__rule-main">
<div className="cf-waf__rule-head">
<Badge
variant={rule.action === 'block' ? 'destructive' : 'secondary'}
>
{rule.action}
</Badge>
<span className="cf-waf__rule-desc">
{rule.description || 'Untitled rule'}
</span>
</div>
<code className="cf-waf__rule-expr">{rule.expression}</code>
</div>
<div className="cf-waf__rule-actions">
<Switch
checked={rule.enabled}
onCheckedChange={() => handleToggleRule(rule)}
disabled={writeDisabled}
/>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteRule(rule)}
disabled={writeDisabled}
>
{t('common.actions.delete', 'Delete')}
</Button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
);
}
@@ -1,266 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { HardDrive } from 'lucide-react';
import api from '../services/cloudflare';
import { Button, Input, EmptyState, useToast } from 'serverkit-sdk';
import { useTranslation } from 'react-i18next';
function StorageSection({
title,
items,
error,
placeholder,
hint,
renderItem,
onCreate,
onDelete,
isAdmin,
busy,
}) {
const { t } = useTranslation();
const [value, setValue] = useState('');
const writeDisabled = !isAdmin || busy;
const handleCreate = async () => {
const trimmed = value.trim();
if (!trimmed) return;
// Only clear the input when the create succeeds; a throw leaves the text intact.
await onCreate(trimmed);
setValue('');
};
return (
<section className="cf-storage__section">
<h3 className="cf-storage__heading">
{title} ({error ? 0 : items.length})
</h3>
{hint && <p className="cf-storage__hint">{hint}</p>}
{error ? (
<p className="cf-storage__error">{error}</p>
) : (
<>
<div className="cf-storage__create">
<Input
value={value}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
disabled={writeDisabled}
/>
<Button
size="sm"
onClick={handleCreate}
disabled={writeDisabled || !value.trim()}
>
{t('common.actions.add', 'Add')}
</Button>
</div>
{items.length === 0 ? (
<p className="cf-storage__hint">{`No ${title.toLowerCase()} yet.`}</p>
) : (
<ul className="cf-storage__list">
{items.map((item) => {
const { key, label } = renderItem(item);
return (
<li className="cf-storage__item" key={key}>
<code className="cf-storage__name">{label}</code>
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(item)}
disabled={writeDisabled}
>
{t('common.actions.delete', 'Delete')}
</Button>
</li>
);
})}
</ul>
)}
</>
)}
</section>
);
}
export default function StoragePanel({ zoneId, isAdmin }) {
const { t } = useTranslation();
const toast = useToast();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
// Tracks any in-flight write so all write controls can disable
const [working, setWorking] = useState(false);
const loadData = useCallback(async () => {
try {
const res = await api.getCloudflareStorage(zoneId);
setData(res);
setError(null);
} catch (err) {
setError(err.message);
}
}, [zoneId]);
useEffect(() => {
let active = true;
setLoading(true);
(async () => {
try {
const res = await api.getCloudflareStorage(zoneId);
if (!active) return;
setData(res);
setError(null);
} catch (err) {
if (active) setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [zoneId]);
const handleCreateR2 = async (name) => {
setWorking(true);
try {
await api.createCloudflareR2Bucket(zoneId, name);
toast.success(t('app.storagePanel.createdBucket', 'Created bucket "{{name}}"', { name: name }));
await loadData();
} catch (err) {
toast.error(err.message);
throw err;
} finally {
setWorking(false);
}
};
const handleDeleteR2 = async (bucket) => {
setWorking(true);
try {
await api.deleteCloudflareR2Bucket(zoneId, bucket.name);
toast.success(t('app.storagePanel.deleted', 'Deleted'));
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleCreateKv = async (title) => {
setWorking(true);
try {
await api.createCloudflareKvNamespace(zoneId, title);
toast.success(t('app.storagePanel.createdNamespace', 'Created namespace "{{title}}"', { title: title }));
await loadData();
} catch (err) {
toast.error(err.message);
throw err;
} finally {
setWorking(false);
}
};
const handleDeleteKv = async (namespace) => {
setWorking(true);
try {
await api.deleteCloudflareKvNamespace(zoneId, namespace.id);
toast.success(t('app.storagePanel.deleted', 'Deleted'));
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleCreateD1 = async (name) => {
setWorking(true);
try {
await api.createCloudflareD1Database(zoneId, name);
toast.success(t('app.storagePanel.createdDatabase', 'Created database "{{name}}"', { name: name }));
await loadData();
} catch (err) {
toast.error(err.message);
throw err;
} finally {
setWorking(false);
}
};
const handleDeleteD1 = async (database) => {
setWorking(true);
try {
await api.deleteCloudflareD1Database(zoneId, database.uuid);
toast.success(t('app.storagePanel.deleted', 'Deleted'));
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
if (loading) {
return <div className="cf-storage__loading">{t('app.storagePanel.loadingStorage', 'Loading storage…')}</div>;
}
if (error) {
return (
<EmptyState
icon={HardDrive}
title={t('app.storagePanel.storageUnavailable', 'Storage unavailable')}
description={error}
/>
);
}
const errors = data.errors || {};
return (
<div className="cf-storage">
<StorageSection
title={t('app.storagePanel.r2Buckets', 'R2 buckets')}
items={data.r2 || []}
error={errors.r2}
placeholder="my-bucket"
hint={t('app.storagePanel.r2IsS3CompatibleSoA', 'R2 is S3-compatible, so a bucket here can back ServerKit backups later.')}
renderItem={(b) => ({ key: b.name, label: b.name })}
onCreate={handleCreateR2}
onDelete={handleDeleteR2}
isAdmin={isAdmin}
busy={working}
/>
<StorageSection
title={t('app.storagePanel.kvNamespaces', 'KV namespaces')}
items={data.kv || []}
error={errors.kv}
placeholder="my-namespace"
renderItem={(n) => ({ key: n.id, label: n.title })}
onCreate={handleCreateKv}
onDelete={handleDeleteKv}
isAdmin={isAdmin}
busy={working}
/>
<StorageSection
title={t('app.storagePanel.d1Databases', 'D1 databases')}
items={data.d1 || []}
error={errors.d1}
placeholder="my-database"
renderItem={(d) => ({ key: d.uuid, label: d.name })}
onCreate={handleCreateD1}
onDelete={handleDeleteD1}
isAdmin={isAdmin}
busy={working}
/>
</div>
);
}
@@ -1,362 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Network } from 'lucide-react';
import api from '../services/cloudflare';
import { Button, Input, Badge, EmptyState, useToast, copyToClipboard } from 'serverkit-sdk';
import { useTranslation } from 'react-i18next';
function InstallBox({ command }) {
const { t } = useTranslation();
const toast = useToast();
if (!command) {
return <p className="cf-tunnels__hint">{t('app.tunnelsPanel.noInstallCommandAvailable', 'No install command available.')}</p>;
}
const handleCopy = async () => {
if (await copyToClipboard(command)) toast.success(t('app.tunnelsPanel.copied', 'Copied'));
else toast.error(t('app.tunnelsPanel.couldNotCopyTheInstallCommand', 'Could not copy the install command'));
};
return (
<div className="cf-tunnels__install">
<code className="cf-tunnels__cmd">{command}</code>
<Button size="sm" variant="outline" onClick={handleCopy}>
{t('common.actions.copy', 'Copy')}
</Button>
</div>
);
}
function TunnelRow({ zoneId, tunnel, isAdmin, onChanged }) {
const { t } = useTranslation();
const toast = useToast();
const [install, setInstall] = useState(null);
const [expanded, setExpanded] = useState(false);
const [hostnames, setHostnames] = useState([]);
const [hostname, setHostname] = useState('');
const [service, setService] = useState('');
const [working, setWorking] = useState(false);
const loadHostnames = useCallback(async () => {
try {
const res = await api.getCloudflareTunnelHostnames(zoneId, tunnel.id);
setHostnames(res.hostnames || []);
} catch (err) {
toast.error(err.message);
}
}, [zoneId, tunnel.id, toast]);
const handleToggleInstall = async () => {
if (install) {
setInstall(null);
return;
}
setWorking(true);
try {
const res = await api.getCloudflareTunnelInstall(zoneId, tunnel.id);
setInstall(res.install || null);
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleToggleHostnames = async () => {
const next = !expanded;
setExpanded(next);
if (next && hostnames.length === 0) {
await loadHostnames();
}
};
const handleDelete = async () => {
setWorking(true);
try {
await api.deleteCloudflareTunnel(zoneId, tunnel.id);
toast.success(t('app.tunnelsPanel.deletedCloudflareTunnel', 'Deleted Cloudflare Tunnel "{{name}}"', { name: tunnel.name }));
onChanged();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleRemoveHostname = async (h) => {
setWorking(true);
try {
await api.removeCloudflareTunnelHostname(zoneId, tunnel.id, h.hostname);
toast.success(t('app.tunnelsPanel.hostnameRemoved', 'Hostname removed'));
await loadHostnames();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleAddHostname = async () => {
setWorking(true);
try {
const res = await api.addCloudflareTunnelHostname(
zoneId,
tunnel.id,
hostname,
service,
);
toast.success(t('app.tunnelsPanel.hostnameRouted', 'Hostname routed'));
if (res.dns && !res.dns.created) {
toast.error(t('app.tunnelsPanel.routeSetButTheDnsRecord', 'Route set, but the DNS record failed: ') + res.dns.error);
}
setHostname('');
setService('');
await loadHostnames();
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const writeDisabled = !isAdmin || working;
return (
<li className="cf-tunnels__item">
<div className="cf-tunnels__item-head">
<span className="cf-tunnels__name">{tunnel.name}</span>
<code className="cf-tunnels__meta">{tunnel.id.slice(0, 12)}…</code>
{tunnel.status && (
<span className="cf-tunnels__meta">{tunnel.status}</span>
)}
{tunnel.connections != null && (
<span className="cf-tunnels__meta">{tunnel.connections}</span>
)}
{tunnel.managed && <Badge variant="secondary">{t('common.labels.serverKit', 'ServerKit')}</Badge>}
</div>
<div className="cf-tunnels__item-actions">
<Button
size="sm"
variant="outline"
onClick={handleToggleInstall}
disabled={!isAdmin || working}
>
{t('app.tunnelsPanel.installCommand', 'Install command')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleToggleHostnames}
disabled={working}
>
{t('app.tunnelsPanel.hostnames', 'Hostnames')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={writeDisabled}
>
{t('common.actions.delete', 'Delete')}
</Button>
</div>
{install && <InstallBox command={install} />}
{expanded && (
<div className="cf-tunnels__hostnames">
{hostnames.length === 0 ? (
<p className="cf-tunnels__hint">{t('app.tunnelsPanel.noPublicHostnamesRoutedYet', 'No public hostnames routed yet.')}</p>
) : (
hostnames.map((h) => (
<div className="cf-tunnels__host" key={h.hostname}>
<span>
<code>{h.hostname}</code> &rarr; <code>{h.service}</code>
</span>
<Button
variant="destructive"
size="sm"
onClick={() => handleRemoveHostname(h)}
disabled={writeDisabled}
>
{t('common.actions.remove', 'Remove')}
</Button>
</div>
))
)}
<div className="cf-tunnels__host-add">
<Input
value={hostname}
placeholder="app.example.com"
onChange={(e) => setHostname(e.target.value)}
disabled={writeDisabled}
/>
<Input
value={service}
placeholder="http://localhost:8080"
onChange={(e) => setService(e.target.value)}
disabled={writeDisabled}
/>
<Button
size="sm"
onClick={handleAddHostname}
disabled={writeDisabled || !hostname.trim() || !service.trim()}
>
{t('common.actions.add', 'Add')}
</Button>
</div>
<p className="cf-tunnels__hint">
{t('app.tunnelsPanel.theHostnameShouldBeInsideThis', 'The hostname should be inside this zone\'s domain; a proxied DNS record is created automatically.')}
</p>
</div>
)}
</li>
);
}
export default function TunnelsPanel({ zoneId, isAdmin }) {
const { t } = useTranslation();
const toast = useToast();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [tunnels, setTunnels] = useState([]);
// Create form state
const [name, setName] = useState('');
const [creating, setCreating] = useState(false);
const [lastInstall, setLastInstall] = useState(null);
const loadData = useCallback(async () => {
try {
const data = await api.getCloudflareTunnels(zoneId);
setTunnels(data.tunnels || []);
setError(null);
} catch (err) {
setError(err.message);
}
}, [zoneId]);
useEffect(() => {
let active = true;
setLoading(true);
(async () => {
try {
const data = await api.getCloudflareTunnels(zoneId);
if (!active) return;
setTunnels(data.tunnels || []);
setError(null);
} catch (err) {
if (active) setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [zoneId]);
const handleCreate = async () => {
setCreating(true);
try {
const res = await api.createCloudflareTunnel(zoneId, name);
toast.success(t('app.tunnelsPanel.createdTunnel', 'Created tunnel "{{name}}"', { name: name }));
setLastInstall({ name, install: res.install, token: res.token });
setName('');
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setCreating(false);
}
};
if (loading) {
return <div className="cf-tunnels__loading">{t('app.tunnelsPanel.loadingCloudflareTunnels', 'Loading Cloudflare Tunnels…')}</div>;
}
if (error) {
return (
<EmptyState
icon={Network}
title={t('app.tunnelsPanel.cloudflareTunnelsUnavailable', 'Cloudflare Tunnels unavailable')}
description={error}
/>
);
}
const createDisabled = !isAdmin || creating || !name.trim();
return (
<div className="cf-tunnels">
{/* Create a Cloudflare Tunnel */}
<section className="cf-tunnels__section">
<h3 className="cf-tunnels__heading">{t('app.tunnelsPanel.createACloudflareTunnel', 'Create a Cloudflare Tunnel')}</h3>
<p className="cf-tunnels__hint">
{t('app.tunnelsPanel.aCloudflareTunnelExposesALocal', 'A Cloudflare Tunnel exposes a local or private service through Cloudflare\'s edge — no public IP or open ports required.')}
</p>
<div className="cf-tunnels__field">
<label className="cf-tunnels__label">{t('common.labels.name', 'Name')}</label>
<Input
value={name}
placeholder="home-jellyfin"
onChange={(e) => setName(e.target.value)}
disabled={!isAdmin || creating}
/>
</div>
<div className="cf-tunnels__actions">
<Button onClick={handleCreate} disabled={createDisabled}>
{t('common.actions.create', 'Create')}
</Button>
</div>
{lastInstall && (
<div className="cf-tunnels__install">
<p className="cf-tunnels__hint">
{t('app.tunnelsPanel.runThisOnceOnTheMachine', 'Run this once on the machine hosting your local service. The connector token is shown only now.')}
</p>
<InstallBox command={lastInstall.install} />
<Button
size="sm"
variant="ghost"
onClick={() => setLastInstall(null)}
>
{t('common.actions.dismiss', 'Dismiss')}
</Button>
</div>
)}
</section>
{/* Cloudflare Tunnels list */}
<section className="cf-tunnels__section">
<h3 className="cf-tunnels__heading">{t('app.tunnelsPanel.cloudflareTunnels', 'Cloudflare Tunnels (')}{tunnels.length})</h3>
{tunnels.length === 0 ? (
<EmptyState
icon={Network}
title={t('app.tunnelsPanel.noCloudflareTunnels', 'No Cloudflare Tunnels')}
description={t('app.tunnelsPanel.createOneAboveToExposeA', 'Create one above to expose a local service without a public IP.')}
/>
) : (
<ul className="cf-tunnels__list">
{tunnels.map((tunnel) => (
<TunnelRow
key={tunnel.id}
zoneId={zoneId}
tunnel={tunnel}
isAdmin={isAdmin}
onChanged={loadData}
/>
))}
</ul>
)}
</section>
</div>
);
}
@@ -1,253 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Zap } from 'lucide-react';
import api from '../services/cloudflare';
import { Button, Input, Textarea, Badge, EmptyState, useToast } from 'serverkit-sdk';
import { useTranslation } from 'react-i18next';
const STARTER_SCRIPT = `export default {
async fetch(request, env, ctx) {
return new Response("Hello from ServerKit + Cloudflare Workers!", {
headers: { "content-type": "text/plain" },
});
},
};`;
export default function WorkersPanel({ zoneId, isAdmin }) {
const { t } = useTranslation();
const toast = useToast();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [workers, setWorkers] = useState([]);
const [routes, setRoutes] = useState([]);
// Deploy form state
const [name, setName] = useState('');
const [code, setCode] = useState(STARTER_SCRIPT);
const [routePattern, setRoutePattern] = useState('');
// Tracks any in-flight write so buttons can disable
const [deploying, setDeploying] = useState(false);
const [working, setWorking] = useState(false);
const loadData = useCallback(async () => {
try {
const data = await api.getCloudflareWorkers(zoneId);
setWorkers(data.workers || []);
setRoutes(data.routes || []);
setError(null);
} catch (err) {
setError(err.message);
}
}, [zoneId]);
useEffect(() => {
let active = true;
setLoading(true);
(async () => {
try {
const data = await api.getCloudflareWorkers(zoneId);
if (!active) return;
setWorkers(data.workers || []);
setRoutes(data.routes || []);
setError(null);
} catch (err) {
if (active) setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [zoneId]);
const handleDeploy = async () => {
setDeploying(true);
try {
const res = await api.deployCloudflareWorker(zoneId, {
name,
code,
route_pattern: routePattern || undefined,
});
toast.success(t('app.workersPanel.deployedWorker', 'Deployed worker "{{name}}"', { name: name }));
if (res.route && !res.route.success) {
toast.error(t('app.workersPanel.workerDeployedButTheRouteFailed', 'Worker deployed, but the route failed: ') + res.route.error);
}
setName('');
setRoutePattern('');
await loadData();
} catch (err) {
toast.error(err.message);
} finally {
setDeploying(false);
}
};
const handleDeleteWorker = async (worker) => {
setWorking(true);
try {
await api.deleteCloudflareWorker(zoneId, worker.name);
await loadData();
toast.success(t('app.workersPanel.deletedWorker', 'Deleted worker "{{name}}"', { name: worker.name }));
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
const handleDeleteRoute = async (route) => {
setWorking(true);
try {
await api.deleteCloudflareWorkerRoute(zoneId, route.id);
await loadData();
toast.success(t('app.workersPanel.routeRemoved', 'Route removed'));
} catch (err) {
toast.error(err.message);
} finally {
setWorking(false);
}
};
if (loading) {
return <div className="cf-workers__loading">{t('app.workersPanel.loadingWorkers', 'Loading workers…')}</div>;
}
if (error) {
return (
<EmptyState
icon={Zap}
title={t('app.workersPanel.workersUnavailable', 'Workers unavailable')}
description={error}
/>
);
}
const writeDisabled = !isAdmin || working;
const deployDisabled = !isAdmin || deploying || !name.trim() || !code.trim();
return (
<div className="cf-workers">
{/* Deploy a Worker */}
<section className="cf-workers__section">
<h3 className="cf-workers__heading">{t('app.workersPanel.deployAWorker', 'Deploy a Worker')}</h3>
<div className="cf-workers__field">
<label className="cf-workers__label">{t('common.labels.name', 'Name')}</label>
<Input
value={name}
placeholder="my-worker"
onChange={(e) => setName(e.target.value)}
disabled={!isAdmin || deploying}
/>
</div>
<div className="cf-workers__field">
<label className="cf-workers__label">{t('app.workersPanel.code', 'Code')}</label>
<Textarea
rows={8}
className="cf-workers__code"
value={code}
onChange={(e) => setCode(e.target.value)}
disabled={!isAdmin || deploying}
/>
</div>
<div className="cf-workers__field">
<label className="cf-workers__label">{t('app.workersPanel.routePatternOptional', 'Route pattern (optional)')}</label>
<Input
value={routePattern}
placeholder="example.com/*"
onChange={(e) => setRoutePattern(e.target.value)}
disabled={!isAdmin || deploying}
/>
<p className="cf-workers__hint">
{t('app.workersPanel.attachesTheWorkerToThisDomain', 'Attaches the worker to this domain so matching traffic runs the script.')}
</p>
</div>
<div className="cf-workers__actions">
<Button onClick={handleDeploy} disabled={deployDisabled}>
{t('app.workersPanel.deploy', 'Deploy')}
</Button>
</div>
</section>
{/* Deployed workers */}
<section className="cf-workers__section">
<h3 className="cf-workers__heading">{t('app.workersPanel.workers', 'Workers (')}{workers.length})</h3>
{workers.length === 0 ? (
<EmptyState
icon={Zap}
title={t('app.workersPanel.noWorkersDeployed', 'No Workers deployed')}
description={t('app.workersPanel.deployYourFirstEdgeScriptAbove', 'Deploy your first edge script above.')}
/>
) : (
<ul className="cf-workers__list">
{workers.map((worker) => (
<li className="cf-workers__item" key={worker.name}>
<div className="cf-workers__name">
<code>{worker.name}</code>
{worker.managed && (
<Badge variant="secondary">{t('common.labels.serverKit', 'ServerKit')}</Badge>
)}
{worker.modified_on && (
<span className="cf-workers__meta">
{new Date(worker.modified_on).toLocaleString()}
</span>
)}
</div>
<div className="cf-workers__item-actions">
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteWorker(worker)}
disabled={writeDisabled}
>
{t('common.actions.delete', 'Delete')}
</Button>
</div>
</li>
))}
</ul>
)}
</section>
{/* Routes */}
<section className="cf-workers__section">
<h3 className="cf-workers__heading">{t('app.workersPanel.routes', 'Routes (')}{routes.length})</h3>
{routes.length === 0 ? (
<p className="cf-workers__hint">
{t('app.workersPanel.noRoutesYetAddARoute', 'No routes yet. Add a route pattern when deploying to send traffic to a worker.')}
</p>
) : (
<ul className="cf-workers__list">
{routes.map((route) => (
<li className="cf-workers__item" key={route.id}>
<div className="cf-workers__name">
<code>{route.pattern}</code>
<span className="cf-workers__meta">&rarr; {route.script}</span>
</div>
<div className="cf-workers__item-actions">
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteRoute(route)}
disabled={writeDisabled}
>
{t('common.actions.remove', 'Remove')}
</Button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
);
}
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more