From c7aea8d4193b9a3cbb84c242df2a5acd6d266eeb Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Mon, 26 Oct 2026 11:02:58 +0000 Subject: [PATCH] =?UTF-8?q?Ariweb=20Suite=200.1.1=20=EA=B8=B0=EB=B0=98:=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC(db=C2=B7http=C2=B7=EC=84=B8=EC=85=98=C2=B7=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=97=94=EC=A7=84=C2=B7=ED=95=A0=EB=8B=B9/?= =?UTF-8?q?=EC=98=88=EC=95=BD=C2=B7=EC=95=8C=EB=A6=BC=C2=B7=EA=B0=90?= =?UTF-8?q?=EC=82=AC=C2=B7=EB=B3=B4=EA=B4=80=EC=86=8C),=20=ED=8F=AC?= =?UTF-8?q?=ED=84=B8=20=EB=BC=88=EB=8C=80,=20Webmaker/AriMail=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EC=86=8C=EC=8A=A4=EC=99=80=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .gitignore | 11 + IMPORTED_SOURCES.json | 1607 + IMPORTED_SOURCES.md | 290 + docs/ARCHITECTURE.md | 121 + lang/ko/common.json | 7 + lib/audit.mjs | 45 + lib/crypto.mjs | 29 + lib/db.mjs | 74 + lib/http.mjs | 157 + lib/i18n.mjs | 27 + lib/jobs.mjs | 212 + lib/kst.mjs | 25 + lib/notify.mjs | 59 + lib/quota.mjs | 150 + lib/redact.mjs | 45 + lib/session.mjs | 64 + lib/status.mjs | 53 + lib/test.mjs | 26 + lib/vault.mjs | 41 + modules/arimail/README.md | 68 + modules/arimail/deploy/deploy.sh | 52 + modules/arimail/deploy/domain-nginx.sh | 54 + modules/arimail/deploy/gateway.env.in | 65 + modules/arimail/deploy/haraka-apply.sh | 44 + .../deploy/letsencrypt-deploy-hook.sh.in | 12 + modules/arimail/deploy/lib.sh | 54 + modules/arimail/deploy/new-site.sh | 84 + modules/arimail/deploy/nginx-apply.sh | 22 + .../arimail/deploy/nginx/mail-path.conf.in | 27 + .../deploy/nginx/mail.cloudflare.conf.in | 76 + modules/arimail/deploy/nginx/mail.conf.in | 90 + .../deploy/nginx/mail.http-only.conf.in | 8 + modules/arimail/deploy/setup.sh | 85 + modules/arimail/deploy/site-mail-path.sh | 64 + .../deploy/systemd/mail-gateway.service.in | 39 + .../deploy/systemd/mail-haraka.service.in | 42 + .../deploy/systemd/mail-tls.service.in | 8 + .../arimail/deploy/systemd/mail-tls.timer.in | 10 + modules/arimail/deploy/tls-when-ready.sh | 29 + modules/arimail/deploy/webmaker-access.sh | 131 + modules/arimail/docs/SETUP.md | 453 + modules/arimail/docs/USAGE-API.md | 91 + modules/arimail/haraka/README.md | 12 + modules/arimail/haraka/package.json | 9 + modules/arimail/haraka/plugins/arimail.js | 111 + modules/arimail/haraka/run.mjs | 198 + modules/arimail/lang/ko.json | 666 + modules/arimail/migrations/0001_init.sql | 81 + modules/arimail/migrations/0002_imap.sql | 16 + modules/arimail/migrations/0003_accounts.sql | 19 + .../arimail/migrations/0004_no_retention.sql | 20 + .../migrations/0005_imap_uid_reassign.sql | 16 + .../arimail/migrations/0006_session_scope.sql | 6 + .../arimail/migrations/0007_auto_actions.sql | 17 + .../arimail/migrations/0008_large_files.sql | 26 + modules/arimail/package.json | 19 + modules/arimail/server/api.mjs | 479 + modules/arimail/server/auth.mjs | 416 + modules/arimail/server/automation.mjs | 191 + modules/arimail/server/config.mjs | 274 + modules/arimail/server/db-migrate.mjs | 9 + modules/arimail/server/db.mjs | 62 + modules/arimail/server/dns/common.mjs | 52 + modules/arimail/server/dns/google.mjs | 99 + modules/arimail/server/dns/index.mjs | 99 + modules/arimail/server/dns/route53.mjs | 107 + modules/arimail/server/domains.mjs | 356 + modules/arimail/server/files.mjs | 242 + modules/arimail/server/imap-mime.mjs | 228 + modules/arimail/server/imap.mjs | 476 + modules/arimail/server/index.mjs | 17 + modules/arimail/server/limits.mjs | 296 + modules/arimail/server/main.mjs | 342 + modules/arimail/server/mime.mjs | 117 + modules/arimail/server/pop3.mjs | 244 + modules/arimail/server/providers/index.mjs | 69 + modules/arimail/server/providers/resend.mjs | 223 + .../arimail/server/providers/smtp-client.mjs | 156 + modules/arimail/server/providers/smtp.mjs | 46 + modules/arimail/server/provision.mjs | 209 + modules/arimail/server/resend.mjs | 5 + modules/arimail/server/sanitize.mjs | 116 + modules/arimail/server/setup.mjs | 230 + modules/arimail/server/smtp.mjs | 446 + modules/arimail/server/store.mjs | 574 + modules/arimail/server/usage.mjs | 224 + modules/arimail/server/user-cli.mjs | 81 + modules/arimail/server/util.mjs | 76 + modules/arimail/server/webhook.mjs | 24 + modules/arimail/server/webmaker-snapshot.mjs | 87 + modules/arimail/server/webmaker.mjs | 207 + modules/arimail/test/_helpers.mjs | 45 + modules/arimail/test/config.test.mjs | 185 + modules/arimail/test/domains.test.mjs | 226 + modules/arimail/test/gateway.test.mjs | 1157 + modules/arimail/test/imap.test.mjs | 208 + modules/arimail/test/provision.test.mjs | 255 + modules/arimail/test/smtp.test.mjs | 225 + modules/arimail/test/usage.test.mjs | 162 + modules/arimail/test/webmaker.test.mjs | 290 + modules/arimail/web/app.js | 534 + modules/arimail/web/favicon.svg | 1 + modules/arimail/web/index.html | 14 + modules/arimail/web/setup.css | 30 + modules/arimail/web/setup.html | 13 + modules/arimail/web/setup.js | 312 + modules/arimail/web/style.css | 105 + modules/webmaker/README.md | 455 + modules/webmaker/deploy/deploy.sh | 29 + .../webmaker/deploy/front-proxy-webmaker.conf | 29 + modules/webmaker/deploy/install-arimail.sh | 184 + modules/webmaker/deploy/install-gitea.sh | 133 + modules/webmaker/deploy/install-services.sh | 55 + modules/webmaker/deploy/install.sh | 44 + modules/webmaker/deploy/nginx-webhosting.conf | 56 + modules/webmaker/deploy/nginx-wmstats.conf | 4 + modules/webmaker/deploy/remote-install.sh | 144 + modules/webmaker/deploy/remote-update.sh | 46 + .../webmaker/deploy/webhosting-agent.service | 28 + .../webmaker/deploy/webmaker-setup.service | 25 + modules/webmaker/docs/ARIMAIL.md | 99 + modules/webmaker/docs/CALENDAR.md | 50 + modules/webmaker/docs/CONFIG.md | 103 + modules/webmaker/docs/DOMAIN-DNS-PROVIDERS.md | 94 + modules/webmaker/docs/DOMAIN-SETUP.md | 244 + modules/webmaker/docs/FIRST-INSTALL.md | 59 + modules/webmaker/docs/GITEA-LOCAL.md | 72 + modules/webmaker/docs/SSL-WILDCARD.md | 76 + modules/webmaker/hooks/askpass.sh | 4 + modules/webmaker/hooks/guard.js | 63 + modules/webmaker/hooks/guard.sh | 4 + modules/webmaker/hooks/settings.json | 10 + modules/webmaker/lib/audit.js | 46 + modules/webmaker/lib/auth.js | 214 + modules/webmaker/lib/awsv4.js | 32 + modules/webmaker/lib/billing.js | 51 + modules/webmaker/lib/brief.js | 236 + modules/webmaker/lib/calendar.js | 183 + modules/webmaker/lib/claude.js | 361 + modules/webmaker/lib/config.js | 303 + modules/webmaker/lib/connectors/arimail.js | 259 + modules/webmaker/lib/connectors/gitea.js | 122 + modules/webmaker/lib/connectors/index.js | 48 + modules/webmaker/lib/costcal.js | 64 + modules/webmaker/lib/crawler.js | 181 + modules/webmaker/lib/credits.js | 85 + modules/webmaker/lib/deploy.js | 560 + modules/webmaker/lib/dns/gcloud.js | 59 + modules/webmaker/lib/dns/openprovider.js | 8 + modules/webmaker/lib/dns/porkbun.js | 36 + modules/webmaker/lib/dns/route53.js | 104 + modules/webmaker/lib/dnszone.js | 101 + modules/webmaker/lib/domains.js | 710 + modules/webmaker/lib/engine-codex.js | 184 + modules/webmaker/lib/engine-openai.js | 96 + modules/webmaker/lib/feedback.js | 161 + modules/webmaker/lib/forms.js | 53 + modules/webmaker/lib/fx.js | 62 + modules/webmaker/lib/gitstore.js | 174 + modules/webmaker/lib/google.js | 70 + modules/webmaker/lib/i18n.js | 389 + modules/webmaker/lib/jobpids.js | 26 + modules/webmaker/lib/jsonstore.js | 59 + modules/webmaker/lib/logview.js | 75 + modules/webmaker/lib/lunar.js | 262 + modules/webmaker/lib/mailer.js | 60 + modules/webmaker/lib/mailservers.js | 350 + modules/webmaker/lib/mailsys.js | 468 + modules/webmaker/lib/mailusage.js | 125 + modules/webmaker/lib/maintpage.js | 41 + modules/webmaker/lib/modules.js | 518 + modules/webmaker/lib/multilang.js | 453 + modules/webmaker/lib/netguard.js | 139 + modules/webmaker/lib/openprovider.js | 116 + modules/webmaker/lib/passpolicy.js | 51 + modules/webmaker/lib/planner.js | 222 + modules/webmaker/lib/prepaid.js | 154 + modules/webmaker/lib/prompt.js | 227 + modules/webmaker/lib/providers.js | 159 + modules/webmaker/lib/ratelimit.js | 29 + modules/webmaker/lib/recovery.js | 33 + modules/webmaker/lib/refs.js | 63 + modules/webmaker/lib/registrar.js | 21 + .../webmaker/lib/registrars/openprovider.js | 8 + modules/webmaker/lib/registrars/porkbun.js | 30 + modules/webmaker/lib/registrars/route53.js | 132 + modules/webmaker/lib/remote.js | 475 + modules/webmaker/lib/report.js | 32 + modules/webmaker/lib/run.js | 27 + modules/webmaker/lib/seo.js | 376 + modules/webmaker/lib/settings.js | 257 + modules/webmaker/lib/setupapi.js | 167 + modules/webmaker/lib/setupauth.js | 91 + modules/webmaker/lib/setupstate.js | 105 + modules/webmaker/lib/sitemsg.js | 92 + modules/webmaker/lib/ssl.js | 157 + modules/webmaker/lib/stats.js | 244 + modules/webmaker/lib/store.js | 73 + modules/webmaker/lib/templates.js | 159 + modules/webmaker/lib/uploads.js | 94 + modules/webmaker/lib/vault.js | 197 + modules/webmaker/lib/xlsx.js | 61 + modules/webmaker/locales/ko.json | 45393 ++++++++++++++++ modules/webmaker/locales/site/ar.json | 11 + modules/webmaker/locales/site/de.json | 11 + modules/webmaker/locales/site/en.json | 230 + modules/webmaker/locales/site/es.json | 11 + modules/webmaker/locales/site/fa.json | 11 + modules/webmaker/locales/site/fr.json | 11 + modules/webmaker/locales/site/he.json | 11 + modules/webmaker/locales/site/id.json | 11 + modules/webmaker/locales/site/ja.json | 11 + modules/webmaker/locales/site/ko.json | 903 + modules/webmaker/locales/site/mn.json | 11 + modules/webmaker/locales/site/pt.json | 11 + modules/webmaker/locales/site/ru.json | 11 + modules/webmaker/locales/site/th.json | 11 + modules/webmaker/locales/site/tr.json | 11 + modules/webmaker/locales/site/vi.json | 11 + modules/webmaker/locales/site/zh-CN.json | 11 + modules/webmaker/locales/site/zh-TW.json | 11 + modules/webmaker/modules/README.md | 36 + modules/webmaker/modules/blog/widget.html | 19 + modules/webmaker/modules/board/widget.html | 30 + modules/webmaker/modules/booking/widget.html | 21 + modules/webmaker/modules/calendar/widget.html | 18 + modules/webmaker/modules/faq/widget.html | 14 + modules/webmaker/modules/gallery/widget.html | 15 + modules/webmaker/modules/inquiry/widget.html | 31 + modules/webmaker/modules/library/widget.html | 17 + modules/webmaker/modules/map/widget.html | 13 + modules/webmaker/modules/members/widget.html | 30 + modules/webmaker/modules/reviews/widget.html | 21 + modules/webmaker/modules/survey/widget.html | 16 + modules/webmaker/package.json | 15 + modules/webmaker/public/404.html | 1 + modules/webmaker/public/admin.css | 122 + modules/webmaker/public/admin.html | 572 + modules/webmaker/public/admin.js | 1245 + modules/webmaker/public/app.js | 1180 + modules/webmaker/public/credits-ui.js | 129 + modules/webmaker/public/domains-ui.js | 742 + modules/webmaker/public/flow-ui.js | 239 + modules/webmaker/public/i18n.js | 147 + modules/webmaker/public/import.js | 74 + modules/webmaker/public/index.html | 504 + modules/webmaker/public/login.html | 110 + modules/webmaker/public/manage-ui.js | 72 + modules/webmaker/public/modules-ui.js | 22 + modules/webmaker/public/numfmt.js | 54 + modules/webmaker/public/optimize.js | 273 + modules/webmaker/public/pwrule.js | 23 + modules/webmaker/public/setup.html | 48 + modules/webmaker/public/setup.js | 297 + modules/webmaker/public/site-admin.html | 53 + modules/webmaker/public/site-admin.js | 356 + modules/webmaker/public/site-info.js | 160 + modules/webmaker/public/style.css | 757 + modules/webmaker/public/templates-ui.js | 79 + modules/webmaker/public/webmail.html | 58 + modules/webmaker/public/webmail.js | 65 + modules/webmaker/public/wizard.js | 608 + modules/webmaker/public/wm-modules.js | 365 + modules/webmaker/public/wm-stats.js | 49 + modules/webmaker/scripts/acme-dns-hook.js | 77 + modules/webmaker/scripts/admin.js | 15 + modules/webmaker/scripts/backup-data.js | 24 + modules/webmaker/scripts/collect-backlog.js | 50 + modules/webmaker/scripts/config.js | 59 + modules/webmaker/scripts/i18n-extract.js | 10 + modules/webmaker/scripts/migrate-gitstore.sh | 59 + modules/webmaker/scripts/site-msg-catalog.py | 105 + modules/webmaker/scripts/ssl-wildcard.js | 37 + modules/webmaker/scripts/unit-check.js | 60 + modules/webmaker/server.js | 2844 + modules/webmaker/setup-server.js | 90 + .../webmaker/templates/contact-widget.html | 20 + modules/webmaker/test/dnszone-import.test.js | 34 + modules/webmaker/test/domains-process.test.js | 148 + modules/webmaker/test/lib.test.js | 125 + modules/webmaker/test/mailservers.test.js | 280 + .../webmaker/test/registrar-route53.test.js | 67 + modules/webmaker/test/server.test.js | 152 + package.json | 17 + portal/app.mjs | 32 + portal/auth/guard.mjs | 52 + portal/config.mjs | 28 + portal/index.mjs | 30 + portal/schema.mjs | 21 + portal/ui/layout.mjs | 47 + portal/ui/public/app.css | 30 + portal/ui/public/app.js | 48 + scripts/import-sources.mjs | 58 + test/lib.test.mjs | 107 + test/run.mjs | 20 + 295 files changed, 90146 insertions(+) create mode 100644 .gitignore create mode 100644 IMPORTED_SOURCES.json create mode 100644 IMPORTED_SOURCES.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 lang/ko/common.json create mode 100644 lib/audit.mjs create mode 100644 lib/crypto.mjs create mode 100644 lib/db.mjs create mode 100644 lib/http.mjs create mode 100644 lib/i18n.mjs create mode 100644 lib/jobs.mjs create mode 100644 lib/kst.mjs create mode 100644 lib/notify.mjs create mode 100644 lib/quota.mjs create mode 100644 lib/redact.mjs create mode 100644 lib/session.mjs create mode 100644 lib/status.mjs create mode 100644 lib/test.mjs create mode 100644 lib/vault.mjs create mode 100644 modules/arimail/README.md create mode 100755 modules/arimail/deploy/deploy.sh create mode 100755 modules/arimail/deploy/domain-nginx.sh create mode 100644 modules/arimail/deploy/gateway.env.in create mode 100644 modules/arimail/deploy/haraka-apply.sh create mode 100644 modules/arimail/deploy/letsencrypt-deploy-hook.sh.in create mode 100755 modules/arimail/deploy/lib.sh create mode 100755 modules/arimail/deploy/new-site.sh create mode 100755 modules/arimail/deploy/nginx-apply.sh create mode 100644 modules/arimail/deploy/nginx/mail-path.conf.in create mode 100644 modules/arimail/deploy/nginx/mail.cloudflare.conf.in create mode 100644 modules/arimail/deploy/nginx/mail.conf.in create mode 100644 modules/arimail/deploy/nginx/mail.http-only.conf.in create mode 100755 modules/arimail/deploy/setup.sh create mode 100755 modules/arimail/deploy/site-mail-path.sh create mode 100644 modules/arimail/deploy/systemd/mail-gateway.service.in create mode 100644 modules/arimail/deploy/systemd/mail-haraka.service.in create mode 100644 modules/arimail/deploy/systemd/mail-tls.service.in create mode 100644 modules/arimail/deploy/systemd/mail-tls.timer.in create mode 100755 modules/arimail/deploy/tls-when-ready.sh create mode 100755 modules/arimail/deploy/webmaker-access.sh create mode 100644 modules/arimail/docs/SETUP.md create mode 100644 modules/arimail/docs/USAGE-API.md create mode 100644 modules/arimail/haraka/README.md create mode 100644 modules/arimail/haraka/package.json create mode 100644 modules/arimail/haraka/plugins/arimail.js create mode 100644 modules/arimail/haraka/run.mjs create mode 100644 modules/arimail/lang/ko.json create mode 100644 modules/arimail/migrations/0001_init.sql create mode 100644 modules/arimail/migrations/0002_imap.sql create mode 100644 modules/arimail/migrations/0003_accounts.sql create mode 100644 modules/arimail/migrations/0004_no_retention.sql create mode 100644 modules/arimail/migrations/0005_imap_uid_reassign.sql create mode 100644 modules/arimail/migrations/0006_session_scope.sql create mode 100644 modules/arimail/migrations/0007_auto_actions.sql create mode 100644 modules/arimail/migrations/0008_large_files.sql create mode 100644 modules/arimail/package.json create mode 100644 modules/arimail/server/api.mjs create mode 100644 modules/arimail/server/auth.mjs create mode 100644 modules/arimail/server/automation.mjs create mode 100644 modules/arimail/server/config.mjs create mode 100644 modules/arimail/server/db-migrate.mjs create mode 100644 modules/arimail/server/db.mjs create mode 100644 modules/arimail/server/dns/common.mjs create mode 100644 modules/arimail/server/dns/google.mjs create mode 100644 modules/arimail/server/dns/index.mjs create mode 100644 modules/arimail/server/dns/route53.mjs create mode 100644 modules/arimail/server/domains.mjs create mode 100644 modules/arimail/server/files.mjs create mode 100644 modules/arimail/server/imap-mime.mjs create mode 100644 modules/arimail/server/imap.mjs create mode 100644 modules/arimail/server/index.mjs create mode 100644 modules/arimail/server/limits.mjs create mode 100644 modules/arimail/server/main.mjs create mode 100644 modules/arimail/server/mime.mjs create mode 100644 modules/arimail/server/pop3.mjs create mode 100644 modules/arimail/server/providers/index.mjs create mode 100644 modules/arimail/server/providers/resend.mjs create mode 100644 modules/arimail/server/providers/smtp-client.mjs create mode 100644 modules/arimail/server/providers/smtp.mjs create mode 100644 modules/arimail/server/provision.mjs create mode 100644 modules/arimail/server/resend.mjs create mode 100644 modules/arimail/server/sanitize.mjs create mode 100644 modules/arimail/server/setup.mjs create mode 100644 modules/arimail/server/smtp.mjs create mode 100644 modules/arimail/server/store.mjs create mode 100644 modules/arimail/server/usage.mjs create mode 100644 modules/arimail/server/user-cli.mjs create mode 100644 modules/arimail/server/util.mjs create mode 100644 modules/arimail/server/webhook.mjs create mode 100644 modules/arimail/server/webmaker-snapshot.mjs create mode 100644 modules/arimail/server/webmaker.mjs create mode 100644 modules/arimail/test/_helpers.mjs create mode 100644 modules/arimail/test/config.test.mjs create mode 100644 modules/arimail/test/domains.test.mjs create mode 100644 modules/arimail/test/gateway.test.mjs create mode 100644 modules/arimail/test/imap.test.mjs create mode 100644 modules/arimail/test/provision.test.mjs create mode 100644 modules/arimail/test/smtp.test.mjs create mode 100644 modules/arimail/test/usage.test.mjs create mode 100644 modules/arimail/test/webmaker.test.mjs create mode 100644 modules/arimail/web/app.js create mode 100644 modules/arimail/web/favicon.svg create mode 100644 modules/arimail/web/index.html create mode 100644 modules/arimail/web/setup.css create mode 100644 modules/arimail/web/setup.html create mode 100644 modules/arimail/web/setup.js create mode 100644 modules/arimail/web/style.css create mode 100644 modules/webmaker/README.md create mode 100755 modules/webmaker/deploy/deploy.sh create mode 100644 modules/webmaker/deploy/front-proxy-webmaker.conf create mode 100755 modules/webmaker/deploy/install-arimail.sh create mode 100755 modules/webmaker/deploy/install-gitea.sh create mode 100755 modules/webmaker/deploy/install-services.sh create mode 100755 modules/webmaker/deploy/install.sh create mode 100644 modules/webmaker/deploy/nginx-webhosting.conf create mode 100644 modules/webmaker/deploy/nginx-wmstats.conf create mode 100755 modules/webmaker/deploy/remote-install.sh create mode 100755 modules/webmaker/deploy/remote-update.sh create mode 100644 modules/webmaker/deploy/webhosting-agent.service create mode 100644 modules/webmaker/deploy/webmaker-setup.service create mode 100644 modules/webmaker/docs/ARIMAIL.md create mode 100644 modules/webmaker/docs/CALENDAR.md create mode 100644 modules/webmaker/docs/CONFIG.md create mode 100644 modules/webmaker/docs/DOMAIN-DNS-PROVIDERS.md create mode 100644 modules/webmaker/docs/DOMAIN-SETUP.md create mode 100644 modules/webmaker/docs/FIRST-INSTALL.md create mode 100644 modules/webmaker/docs/GITEA-LOCAL.md create mode 100644 modules/webmaker/docs/SSL-WILDCARD.md create mode 100755 modules/webmaker/hooks/askpass.sh create mode 100755 modules/webmaker/hooks/guard.js create mode 100755 modules/webmaker/hooks/guard.sh create mode 100644 modules/webmaker/hooks/settings.json create mode 100644 modules/webmaker/lib/audit.js create mode 100644 modules/webmaker/lib/auth.js create mode 100644 modules/webmaker/lib/awsv4.js create mode 100644 modules/webmaker/lib/billing.js create mode 100644 modules/webmaker/lib/brief.js create mode 100644 modules/webmaker/lib/calendar.js create mode 100644 modules/webmaker/lib/claude.js create mode 100644 modules/webmaker/lib/config.js create mode 100644 modules/webmaker/lib/connectors/arimail.js create mode 100644 modules/webmaker/lib/connectors/gitea.js create mode 100644 modules/webmaker/lib/connectors/index.js create mode 100644 modules/webmaker/lib/costcal.js create mode 100644 modules/webmaker/lib/crawler.js create mode 100644 modules/webmaker/lib/credits.js create mode 100644 modules/webmaker/lib/deploy.js create mode 100644 modules/webmaker/lib/dns/gcloud.js create mode 100644 modules/webmaker/lib/dns/openprovider.js create mode 100644 modules/webmaker/lib/dns/porkbun.js create mode 100644 modules/webmaker/lib/dns/route53.js create mode 100644 modules/webmaker/lib/dnszone.js create mode 100644 modules/webmaker/lib/domains.js create mode 100644 modules/webmaker/lib/engine-codex.js create mode 100644 modules/webmaker/lib/engine-openai.js create mode 100644 modules/webmaker/lib/feedback.js create mode 100644 modules/webmaker/lib/forms.js create mode 100644 modules/webmaker/lib/fx.js create mode 100644 modules/webmaker/lib/gitstore.js create mode 100644 modules/webmaker/lib/google.js create mode 100644 modules/webmaker/lib/i18n.js create mode 100644 modules/webmaker/lib/jobpids.js create mode 100644 modules/webmaker/lib/jsonstore.js create mode 100644 modules/webmaker/lib/logview.js create mode 100644 modules/webmaker/lib/lunar.js create mode 100644 modules/webmaker/lib/mailer.js create mode 100644 modules/webmaker/lib/mailservers.js create mode 100644 modules/webmaker/lib/mailsys.js create mode 100644 modules/webmaker/lib/mailusage.js create mode 100644 modules/webmaker/lib/maintpage.js create mode 100644 modules/webmaker/lib/modules.js create mode 100644 modules/webmaker/lib/multilang.js create mode 100644 modules/webmaker/lib/netguard.js create mode 100644 modules/webmaker/lib/openprovider.js create mode 100644 modules/webmaker/lib/passpolicy.js create mode 100644 modules/webmaker/lib/planner.js create mode 100644 modules/webmaker/lib/prepaid.js create mode 100644 modules/webmaker/lib/prompt.js create mode 100644 modules/webmaker/lib/providers.js create mode 100644 modules/webmaker/lib/ratelimit.js create mode 100644 modules/webmaker/lib/recovery.js create mode 100644 modules/webmaker/lib/refs.js create mode 100644 modules/webmaker/lib/registrar.js create mode 100644 modules/webmaker/lib/registrars/openprovider.js create mode 100644 modules/webmaker/lib/registrars/porkbun.js create mode 100644 modules/webmaker/lib/registrars/route53.js create mode 100644 modules/webmaker/lib/remote.js create mode 100644 modules/webmaker/lib/report.js create mode 100644 modules/webmaker/lib/run.js create mode 100644 modules/webmaker/lib/seo.js create mode 100644 modules/webmaker/lib/settings.js create mode 100644 modules/webmaker/lib/setupapi.js create mode 100644 modules/webmaker/lib/setupauth.js create mode 100644 modules/webmaker/lib/setupstate.js create mode 100644 modules/webmaker/lib/sitemsg.js create mode 100644 modules/webmaker/lib/ssl.js create mode 100644 modules/webmaker/lib/stats.js create mode 100644 modules/webmaker/lib/store.js create mode 100644 modules/webmaker/lib/templates.js create mode 100644 modules/webmaker/lib/uploads.js create mode 100644 modules/webmaker/lib/vault.js create mode 100644 modules/webmaker/lib/xlsx.js create mode 100644 modules/webmaker/locales/ko.json create mode 100644 modules/webmaker/locales/site/ar.json create mode 100644 modules/webmaker/locales/site/de.json create mode 100644 modules/webmaker/locales/site/en.json create mode 100644 modules/webmaker/locales/site/es.json create mode 100644 modules/webmaker/locales/site/fa.json create mode 100644 modules/webmaker/locales/site/fr.json create mode 100644 modules/webmaker/locales/site/he.json create mode 100644 modules/webmaker/locales/site/id.json create mode 100644 modules/webmaker/locales/site/ja.json create mode 100644 modules/webmaker/locales/site/ko.json create mode 100644 modules/webmaker/locales/site/mn.json create mode 100644 modules/webmaker/locales/site/pt.json create mode 100644 modules/webmaker/locales/site/ru.json create mode 100644 modules/webmaker/locales/site/th.json create mode 100644 modules/webmaker/locales/site/tr.json create mode 100644 modules/webmaker/locales/site/vi.json create mode 100644 modules/webmaker/locales/site/zh-CN.json create mode 100644 modules/webmaker/locales/site/zh-TW.json create mode 100644 modules/webmaker/modules/README.md create mode 100644 modules/webmaker/modules/blog/widget.html create mode 100644 modules/webmaker/modules/board/widget.html create mode 100644 modules/webmaker/modules/booking/widget.html create mode 100644 modules/webmaker/modules/calendar/widget.html create mode 100644 modules/webmaker/modules/faq/widget.html create mode 100644 modules/webmaker/modules/gallery/widget.html create mode 100644 modules/webmaker/modules/inquiry/widget.html create mode 100644 modules/webmaker/modules/library/widget.html create mode 100644 modules/webmaker/modules/map/widget.html create mode 100644 modules/webmaker/modules/members/widget.html create mode 100644 modules/webmaker/modules/reviews/widget.html create mode 100644 modules/webmaker/modules/survey/widget.html create mode 100644 modules/webmaker/package.json create mode 100644 modules/webmaker/public/404.html create mode 100644 modules/webmaker/public/admin.css create mode 100644 modules/webmaker/public/admin.html create mode 100644 modules/webmaker/public/admin.js create mode 100644 modules/webmaker/public/app.js create mode 100644 modules/webmaker/public/credits-ui.js create mode 100644 modules/webmaker/public/domains-ui.js create mode 100644 modules/webmaker/public/flow-ui.js create mode 100644 modules/webmaker/public/i18n.js create mode 100644 modules/webmaker/public/import.js create mode 100644 modules/webmaker/public/index.html create mode 100644 modules/webmaker/public/login.html create mode 100644 modules/webmaker/public/manage-ui.js create mode 100644 modules/webmaker/public/modules-ui.js create mode 100644 modules/webmaker/public/numfmt.js create mode 100644 modules/webmaker/public/optimize.js create mode 100644 modules/webmaker/public/pwrule.js create mode 100644 modules/webmaker/public/setup.html create mode 100644 modules/webmaker/public/setup.js create mode 100644 modules/webmaker/public/site-admin.html create mode 100644 modules/webmaker/public/site-admin.js create mode 100644 modules/webmaker/public/site-info.js create mode 100644 modules/webmaker/public/style.css create mode 100644 modules/webmaker/public/templates-ui.js create mode 100644 modules/webmaker/public/webmail.html create mode 100644 modules/webmaker/public/webmail.js create mode 100644 modules/webmaker/public/wizard.js create mode 100644 modules/webmaker/public/wm-modules.js create mode 100644 modules/webmaker/public/wm-stats.js create mode 100644 modules/webmaker/scripts/acme-dns-hook.js create mode 100644 modules/webmaker/scripts/admin.js create mode 100755 modules/webmaker/scripts/backup-data.js create mode 100755 modules/webmaker/scripts/collect-backlog.js create mode 100755 modules/webmaker/scripts/config.js create mode 100755 modules/webmaker/scripts/i18n-extract.js create mode 100755 modules/webmaker/scripts/migrate-gitstore.sh create mode 100644 modules/webmaker/scripts/site-msg-catalog.py create mode 100755 modules/webmaker/scripts/ssl-wildcard.js create mode 100755 modules/webmaker/scripts/unit-check.js create mode 100644 modules/webmaker/server.js create mode 100755 modules/webmaker/setup-server.js create mode 100644 modules/webmaker/templates/contact-widget.html create mode 100644 modules/webmaker/test/dnszone-import.test.js create mode 100644 modules/webmaker/test/domains-process.test.js create mode 100644 modules/webmaker/test/lib.test.js create mode 100644 modules/webmaker/test/mailservers.test.js create mode 100644 modules/webmaker/test/registrar-route53.test.js create mode 100644 modules/webmaker/test/server.test.js create mode 100644 package.json create mode 100644 portal/app.mjs create mode 100644 portal/auth/guard.mjs create mode 100644 portal/config.mjs create mode 100644 portal/index.mjs create mode 100644 portal/schema.mjs create mode 100644 portal/ui/layout.mjs create mode 100644 portal/ui/public/app.css create mode 100644 portal/ui/public/app.js create mode 100644 scripts/import-sources.mjs create mode 100644 test/lib.test.mjs create mode 100644 test/run.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2074f8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +data/ +*.sqlite +*.sqlite-* +*.log +*.tmp +.DS_Store +/dist/ +/modules/webmaker/data/ +/modules/arimail/data/ +/test/.tmp/ diff --git a/IMPORTED_SOURCES.json b/IMPORTED_SOURCES.json new file mode 100644 index 0000000..93ff2ee --- /dev/null +++ b/IMPORTED_SOURCES.json @@ -0,0 +1,1607 @@ +{ + "generatedAt": "2026-10-26T10:58:20.537Z", + "sources": [ + { + "name": "webmaker", + "repo": "https://source.ibit.kr/OZ-Group/WebHosting_AI_agent", + "commit": "3b10859884ef2fd0f2af11934ab6c16370fd64b8", + "dest": "modules/webmaker", + "license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.", + "files": [ + { + "from": "README.md", + "to": "modules/webmaker/README.md", + "status": "unchanged", + "origHash": "a7aa1eb7a6fdae8d" + }, + { + "from": "deploy/deploy.sh", + "to": "modules/webmaker/deploy/deploy.sh", + "status": "imported", + "origHash": "6f016f8dcf56a3b2" + }, + { + "from": "deploy/front-proxy-webmaker.conf", + "to": "modules/webmaker/deploy/front-proxy-webmaker.conf", + "status": "imported", + "origHash": "0ec1641b554a3b50" + }, + { + "from": "deploy/install-arimail.sh", + "to": "modules/webmaker/deploy/install-arimail.sh", + "status": "imported", + "origHash": "d8626ef07df49738" + }, + { + "from": "deploy/install-gitea.sh", + "to": "modules/webmaker/deploy/install-gitea.sh", + "status": "imported", + "origHash": "199631e74ea4f62c" + }, + { + "from": "deploy/install-services.sh", + "to": "modules/webmaker/deploy/install-services.sh", + "status": "imported", + "origHash": "f3b507512b501fd8" + }, + { + "from": "deploy/install.sh", + "to": "modules/webmaker/deploy/install.sh", + "status": "imported", + "origHash": "58ba0c250620b638" + }, + { + "from": "deploy/nginx-webhosting.conf", + "to": "modules/webmaker/deploy/nginx-webhosting.conf", + "status": "imported", + "origHash": "0058f1dafd622bc5" + }, + { + "from": "deploy/nginx-wmstats.conf", + "to": "modules/webmaker/deploy/nginx-wmstats.conf", + "status": "imported", + "origHash": "cf4069704f65ef73" + }, + { + "from": "deploy/remote-install.sh", + "to": "modules/webmaker/deploy/remote-install.sh", + "status": "imported", + "origHash": "66457327d0ef1b3c" + }, + { + "from": "deploy/remote-update.sh", + "to": "modules/webmaker/deploy/remote-update.sh", + "status": "imported", + "origHash": "fb0cf663fc687542" + }, + { + "from": "deploy/webhosting-agent.service", + "to": "modules/webmaker/deploy/webhosting-agent.service", + "status": "imported", + "origHash": "77259a206ca5530b" + }, + { + "from": "deploy/webmaker-setup.service", + "to": "modules/webmaker/deploy/webmaker-setup.service", + "status": "imported", + "origHash": "9bbcbb9239d24ef9" + }, + { + "from": "docs/ARIMAIL.md", + "to": "modules/webmaker/docs/ARIMAIL.md", + "status": "imported", + "origHash": "1e326aa2252621cf" + }, + { + "from": "docs/CALENDAR.md", + "to": "modules/webmaker/docs/CALENDAR.md", + "status": "imported", + "origHash": "1d12cd1720897254" + }, + { + "from": "docs/CONFIG.md", + "to": "modules/webmaker/docs/CONFIG.md", + "status": "imported", + "origHash": "13c26d45b5002b48" + }, + { + "from": "docs/DOMAIN-DNS-PROVIDERS.md", + "to": "modules/webmaker/docs/DOMAIN-DNS-PROVIDERS.md", + "status": "imported", + "origHash": "c5ee35c850c7c728" + }, + { + "from": "docs/DOMAIN-SETUP.md", + "to": "modules/webmaker/docs/DOMAIN-SETUP.md", + "status": "imported", + "origHash": "83f0e447cd82e3dd" + }, + { + "from": "docs/FIRST-INSTALL.md", + "to": "modules/webmaker/docs/FIRST-INSTALL.md", + "status": "imported", + "origHash": "642ce20eb96cabb9" + }, + { + "from": "docs/GITEA-LOCAL.md", + "to": "modules/webmaker/docs/GITEA-LOCAL.md", + "status": "imported", + "origHash": "4d7462b52ef34783" + }, + { + "from": "docs/SSL-WILDCARD.md", + "to": "modules/webmaker/docs/SSL-WILDCARD.md", + "status": "imported", + "origHash": "d93102519cb39333" + }, + { + "from": "hooks/askpass.sh", + "to": "modules/webmaker/hooks/askpass.sh", + "status": "imported", + "origHash": "c86b42091d07748d" + }, + { + "from": "hooks/guard.js", + "to": "modules/webmaker/hooks/guard.js", + "status": "imported", + "origHash": "4804e4cd337db53c" + }, + { + "from": "hooks/guard.sh", + "to": "modules/webmaker/hooks/guard.sh", + "status": "imported", + "origHash": "29df99b89bc31912" + }, + { + "from": "hooks/settings.json", + "to": "modules/webmaker/hooks/settings.json", + "status": "imported", + "origHash": "30ebe47478166d24" + }, + { + "from": "lib/audit.js", + "to": "modules/webmaker/lib/audit.js", + "status": "imported", + "origHash": "5a0b6a90e3281d2f" + }, + { + "from": "lib/auth.js", + "to": "modules/webmaker/lib/auth.js", + "status": "imported", + "origHash": "84e3fcda15e46b49" + }, + { + "from": "lib/awsv4.js", + "to": "modules/webmaker/lib/awsv4.js", + "status": "imported", + "origHash": "8a4bdab7d701c4ec" + }, + { + "from": "lib/billing.js", + "to": "modules/webmaker/lib/billing.js", + "status": "imported", + "origHash": "4e3f88fd1f8ebae1" + }, + { + "from": "lib/brief.js", + "to": "modules/webmaker/lib/brief.js", + "status": "imported", + "origHash": "9f37eb2e30c3e710" + }, + { + "from": "lib/calendar.js", + "to": "modules/webmaker/lib/calendar.js", + "status": "imported", + "origHash": "c5ee8d9c2777daac" + }, + { + "from": "lib/claude.js", + "to": "modules/webmaker/lib/claude.js", + "status": "imported", + "origHash": "dcdebeeeb1d8ea99" + }, + { + "from": "lib/config.js", + "to": "modules/webmaker/lib/config.js", + "status": "imported", + "origHash": "aa577cf668b504e7" + }, + { + "from": "lib/connectors/arimail.js", + "to": "modules/webmaker/lib/connectors/arimail.js", + "status": "imported", + "origHash": "f4dea3dc448c155e" + }, + { + "from": "lib/connectors/gitea.js", + "to": "modules/webmaker/lib/connectors/gitea.js", + "status": "imported", + "origHash": "e736d764b2cdac6a" + }, + { + "from": "lib/connectors/index.js", + "to": "modules/webmaker/lib/connectors/index.js", + "status": "imported", + "origHash": "1dc6f6a8290b7a7f" + }, + { + "from": "lib/costcal.js", + "to": "modules/webmaker/lib/costcal.js", + "status": "imported", + "origHash": "22b008dccc3318db" + }, + { + "from": "lib/crawler.js", + "to": "modules/webmaker/lib/crawler.js", + "status": "imported", + "origHash": "392e0fd1e9c88a26" + }, + { + "from": "lib/credits.js", + "to": "modules/webmaker/lib/credits.js", + "status": "imported", + "origHash": "59caba266ed2bcda" + }, + { + "from": "lib/deploy.js", + "to": "modules/webmaker/lib/deploy.js", + "status": "imported", + "origHash": "ddc85c8850a243a6" + }, + { + "from": "lib/dns/gcloud.js", + "to": "modules/webmaker/lib/dns/gcloud.js", + "status": "imported", + "origHash": "e006e6ff4cde77c5" + }, + { + "from": "lib/dns/openprovider.js", + "to": "modules/webmaker/lib/dns/openprovider.js", + "status": "imported", + "origHash": "ae8009bedd485e50" + }, + { + "from": "lib/dns/porkbun.js", + "to": "modules/webmaker/lib/dns/porkbun.js", + "status": "imported", + "origHash": "9f779244aeddc392" + }, + { + "from": "lib/dns/route53.js", + "to": "modules/webmaker/lib/dns/route53.js", + "status": "imported", + "origHash": "b409db36696b9d5e" + }, + { + "from": "lib/dnszone.js", + "to": "modules/webmaker/lib/dnszone.js", + "status": "imported", + "origHash": "62f74f0772fdc4d4" + }, + { + "from": "lib/domains.js", + "to": "modules/webmaker/lib/domains.js", + "status": "imported", + "origHash": "cee7a767487f9a41" + }, + { + "from": "lib/engine-codex.js", + "to": "modules/webmaker/lib/engine-codex.js", + "status": "imported", + "origHash": "a074e8a4b9867d6d" + }, + { + "from": "lib/engine-openai.js", + "to": "modules/webmaker/lib/engine-openai.js", + "status": "imported", + "origHash": "93e4701e165d032a" + }, + { + "from": "lib/feedback.js", + "to": "modules/webmaker/lib/feedback.js", + "status": "imported", + "origHash": "c5860a676dd6118a" + }, + { + "from": "lib/forms.js", + "to": "modules/webmaker/lib/forms.js", + "status": "imported", + "origHash": "e2f1b1bb21e5bbdd" + }, + { + "from": "lib/fx.js", + "to": "modules/webmaker/lib/fx.js", + "status": "imported", + "origHash": "7514ae9840fa6307" + }, + { + "from": "lib/gitstore.js", + "to": "modules/webmaker/lib/gitstore.js", + "status": "imported", + "origHash": "d4650fab7b85f9b6" + }, + { + "from": "lib/google.js", + "to": "modules/webmaker/lib/google.js", + "status": "imported", + "origHash": "bfdaa70652f846cc" + }, + { + "from": "lib/i18n.js", + "to": "modules/webmaker/lib/i18n.js", + "status": "imported", + "origHash": "944c12aa16d00f45" + }, + { + "from": "lib/jobpids.js", + "to": "modules/webmaker/lib/jobpids.js", + "status": "imported", + "origHash": "f5df0824a0a457e9" + }, + { + "from": "lib/jsonstore.js", + "to": "modules/webmaker/lib/jsonstore.js", + "status": "imported", + "origHash": "20a9ee6f5acb22e7" + }, + { + "from": "lib/logview.js", + "to": "modules/webmaker/lib/logview.js", + "status": "imported", + "origHash": "0262fdf9042301f5" + }, + { + "from": "lib/lunar.js", + "to": "modules/webmaker/lib/lunar.js", + "status": "imported", + "origHash": "fc858ad1b9166b8c" + }, + { + "from": "lib/mailer.js", + "to": "modules/webmaker/lib/mailer.js", + "status": "imported", + "origHash": "7f10112c31ca2d7b" + }, + { + "from": "lib/mailservers.js", + "to": "modules/webmaker/lib/mailservers.js", + "status": "imported", + "origHash": "8608b0f16a0ea9b1" + }, + { + "from": "lib/mailsys.js", + "to": "modules/webmaker/lib/mailsys.js", + "status": "imported", + "origHash": "8f2e419e859f62ea" + }, + { + "from": "lib/mailusage.js", + "to": "modules/webmaker/lib/mailusage.js", + "status": "imported", + "origHash": "a3b608fc766ebe06" + }, + { + "from": "lib/maintpage.js", + "to": "modules/webmaker/lib/maintpage.js", + "status": "imported", + "origHash": "f57501de5a9349d8" + }, + { + "from": "lib/modules.js", + "to": "modules/webmaker/lib/modules.js", + "status": "imported", + "origHash": "b0f8b6df731d180e" + }, + { + "from": "lib/multilang.js", + "to": "modules/webmaker/lib/multilang.js", + "status": "imported", + "origHash": "30eaec7a02d17836" + }, + { + "from": "lib/netguard.js", + "to": "modules/webmaker/lib/netguard.js", + "status": "imported", + "origHash": "7f3f4a1a908b2371" + }, + { + "from": "lib/openprovider.js", + "to": "modules/webmaker/lib/openprovider.js", + "status": "imported", + "origHash": "aa0ff8df9e04b482" + }, + { + "from": "lib/passpolicy.js", + "to": "modules/webmaker/lib/passpolicy.js", + "status": "imported", + "origHash": "96c30ce7d9f87797" + }, + { + "from": "lib/planner.js", + "to": "modules/webmaker/lib/planner.js", + "status": "imported", + "origHash": "55fe3f3597bd9278" + }, + { + "from": "lib/prepaid.js", + "to": "modules/webmaker/lib/prepaid.js", + "status": "imported", + "origHash": "f1078822b25d5325" + }, + { + "from": "lib/prompt.js", + "to": "modules/webmaker/lib/prompt.js", + "status": "imported", + "origHash": "3279a47d1bb1e078" + }, + { + "from": "lib/providers.js", + "to": "modules/webmaker/lib/providers.js", + "status": "imported", + "origHash": "bf01ac2ac60c40d1" + }, + { + "from": "lib/ratelimit.js", + "to": "modules/webmaker/lib/ratelimit.js", + "status": "imported", + "origHash": "5abfa063c0f78873" + }, + { + "from": "lib/recovery.js", + "to": "modules/webmaker/lib/recovery.js", + "status": "imported", + "origHash": "42272b277598a7fb" + }, + { + "from": "lib/refs.js", + "to": "modules/webmaker/lib/refs.js", + "status": "imported", + "origHash": "1c530a8241d363dc" + }, + { + "from": "lib/registrar.js", + "to": "modules/webmaker/lib/registrar.js", + "status": "imported", + "origHash": "de6c606dcc87d665" + }, + { + "from": "lib/registrars/openprovider.js", + "to": "modules/webmaker/lib/registrars/openprovider.js", + "status": "imported", + "origHash": "04697456df4cc68e" + }, + { + "from": "lib/registrars/porkbun.js", + "to": "modules/webmaker/lib/registrars/porkbun.js", + "status": "imported", + "origHash": "6bb857595564dcf6" + }, + { + "from": "lib/registrars/route53.js", + "to": "modules/webmaker/lib/registrars/route53.js", + "status": "imported", + "origHash": "b60f17fefad37a4f" + }, + { + "from": "lib/remote.js", + "to": "modules/webmaker/lib/remote.js", + "status": "imported", + "origHash": "965366d84a6ee859" + }, + { + "from": "lib/report.js", + "to": "modules/webmaker/lib/report.js", + "status": "imported", + "origHash": "3c14814e9a8f424f" + }, + { + "from": "lib/run.js", + "to": "modules/webmaker/lib/run.js", + "status": "imported", + "origHash": "aad85b1616605fc3" + }, + { + "from": "lib/seo.js", + "to": "modules/webmaker/lib/seo.js", + "status": "imported", + "origHash": "fabcb8b18469ec10" + }, + { + "from": "lib/settings.js", + "to": "modules/webmaker/lib/settings.js", + "status": "imported", + "origHash": "98abbde31bbef9ce" + }, + { + "from": "lib/setupapi.js", + "to": "modules/webmaker/lib/setupapi.js", + "status": "imported", + "origHash": "3fd30ebd9efa47a6" + }, + { + "from": "lib/setupauth.js", + "to": "modules/webmaker/lib/setupauth.js", + "status": "imported", + "origHash": "8e6e1981448bc343" + }, + { + "from": "lib/setupstate.js", + "to": "modules/webmaker/lib/setupstate.js", + "status": "imported", + "origHash": "5de040b7a5a88f6c" + }, + { + "from": "lib/sitemsg.js", + "to": "modules/webmaker/lib/sitemsg.js", + "status": "imported", + "origHash": "09bcc4cb52e93a57" + }, + { + "from": "lib/ssl.js", + "to": "modules/webmaker/lib/ssl.js", + "status": "imported", + "origHash": "f7a5bb79900d8bdd" + }, + { + "from": "lib/stats.js", + "to": "modules/webmaker/lib/stats.js", + "status": "imported", + "origHash": "fc2de57d4be0960a" + }, + { + "from": "lib/store.js", + "to": "modules/webmaker/lib/store.js", + "status": "imported", + "origHash": "8132dcde60fc5789" + }, + { + "from": "lib/templates.js", + "to": "modules/webmaker/lib/templates.js", + "status": "imported", + "origHash": "bb44e537d0b28b16" + }, + { + "from": "lib/uploads.js", + "to": "modules/webmaker/lib/uploads.js", + "status": "imported", + "origHash": "5441ea45ed434575" + }, + { + "from": "lib/vault.js", + "to": "modules/webmaker/lib/vault.js", + "status": "imported", + "origHash": "0730f701278d33a1" + }, + { + "from": "lib/xlsx.js", + "to": "modules/webmaker/lib/xlsx.js", + "status": "imported", + "origHash": "dc353dd3b5fd6e10" + }, + { + "from": "locales/ko.json", + "to": "modules/webmaker/locales/ko.json", + "status": "imported", + "origHash": "20abf531ef8320f6" + }, + { + "from": "locales/site/ar.json", + "to": "modules/webmaker/locales/site/ar.json", + "status": "imported", + "origHash": "8644e815887e9bb1" + }, + { + "from": "locales/site/de.json", + "to": "modules/webmaker/locales/site/de.json", + "status": "imported", + "origHash": "60a7b71fd70ae371" + }, + { + "from": "locales/site/en.json", + "to": "modules/webmaker/locales/site/en.json", + "status": "imported", + "origHash": "0939d2e2eedf1af9" + }, + { + "from": "locales/site/es.json", + "to": "modules/webmaker/locales/site/es.json", + "status": "imported", + "origHash": "8ba29c21cf2b90fb" + }, + { + "from": "locales/site/fa.json", + "to": "modules/webmaker/locales/site/fa.json", + "status": "imported", + "origHash": "4c2b4917b1639f87" + }, + { + "from": "locales/site/fr.json", + "to": "modules/webmaker/locales/site/fr.json", + "status": "imported", + "origHash": "55e2e55db288f856" + }, + { + "from": "locales/site/he.json", + "to": "modules/webmaker/locales/site/he.json", + "status": "imported", + "origHash": "ccaf97cc3f1ea1da" + }, + { + "from": "locales/site/id.json", + "to": "modules/webmaker/locales/site/id.json", + "status": "imported", + "origHash": "9dc0512cc5d63255" + }, + { + "from": "locales/site/ja.json", + "to": "modules/webmaker/locales/site/ja.json", + "status": "imported", + "origHash": "e6ecb37062ec5a8b" + }, + { + "from": "locales/site/ko.json", + "to": "modules/webmaker/locales/site/ko.json", + "status": "imported", + "origHash": "ea80e5efdb464a7e" + }, + { + "from": "locales/site/mn.json", + "to": "modules/webmaker/locales/site/mn.json", + "status": "imported", + "origHash": "bf5ceec52790a369" + }, + { + "from": "locales/site/pt.json", + "to": "modules/webmaker/locales/site/pt.json", + "status": "imported", + "origHash": "6922cd08078716c5" + }, + { + "from": "locales/site/ru.json", + "to": "modules/webmaker/locales/site/ru.json", + "status": "imported", + "origHash": "9d850324dda56354" + }, + { + "from": "locales/site/th.json", + "to": "modules/webmaker/locales/site/th.json", + "status": "imported", + "origHash": "4f22ec5351f5c41d" + }, + { + "from": "locales/site/tr.json", + "to": "modules/webmaker/locales/site/tr.json", + "status": "imported", + "origHash": "597f9c5b952934ab" + }, + { + "from": "locales/site/vi.json", + "to": "modules/webmaker/locales/site/vi.json", + "status": "imported", + "origHash": "886013f8dfbd56fc" + }, + { + "from": "locales/site/zh-CN.json", + "to": "modules/webmaker/locales/site/zh-CN.json", + "status": "imported", + "origHash": "237e520720cb0166" + }, + { + "from": "locales/site/zh-TW.json", + "to": "modules/webmaker/locales/site/zh-TW.json", + "status": "imported", + "origHash": "a7982e11198a617d" + }, + { + "from": "modules/README.md", + "to": "modules/webmaker/modules/README.md", + "status": "imported", + "origHash": "f5d15ad9e5fb2d26" + }, + { + "from": "modules/blog/widget.html", + "to": "modules/webmaker/modules/blog/widget.html", + "status": "imported", + "origHash": "6a2e80e57038f69a" + }, + { + "from": "modules/board/widget.html", + "to": "modules/webmaker/modules/board/widget.html", + "status": "imported", + "origHash": "ac119ae767ce5aa5" + }, + { + "from": "modules/booking/widget.html", + "to": "modules/webmaker/modules/booking/widget.html", + "status": "imported", + "origHash": "40b6e4cefcf0e901" + }, + { + "from": "modules/calendar/widget.html", + "to": "modules/webmaker/modules/calendar/widget.html", + "status": "imported", + "origHash": "999a4f5558854ddf" + }, + { + "from": "modules/faq/widget.html", + "to": "modules/webmaker/modules/faq/widget.html", + "status": "imported", + "origHash": "791e54b1dfcb8448" + }, + { + "from": "modules/gallery/widget.html", + "to": "modules/webmaker/modules/gallery/widget.html", + "status": "imported", + "origHash": "70869995fd8fd4f7" + }, + { + "from": "modules/inquiry/widget.html", + "to": "modules/webmaker/modules/inquiry/widget.html", + "status": "imported", + "origHash": "b56c4807dd11a007" + }, + { + "from": "modules/library/widget.html", + "to": "modules/webmaker/modules/library/widget.html", + "status": "imported", + "origHash": "1f5ff8f6874a741a" + }, + { + "from": "modules/map/widget.html", + "to": "modules/webmaker/modules/map/widget.html", + "status": "imported", + "origHash": "0f68e9b3c48e4678" + }, + { + "from": "modules/members/widget.html", + "to": "modules/webmaker/modules/members/widget.html", + "status": "imported", + "origHash": "3a0c8409adda26db" + }, + { + "from": "modules/reviews/widget.html", + "to": "modules/webmaker/modules/reviews/widget.html", + "status": "imported", + "origHash": "de7ad783f7cad79a" + }, + { + "from": "modules/survey/widget.html", + "to": "modules/webmaker/modules/survey/widget.html", + "status": "imported", + "origHash": "62b2a3c2ee267b66" + }, + { + "from": "package.json", + "to": "modules/webmaker/package.json", + "status": "imported", + "origHash": "624dc8ee663ac2ba" + }, + { + "from": "public/404.html", + "to": "modules/webmaker/public/404.html", + "status": "imported", + "origHash": "22b18c443cbb8f50" + }, + { + "from": "public/admin.css", + "to": "modules/webmaker/public/admin.css", + "status": "imported", + "origHash": "b9b4a4cbaa9dc3ec" + }, + { + "from": "public/admin.html", + "to": "modules/webmaker/public/admin.html", + "status": "imported", + "origHash": "6853f78ac57ef360" + }, + { + "from": "public/admin.js", + "to": "modules/webmaker/public/admin.js", + "status": "imported", + "origHash": "fa5725fbe6356bbc" + }, + { + "from": "public/app.js", + "to": "modules/webmaker/public/app.js", + "status": "imported", + "origHash": "3baf113fbacdae72" + }, + { + "from": "public/credits-ui.js", + "to": "modules/webmaker/public/credits-ui.js", + "status": "imported", + "origHash": "44e43d5179d77578" + }, + { + "from": "public/domains-ui.js", + "to": "modules/webmaker/public/domains-ui.js", + "status": "imported", + "origHash": "ea77c164db309303" + }, + { + "from": "public/flow-ui.js", + "to": "modules/webmaker/public/flow-ui.js", + "status": "imported", + "origHash": "e9a92ab29b71840e" + }, + { + "from": "public/i18n.js", + "to": "modules/webmaker/public/i18n.js", + "status": "imported", + "origHash": "c6c4039435b77583" + }, + { + "from": "public/import.js", + "to": "modules/webmaker/public/import.js", + "status": "imported", + "origHash": "3f17de67ac16b9c4" + }, + { + "from": "public/index.html", + "to": "modules/webmaker/public/index.html", + "status": "imported", + "origHash": "bc33e85029da63b7" + }, + { + "from": "public/login.html", + "to": "modules/webmaker/public/login.html", + "status": "imported", + "origHash": "dd870dfc417adc65" + }, + { + "from": "public/manage-ui.js", + "to": "modules/webmaker/public/manage-ui.js", + "status": "imported", + "origHash": "c57d9d54dea3ab8d" + }, + { + "from": "public/modules-ui.js", + "to": "modules/webmaker/public/modules-ui.js", + "status": "imported", + "origHash": "5f4f54a0abe692c4" + }, + { + "from": "public/numfmt.js", + "to": "modules/webmaker/public/numfmt.js", + "status": "imported", + "origHash": "a11ef5768e547929" + }, + { + "from": "public/optimize.js", + "to": "modules/webmaker/public/optimize.js", + "status": "imported", + "origHash": "1dc20eb65f0db62f" + }, + { + "from": "public/pwrule.js", + "to": "modules/webmaker/public/pwrule.js", + "status": "imported", + "origHash": "9a1ea70939fa58ce" + }, + { + "from": "public/setup.html", + "to": "modules/webmaker/public/setup.html", + "status": "imported", + "origHash": "5c91302251e5cb7b" + }, + { + "from": "public/setup.js", + "to": "modules/webmaker/public/setup.js", + "status": "imported", + "origHash": "9027313fdabe3be0" + }, + { + "from": "public/site-admin.html", + "to": "modules/webmaker/public/site-admin.html", + "status": "imported", + "origHash": "d598563c7d617bbb" + }, + { + "from": "public/site-admin.js", + "to": "modules/webmaker/public/site-admin.js", + "status": "imported", + "origHash": "762da69b23f1db85" + }, + { + "from": "public/site-info.js", + "to": "modules/webmaker/public/site-info.js", + "status": "imported", + "origHash": "99060343aaca0ab3" + }, + { + "from": "public/style.css", + "to": "modules/webmaker/public/style.css", + "status": "imported", + "origHash": "041d7f216677b02d" + }, + { + "from": "public/templates-ui.js", + "to": "modules/webmaker/public/templates-ui.js", + "status": "imported", + "origHash": "6adc66d8e2204b1f" + }, + { + "from": "public/webmail.html", + "to": "modules/webmaker/public/webmail.html", + "status": "imported", + "origHash": "981fee72b5c72bed" + }, + { + "from": "public/webmail.js", + "to": "modules/webmaker/public/webmail.js", + "status": "imported", + "origHash": "6cb9a50f39c8d025" + }, + { + "from": "public/wizard.js", + "to": "modules/webmaker/public/wizard.js", + "status": "imported", + "origHash": "2ba97adfbb4f7cc1" + }, + { + "from": "public/wm-modules.js", + "to": "modules/webmaker/public/wm-modules.js", + "status": "imported", + "origHash": "efc7526ba17c338d" + }, + { + "from": "public/wm-stats.js", + "to": "modules/webmaker/public/wm-stats.js", + "status": "imported", + "origHash": "7e72375620ac125d" + }, + { + "from": "scripts/acme-dns-hook.js", + "to": "modules/webmaker/scripts/acme-dns-hook.js", + "status": "imported", + "origHash": "4401e102dde4c3ec" + }, + { + "from": "scripts/admin.js", + "to": "modules/webmaker/scripts/admin.js", + "status": "imported", + "origHash": "02adaf25dabad0ec" + }, + { + "from": "scripts/backup-data.js", + "to": "modules/webmaker/scripts/backup-data.js", + "status": "imported", + "origHash": "94b331c3e9555664" + }, + { + "from": "scripts/collect-backlog.js", + "to": "modules/webmaker/scripts/collect-backlog.js", + "status": "imported", + "origHash": "d5e4a0c3ca9bf7f0" + }, + { + "from": "scripts/config.js", + "to": "modules/webmaker/scripts/config.js", + "status": "imported", + "origHash": "d4b90ec541922219" + }, + { + "from": "scripts/i18n-extract.js", + "to": "modules/webmaker/scripts/i18n-extract.js", + "status": "imported", + "origHash": "7b0a2c7d76155b06" + }, + { + "from": "scripts/migrate-gitstore.sh", + "to": "modules/webmaker/scripts/migrate-gitstore.sh", + "status": "imported", + "origHash": "6b2fda206e3acb80" + }, + { + "from": "scripts/site-msg-catalog.py", + "to": "modules/webmaker/scripts/site-msg-catalog.py", + "status": "imported", + "origHash": "1f9b1ea8adbc174c" + }, + { + "from": "scripts/ssl-wildcard.js", + "to": "modules/webmaker/scripts/ssl-wildcard.js", + "status": "imported", + "origHash": "68cbebdb59a5f6d9" + }, + { + "from": "scripts/unit-check.js", + "to": "modules/webmaker/scripts/unit-check.js", + "status": "imported", + "origHash": "ba15624f696892b2" + }, + { + "from": "server.js", + "to": "modules/webmaker/server.js", + "status": "imported", + "origHash": "4585fd7fb07d3b4b" + }, + { + "from": "setup-server.js", + "to": "modules/webmaker/setup-server.js", + "status": "imported", + "origHash": "6545aab3185765a8" + }, + { + "from": "templates/contact-widget.html", + "to": "modules/webmaker/templates/contact-widget.html", + "status": "imported", + "origHash": "8063f64226c13914" + }, + { + "from": "test/dnszone-import.test.js", + "to": "modules/webmaker/test/dnszone-import.test.js", + "status": "imported", + "origHash": "33e1209656a7bf3f" + }, + { + "from": "test/domains-process.test.js", + "to": "modules/webmaker/test/domains-process.test.js", + "status": "imported", + "origHash": "57ff395fcf97fbab" + }, + { + "from": "test/lib.test.js", + "to": "modules/webmaker/test/lib.test.js", + "status": "imported", + "origHash": "d13ab84b5f8f8b0d" + }, + { + "from": "test/mailservers.test.js", + "to": "modules/webmaker/test/mailservers.test.js", + "status": "imported", + "origHash": "dfb80acca9782be0" + }, + { + "from": "test/registrar-route53.test.js", + "to": "modules/webmaker/test/registrar-route53.test.js", + "status": "imported", + "origHash": "84e9eba32ad5c7ef" + }, + { + "from": "test/server.test.js", + "to": "modules/webmaker/test/server.test.js", + "status": "imported", + "origHash": "b931228730a8bd70" + } + ] + }, + { + "name": "arimail", + "repo": "https://source.ibit.kr/OZ-Group/AriMail", + "commit": "a07f69f1ffb6667de6e66b3d1ec43c1c66fab924", + "dest": "modules/arimail", + "license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.", + "files": [ + { + "from": "README.md", + "to": "modules/arimail/README.md", + "status": "imported", + "origHash": "942037563d2ec90a" + }, + { + "from": "deploy/deploy.sh", + "to": "modules/arimail/deploy/deploy.sh", + "status": "imported", + "origHash": "cd7e1a5f26435839" + }, + { + "from": "deploy/domain-nginx.sh", + "to": "modules/arimail/deploy/domain-nginx.sh", + "status": "imported", + "origHash": "c8322d284a055a68" + }, + { + "from": "deploy/gateway.env.in", + "to": "modules/arimail/deploy/gateway.env.in", + "status": "imported", + "origHash": "3255005cd674a261" + }, + { + "from": "deploy/haraka-apply.sh", + "to": "modules/arimail/deploy/haraka-apply.sh", + "status": "imported", + "origHash": "4a5700051eef5a3e" + }, + { + "from": "deploy/letsencrypt-deploy-hook.sh.in", + "to": "modules/arimail/deploy/letsencrypt-deploy-hook.sh.in", + "status": "imported", + "origHash": "6d22293ac934c49f" + }, + { + "from": "deploy/lib.sh", + "to": "modules/arimail/deploy/lib.sh", + "status": "imported", + "origHash": "4726339b779386e2" + }, + { + "from": "deploy/new-site.sh", + "to": "modules/arimail/deploy/new-site.sh", + "status": "imported", + "origHash": "77048380fc977d44" + }, + { + "from": "deploy/nginx-apply.sh", + "to": "modules/arimail/deploy/nginx-apply.sh", + "status": "imported", + "origHash": "9709a481130916b8" + }, + { + "from": "deploy/nginx/mail-path.conf.in", + "to": "modules/arimail/deploy/nginx/mail-path.conf.in", + "status": "imported", + "origHash": "893c6a395cfa79fb" + }, + { + "from": "deploy/nginx/mail.cloudflare.conf.in", + "to": "modules/arimail/deploy/nginx/mail.cloudflare.conf.in", + "status": "imported", + "origHash": "a6bd91d9ec547aa4" + }, + { + "from": "deploy/nginx/mail.conf.in", + "to": "modules/arimail/deploy/nginx/mail.conf.in", + "status": "imported", + "origHash": "55b039d39b838ae9" + }, + { + "from": "deploy/nginx/mail.http-only.conf.in", + "to": "modules/arimail/deploy/nginx/mail.http-only.conf.in", + "status": "imported", + "origHash": "3f327fdaa7c2ce9d" + }, + { + "from": "deploy/setup.sh", + "to": "modules/arimail/deploy/setup.sh", + "status": "imported", + "origHash": "74fb725daf0b4481" + }, + { + "from": "deploy/site-mail-path.sh", + "to": "modules/arimail/deploy/site-mail-path.sh", + "status": "imported", + "origHash": "50b30b646233902f" + }, + { + "from": "deploy/systemd/mail-gateway.service.in", + "to": "modules/arimail/deploy/systemd/mail-gateway.service.in", + "status": "imported", + "origHash": "1a129db88217c13a" + }, + { + "from": "deploy/systemd/mail-haraka.service.in", + "to": "modules/arimail/deploy/systemd/mail-haraka.service.in", + "status": "imported", + "origHash": "9854efc3e9e6e877" + }, + { + "from": "deploy/systemd/mail-tls.service.in", + "to": "modules/arimail/deploy/systemd/mail-tls.service.in", + "status": "imported", + "origHash": "445add8875f3d99d" + }, + { + "from": "deploy/systemd/mail-tls.timer.in", + "to": "modules/arimail/deploy/systemd/mail-tls.timer.in", + "status": "imported", + "origHash": "9eebee444b7d22a8" + }, + { + "from": "deploy/tls-when-ready.sh", + "to": "modules/arimail/deploy/tls-when-ready.sh", + "status": "imported", + "origHash": "8add122491a9c3d5" + }, + { + "from": "deploy/webmaker-access.sh", + "to": "modules/arimail/deploy/webmaker-access.sh", + "status": "imported", + "origHash": "4390f154d7b48dc4" + }, + { + "from": "docs/SETUP.md", + "to": "modules/arimail/docs/SETUP.md", + "status": "imported", + "origHash": "1362935eee8bcfb9" + }, + { + "from": "docs/USAGE-API.md", + "to": "modules/arimail/docs/USAGE-API.md", + "status": "imported", + "origHash": "622b2346d12972cd" + }, + { + "from": "haraka/README.md", + "to": "modules/arimail/haraka/README.md", + "status": "imported", + "origHash": "d6ce6e90a1466334" + }, + { + "from": "haraka/package.json", + "to": "modules/arimail/haraka/package.json", + "status": "imported", + "origHash": "b29222d4d10a4246" + }, + { + "from": "haraka/plugins/arimail.js", + "to": "modules/arimail/haraka/plugins/arimail.js", + "status": "imported", + "origHash": "c33939420a33d72e" + }, + { + "from": "haraka/run.mjs", + "to": "modules/arimail/haraka/run.mjs", + "status": "imported", + "origHash": "c48fa74615815a7e" + }, + { + "from": "lang/ko.json", + "to": "modules/arimail/lang/ko.json", + "status": "imported", + "origHash": "5e6a8354e25662ff" + }, + { + "from": "migrations/0001_init.sql", + "to": "modules/arimail/migrations/0001_init.sql", + "status": "imported", + "origHash": "f6b1b10b05cdb85a" + }, + { + "from": "migrations/0002_imap.sql", + "to": "modules/arimail/migrations/0002_imap.sql", + "status": "imported", + "origHash": "3e234e2410a7e152" + }, + { + "from": "migrations/0003_accounts.sql", + "to": "modules/arimail/migrations/0003_accounts.sql", + "status": "imported", + "origHash": "00e8cf661fe62d5e" + }, + { + "from": "migrations/0004_no_retention.sql", + "to": "modules/arimail/migrations/0004_no_retention.sql", + "status": "imported", + "origHash": "6e45e554cb1af82d" + }, + { + "from": "migrations/0005_imap_uid_reassign.sql", + "to": "modules/arimail/migrations/0005_imap_uid_reassign.sql", + "status": "imported", + "origHash": "9d3d33c65e05b33d" + }, + { + "from": "migrations/0006_session_scope.sql", + "to": "modules/arimail/migrations/0006_session_scope.sql", + "status": "imported", + "origHash": "9277cff3a82c282b" + }, + { + "from": "migrations/0007_auto_actions.sql", + "to": "modules/arimail/migrations/0007_auto_actions.sql", + "status": "imported", + "origHash": "88c3c02ebf1f38fe" + }, + { + "from": "migrations/0008_large_files.sql", + "to": "modules/arimail/migrations/0008_large_files.sql", + "status": "imported", + "origHash": "a2d11409da0006b4" + }, + { + "from": "package.json", + "to": "modules/arimail/package.json", + "status": "imported", + "origHash": "f986ee70b4841c73" + }, + { + "from": "server/api.mjs", + "to": "modules/arimail/server/api.mjs", + "status": "imported", + "origHash": "b51ef607e4c0e65b" + }, + { + "from": "server/auth.mjs", + "to": "modules/arimail/server/auth.mjs", + "status": "imported", + "origHash": "e2d3c1906a46f0e1" + }, + { + "from": "server/automation.mjs", + "to": "modules/arimail/server/automation.mjs", + "status": "imported", + "origHash": "1f9a370fad319815" + }, + { + "from": "server/config.mjs", + "to": "modules/arimail/server/config.mjs", + "status": "imported", + "origHash": "9f5cc526198f988d" + }, + { + "from": "server/db-migrate.mjs", + "to": "modules/arimail/server/db-migrate.mjs", + "status": "imported", + "origHash": "cbc5540c4a43e134" + }, + { + "from": "server/db.mjs", + "to": "modules/arimail/server/db.mjs", + "status": "imported", + "origHash": "35f5d6c1fc4c8ff2" + }, + { + "from": "server/dns/common.mjs", + "to": "modules/arimail/server/dns/common.mjs", + "status": "imported", + "origHash": "c6ccc6ceeafa0bcf" + }, + { + "from": "server/dns/google.mjs", + "to": "modules/arimail/server/dns/google.mjs", + "status": "imported", + "origHash": "c825d4f04f7921c9" + }, + { + "from": "server/dns/index.mjs", + "to": "modules/arimail/server/dns/index.mjs", + "status": "imported", + "origHash": "e3605c56e29aeb58" + }, + { + "from": "server/dns/route53.mjs", + "to": "modules/arimail/server/dns/route53.mjs", + "status": "imported", + "origHash": "e31e330aa00af85f" + }, + { + "from": "server/domains.mjs", + "to": "modules/arimail/server/domains.mjs", + "status": "imported", + "origHash": "ee558ae6f234b27f" + }, + { + "from": "server/files.mjs", + "to": "modules/arimail/server/files.mjs", + "status": "imported", + "origHash": "0425698a1fe8652b" + }, + { + "from": "server/imap-mime.mjs", + "to": "modules/arimail/server/imap-mime.mjs", + "status": "imported", + "origHash": "ad5a4c60908a7847" + }, + { + "from": "server/imap.mjs", + "to": "modules/arimail/server/imap.mjs", + "status": "imported", + "origHash": "10ec4d7c53048df2" + }, + { + "from": "server/index.mjs", + "to": "modules/arimail/server/index.mjs", + "status": "imported", + "origHash": "3866bb3952efd9a8" + }, + { + "from": "server/limits.mjs", + "to": "modules/arimail/server/limits.mjs", + "status": "imported", + "origHash": "016c3aecef9a6da7" + }, + { + "from": "server/main.mjs", + "to": "modules/arimail/server/main.mjs", + "status": "imported", + "origHash": "90ad71bdc1bca7c0" + }, + { + "from": "server/mime.mjs", + "to": "modules/arimail/server/mime.mjs", + "status": "imported", + "origHash": "485abda8645820fe" + }, + { + "from": "server/pop3.mjs", + "to": "modules/arimail/server/pop3.mjs", + "status": "imported", + "origHash": "377dcf0370e5cf9e" + }, + { + "from": "server/providers/index.mjs", + "to": "modules/arimail/server/providers/index.mjs", + "status": "imported", + "origHash": "5774cb75b5b1c9d4" + }, + { + "from": "server/providers/resend.mjs", + "to": "modules/arimail/server/providers/resend.mjs", + "status": "imported", + "origHash": "2b964265e2d0eb8d" + }, + { + "from": "server/providers/smtp-client.mjs", + "to": "modules/arimail/server/providers/smtp-client.mjs", + "status": "imported", + "origHash": "308ef3b4dfc0b9e0" + }, + { + "from": "server/providers/smtp.mjs", + "to": "modules/arimail/server/providers/smtp.mjs", + "status": "imported", + "origHash": "e7b475fc79c65834" + }, + { + "from": "server/provision.mjs", + "to": "modules/arimail/server/provision.mjs", + "status": "imported", + "origHash": "d4c17584d1ef605d" + }, + { + "from": "server/resend.mjs", + "to": "modules/arimail/server/resend.mjs", + "status": "imported", + "origHash": "a6076fdcffd14f57" + }, + { + "from": "server/sanitize.mjs", + "to": "modules/arimail/server/sanitize.mjs", + "status": "imported", + "origHash": "d14fd6ffd01e75e0" + }, + { + "from": "server/setup.mjs", + "to": "modules/arimail/server/setup.mjs", + "status": "imported", + "origHash": "ece096ed19cbb679" + }, + { + "from": "server/smtp.mjs", + "to": "modules/arimail/server/smtp.mjs", + "status": "imported", + "origHash": "61e2e7187c636297" + }, + { + "from": "server/store.mjs", + "to": "modules/arimail/server/store.mjs", + "status": "imported", + "origHash": "a5741af42349a2dd" + }, + { + "from": "server/usage.mjs", + "to": "modules/arimail/server/usage.mjs", + "status": "imported", + "origHash": "29c97a4b5f627741" + }, + { + "from": "server/user-cli.mjs", + "to": "modules/arimail/server/user-cli.mjs", + "status": "imported", + "origHash": "ba64bccf7f3892dc" + }, + { + "from": "server/util.mjs", + "to": "modules/arimail/server/util.mjs", + "status": "imported", + "origHash": "6daf63506b35fb6c" + }, + { + "from": "server/webhook.mjs", + "to": "modules/arimail/server/webhook.mjs", + "status": "imported", + "origHash": "b3246cf3b830c4d8" + }, + { + "from": "server/webmaker-snapshot.mjs", + "to": "modules/arimail/server/webmaker-snapshot.mjs", + "status": "imported", + "origHash": "ef6962db91b5b09a" + }, + { + "from": "server/webmaker.mjs", + "to": "modules/arimail/server/webmaker.mjs", + "status": "imported", + "origHash": "6cde505d6949137e" + }, + { + "from": "test/_helpers.mjs", + "to": "modules/arimail/test/_helpers.mjs", + "status": "imported", + "origHash": "3c8cf13bfc4fee0a" + }, + { + "from": "test/config.test.mjs", + "to": "modules/arimail/test/config.test.mjs", + "status": "imported", + "origHash": "740db8ae5b3d105f" + }, + { + "from": "test/domains.test.mjs", + "to": "modules/arimail/test/domains.test.mjs", + "status": "imported", + "origHash": "81c77eadfdfba0ea" + }, + { + "from": "test/gateway.test.mjs", + "to": "modules/arimail/test/gateway.test.mjs", + "status": "imported", + "origHash": "fc5ef627262d950f" + }, + { + "from": "test/imap.test.mjs", + "to": "modules/arimail/test/imap.test.mjs", + "status": "imported", + "origHash": "d8387e1386302866" + }, + { + "from": "test/provision.test.mjs", + "to": "modules/arimail/test/provision.test.mjs", + "status": "imported", + "origHash": "58a66582f5d35655" + }, + { + "from": "test/smtp.test.mjs", + "to": "modules/arimail/test/smtp.test.mjs", + "status": "imported", + "origHash": "f92287ef6e7df130" + }, + { + "from": "test/usage.test.mjs", + "to": "modules/arimail/test/usage.test.mjs", + "status": "imported", + "origHash": "659271c6b54935b2" + }, + { + "from": "test/webmaker.test.mjs", + "to": "modules/arimail/test/webmaker.test.mjs", + "status": "imported", + "origHash": "2a6818a40b54cfee" + }, + { + "from": "web/app.js", + "to": "modules/arimail/web/app.js", + "status": "imported", + "origHash": "cbfb4a748cc100f4" + }, + { + "from": "web/favicon.svg", + "to": "modules/arimail/web/favicon.svg", + "status": "imported", + "origHash": "0c44f177404b4090" + }, + { + "from": "web/index.html", + "to": "modules/arimail/web/index.html", + "status": "imported", + "origHash": "f7935713b9d37b8d" + }, + { + "from": "web/setup.css", + "to": "modules/arimail/web/setup.css", + "status": "imported", + "origHash": "ebf376321f3f9074" + }, + { + "from": "web/setup.html", + "to": "modules/arimail/web/setup.html", + "status": "imported", + "origHash": "b07043a0c5b952ad" + }, + { + "from": "web/setup.js", + "to": "modules/arimail/web/setup.js", + "status": "imported", + "origHash": "24f72af0d740a559" + }, + { + "from": "web/style.css", + "to": "modules/arimail/web/style.css", + "status": "imported", + "origHash": "b63d6c779186c363" + } + ] + } + ] +} \ No newline at end of file diff --git a/IMPORTED_SOURCES.md b/IMPORTED_SOURCES.md new file mode 100644 index 0000000..0be3d18 --- /dev/null +++ b/IMPORTED_SOURCES.md @@ -0,0 +1,290 @@ +# 도입 소스 기록(IMPORTED_SOURCES) + +생성: 2026-10-26T10:58:20.537Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다. + +## webmaker + +- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent +- 고정 커밋: `3b10859884ef2fd0f2af11934ab6c16370fd64b8` +- 대상 경로: `modules/webmaker/` +- 파일 수: 176 (수정 0) +- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. + +| 원본 경로 | 대상 경로 | 수정 | 원본 해시 | +|---|---|---|---| +| README.md | modules/webmaker/README.md | 그대로 | a7aa1eb7a6fdae8d | +| deploy/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 | +| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 | +| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 | +| deploy/install-gitea.sh | modules/webmaker/deploy/install-gitea.sh | 그대로 | 199631e74ea4f62c | +| deploy/install-services.sh | modules/webmaker/deploy/install-services.sh | 그대로 | f3b507512b501fd8 | +| deploy/install.sh | modules/webmaker/deploy/install.sh | 그대로 | 58ba0c250620b638 | +| deploy/nginx-webhosting.conf | modules/webmaker/deploy/nginx-webhosting.conf | 그대로 | 0058f1dafd622bc5 | +| deploy/nginx-wmstats.conf | modules/webmaker/deploy/nginx-wmstats.conf | 그대로 | cf4069704f65ef73 | +| deploy/remote-install.sh | modules/webmaker/deploy/remote-install.sh | 그대로 | 66457327d0ef1b3c | +| deploy/remote-update.sh | modules/webmaker/deploy/remote-update.sh | 그대로 | fb0cf663fc687542 | +| deploy/webhosting-agent.service | modules/webmaker/deploy/webhosting-agent.service | 그대로 | 77259a206ca5530b | +| deploy/webmaker-setup.service | modules/webmaker/deploy/webmaker-setup.service | 그대로 | 9bbcbb9239d24ef9 | +| docs/ARIMAIL.md | modules/webmaker/docs/ARIMAIL.md | 그대로 | 1e326aa2252621cf | +| docs/CALENDAR.md | modules/webmaker/docs/CALENDAR.md | 그대로 | 1d12cd1720897254 | +| docs/CONFIG.md | modules/webmaker/docs/CONFIG.md | 그대로 | 13c26d45b5002b48 | +| docs/DOMAIN-DNS-PROVIDERS.md | modules/webmaker/docs/DOMAIN-DNS-PROVIDERS.md | 그대로 | c5ee35c850c7c728 | +| docs/DOMAIN-SETUP.md | modules/webmaker/docs/DOMAIN-SETUP.md | 그대로 | 83f0e447cd82e3dd | +| docs/FIRST-INSTALL.md | modules/webmaker/docs/FIRST-INSTALL.md | 그대로 | 642ce20eb96cabb9 | +| docs/GITEA-LOCAL.md | modules/webmaker/docs/GITEA-LOCAL.md | 그대로 | 4d7462b52ef34783 | +| docs/SSL-WILDCARD.md | modules/webmaker/docs/SSL-WILDCARD.md | 그대로 | d93102519cb39333 | +| hooks/askpass.sh | modules/webmaker/hooks/askpass.sh | 그대로 | c86b42091d07748d | +| hooks/guard.js | modules/webmaker/hooks/guard.js | 그대로 | 4804e4cd337db53c | +| hooks/guard.sh | modules/webmaker/hooks/guard.sh | 그대로 | 29df99b89bc31912 | +| hooks/settings.json | modules/webmaker/hooks/settings.json | 그대로 | 30ebe47478166d24 | +| lib/audit.js | modules/webmaker/lib/audit.js | 그대로 | 5a0b6a90e3281d2f | +| lib/auth.js | modules/webmaker/lib/auth.js | 그대로 | 84e3fcda15e46b49 | +| lib/awsv4.js | modules/webmaker/lib/awsv4.js | 그대로 | 8a4bdab7d701c4ec | +| lib/billing.js | modules/webmaker/lib/billing.js | 그대로 | 4e3f88fd1f8ebae1 | +| lib/brief.js | modules/webmaker/lib/brief.js | 그대로 | 9f37eb2e30c3e710 | +| lib/calendar.js | modules/webmaker/lib/calendar.js | 그대로 | c5ee8d9c2777daac | +| lib/claude.js | modules/webmaker/lib/claude.js | 그대로 | dcdebeeeb1d8ea99 | +| lib/config.js | modules/webmaker/lib/config.js | 그대로 | aa577cf668b504e7 | +| lib/connectors/arimail.js | modules/webmaker/lib/connectors/arimail.js | 그대로 | f4dea3dc448c155e | +| lib/connectors/gitea.js | modules/webmaker/lib/connectors/gitea.js | 그대로 | e736d764b2cdac6a | +| lib/connectors/index.js | modules/webmaker/lib/connectors/index.js | 그대로 | 1dc6f6a8290b7a7f | +| lib/costcal.js | modules/webmaker/lib/costcal.js | 그대로 | 22b008dccc3318db | +| lib/crawler.js | modules/webmaker/lib/crawler.js | 그대로 | 392e0fd1e9c88a26 | +| lib/credits.js | modules/webmaker/lib/credits.js | 그대로 | 59caba266ed2bcda | +| lib/deploy.js | modules/webmaker/lib/deploy.js | 그대로 | ddc85c8850a243a6 | +| lib/dns/gcloud.js | modules/webmaker/lib/dns/gcloud.js | 그대로 | e006e6ff4cde77c5 | +| lib/dns/openprovider.js | modules/webmaker/lib/dns/openprovider.js | 그대로 | ae8009bedd485e50 | +| lib/dns/porkbun.js | modules/webmaker/lib/dns/porkbun.js | 그대로 | 9f779244aeddc392 | +| lib/dns/route53.js | modules/webmaker/lib/dns/route53.js | 그대로 | b409db36696b9d5e | +| lib/dnszone.js | modules/webmaker/lib/dnszone.js | 그대로 | 62f74f0772fdc4d4 | +| lib/domains.js | modules/webmaker/lib/domains.js | 그대로 | cee7a767487f9a41 | +| lib/engine-codex.js | modules/webmaker/lib/engine-codex.js | 그대로 | a074e8a4b9867d6d | +| lib/engine-openai.js | modules/webmaker/lib/engine-openai.js | 그대로 | 93e4701e165d032a | +| lib/feedback.js | modules/webmaker/lib/feedback.js | 그대로 | c5860a676dd6118a | +| lib/forms.js | modules/webmaker/lib/forms.js | 그대로 | e2f1b1bb21e5bbdd | +| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 | +| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | d4650fab7b85f9b6 | +| lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc | +| lib/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 | +| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 | +| lib/jsonstore.js | modules/webmaker/lib/jsonstore.js | 그대로 | 20a9ee6f5acb22e7 | +| lib/logview.js | modules/webmaker/lib/logview.js | 그대로 | 0262fdf9042301f5 | +| lib/lunar.js | modules/webmaker/lib/lunar.js | 그대로 | fc858ad1b9166b8c | +| lib/mailer.js | modules/webmaker/lib/mailer.js | 그대로 | 7f10112c31ca2d7b | +| lib/mailservers.js | modules/webmaker/lib/mailservers.js | 그대로 | 8608b0f16a0ea9b1 | +| lib/mailsys.js | modules/webmaker/lib/mailsys.js | 그대로 | 8f2e419e859f62ea | +| lib/mailusage.js | modules/webmaker/lib/mailusage.js | 그대로 | a3b608fc766ebe06 | +| lib/maintpage.js | modules/webmaker/lib/maintpage.js | 그대로 | f57501de5a9349d8 | +| lib/modules.js | modules/webmaker/lib/modules.js | 그대로 | b0f8b6df731d180e | +| lib/multilang.js | modules/webmaker/lib/multilang.js | 그대로 | 30eaec7a02d17836 | +| lib/netguard.js | modules/webmaker/lib/netguard.js | 그대로 | 7f3f4a1a908b2371 | +| lib/openprovider.js | modules/webmaker/lib/openprovider.js | 그대로 | aa0ff8df9e04b482 | +| lib/passpolicy.js | modules/webmaker/lib/passpolicy.js | 그대로 | 96c30ce7d9f87797 | +| lib/planner.js | modules/webmaker/lib/planner.js | 그대로 | 55fe3f3597bd9278 | +| lib/prepaid.js | modules/webmaker/lib/prepaid.js | 그대로 | f1078822b25d5325 | +| lib/prompt.js | modules/webmaker/lib/prompt.js | 그대로 | 3279a47d1bb1e078 | +| lib/providers.js | modules/webmaker/lib/providers.js | 그대로 | bf01ac2ac60c40d1 | +| lib/ratelimit.js | modules/webmaker/lib/ratelimit.js | 그대로 | 5abfa063c0f78873 | +| lib/recovery.js | modules/webmaker/lib/recovery.js | 그대로 | 42272b277598a7fb | +| lib/refs.js | modules/webmaker/lib/refs.js | 그대로 | 1c530a8241d363dc | +| lib/registrar.js | modules/webmaker/lib/registrar.js | 그대로 | de6c606dcc87d665 | +| lib/registrars/openprovider.js | modules/webmaker/lib/registrars/openprovider.js | 그대로 | 04697456df4cc68e | +| lib/registrars/porkbun.js | modules/webmaker/lib/registrars/porkbun.js | 그대로 | 6bb857595564dcf6 | +| lib/registrars/route53.js | modules/webmaker/lib/registrars/route53.js | 그대로 | b60f17fefad37a4f | +| lib/remote.js | modules/webmaker/lib/remote.js | 그대로 | 965366d84a6ee859 | +| lib/report.js | modules/webmaker/lib/report.js | 그대로 | 3c14814e9a8f424f | +| lib/run.js | modules/webmaker/lib/run.js | 그대로 | aad85b1616605fc3 | +| lib/seo.js | modules/webmaker/lib/seo.js | 그대로 | fabcb8b18469ec10 | +| lib/settings.js | modules/webmaker/lib/settings.js | 그대로 | 98abbde31bbef9ce | +| lib/setupapi.js | modules/webmaker/lib/setupapi.js | 그대로 | 3fd30ebd9efa47a6 | +| lib/setupauth.js | modules/webmaker/lib/setupauth.js | 그대로 | 8e6e1981448bc343 | +| lib/setupstate.js | modules/webmaker/lib/setupstate.js | 그대로 | 5de040b7a5a88f6c | +| lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 | +| lib/ssl.js | modules/webmaker/lib/ssl.js | 그대로 | f7a5bb79900d8bdd | +| lib/stats.js | modules/webmaker/lib/stats.js | 그대로 | fc2de57d4be0960a | +| lib/store.js | modules/webmaker/lib/store.js | 그대로 | 8132dcde60fc5789 | +| lib/templates.js | modules/webmaker/lib/templates.js | 그대로 | bb44e537d0b28b16 | +| lib/uploads.js | modules/webmaker/lib/uploads.js | 그대로 | 5441ea45ed434575 | +| lib/vault.js | modules/webmaker/lib/vault.js | 그대로 | 0730f701278d33a1 | +| lib/xlsx.js | modules/webmaker/lib/xlsx.js | 그대로 | dc353dd3b5fd6e10 | +| locales/ko.json | modules/webmaker/locales/ko.json | 그대로 | 20abf531ef8320f6 | +| locales/site/ar.json | modules/webmaker/locales/site/ar.json | 그대로 | 8644e815887e9bb1 | +| locales/site/de.json | modules/webmaker/locales/site/de.json | 그대로 | 60a7b71fd70ae371 | +| locales/site/en.json | modules/webmaker/locales/site/en.json | 그대로 | 0939d2e2eedf1af9 | +| locales/site/es.json | modules/webmaker/locales/site/es.json | 그대로 | 8ba29c21cf2b90fb | +| locales/site/fa.json | modules/webmaker/locales/site/fa.json | 그대로 | 4c2b4917b1639f87 | +| locales/site/fr.json | modules/webmaker/locales/site/fr.json | 그대로 | 55e2e55db288f856 | +| locales/site/he.json | modules/webmaker/locales/site/he.json | 그대로 | ccaf97cc3f1ea1da | +| locales/site/id.json | modules/webmaker/locales/site/id.json | 그대로 | 9dc0512cc5d63255 | +| locales/site/ja.json | modules/webmaker/locales/site/ja.json | 그대로 | e6ecb37062ec5a8b | +| locales/site/ko.json | modules/webmaker/locales/site/ko.json | 그대로 | ea80e5efdb464a7e | +| locales/site/mn.json | modules/webmaker/locales/site/mn.json | 그대로 | bf5ceec52790a369 | +| locales/site/pt.json | modules/webmaker/locales/site/pt.json | 그대로 | 6922cd08078716c5 | +| locales/site/ru.json | modules/webmaker/locales/site/ru.json | 그대로 | 9d850324dda56354 | +| locales/site/th.json | modules/webmaker/locales/site/th.json | 그대로 | 4f22ec5351f5c41d | +| locales/site/tr.json | modules/webmaker/locales/site/tr.json | 그대로 | 597f9c5b952934ab | +| locales/site/vi.json | modules/webmaker/locales/site/vi.json | 그대로 | 886013f8dfbd56fc | +| locales/site/zh-CN.json | modules/webmaker/locales/site/zh-CN.json | 그대로 | 237e520720cb0166 | +| locales/site/zh-TW.json | modules/webmaker/locales/site/zh-TW.json | 그대로 | a7982e11198a617d | +| modules/README.md | modules/webmaker/modules/README.md | 그대로 | f5d15ad9e5fb2d26 | +| modules/blog/widget.html | modules/webmaker/modules/blog/widget.html | 그대로 | 6a2e80e57038f69a | +| modules/board/widget.html | modules/webmaker/modules/board/widget.html | 그대로 | ac119ae767ce5aa5 | +| modules/booking/widget.html | modules/webmaker/modules/booking/widget.html | 그대로 | 40b6e4cefcf0e901 | +| modules/calendar/widget.html | modules/webmaker/modules/calendar/widget.html | 그대로 | 999a4f5558854ddf | +| modules/faq/widget.html | modules/webmaker/modules/faq/widget.html | 그대로 | 791e54b1dfcb8448 | +| modules/gallery/widget.html | modules/webmaker/modules/gallery/widget.html | 그대로 | 70869995fd8fd4f7 | +| modules/inquiry/widget.html | modules/webmaker/modules/inquiry/widget.html | 그대로 | b56c4807dd11a007 | +| modules/library/widget.html | modules/webmaker/modules/library/widget.html | 그대로 | 1f5ff8f6874a741a | +| modules/map/widget.html | modules/webmaker/modules/map/widget.html | 그대로 | 0f68e9b3c48e4678 | +| modules/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db | +| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a | +| modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 | +| package.json | modules/webmaker/package.json | 그대로 | 624dc8ee663ac2ba | +| public/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 | +| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec | +| public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 | +| public/admin.js | modules/webmaker/public/admin.js | 그대로 | fa5725fbe6356bbc | +| public/app.js | modules/webmaker/public/app.js | 그대로 | 3baf113fbacdae72 | +| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 | +| public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 | +| public/flow-ui.js | modules/webmaker/public/flow-ui.js | 그대로 | e9a92ab29b71840e | +| public/i18n.js | modules/webmaker/public/i18n.js | 그대로 | c6c4039435b77583 | +| public/import.js | modules/webmaker/public/import.js | 그대로 | 3f17de67ac16b9c4 | +| public/index.html | modules/webmaker/public/index.html | 그대로 | bc33e85029da63b7 | +| public/login.html | modules/webmaker/public/login.html | 그대로 | dd870dfc417adc65 | +| public/manage-ui.js | modules/webmaker/public/manage-ui.js | 그대로 | c57d9d54dea3ab8d | +| public/modules-ui.js | modules/webmaker/public/modules-ui.js | 그대로 | 5f4f54a0abe692c4 | +| public/numfmt.js | modules/webmaker/public/numfmt.js | 그대로 | a11ef5768e547929 | +| public/optimize.js | modules/webmaker/public/optimize.js | 그대로 | 1dc20eb65f0db62f | +| public/pwrule.js | modules/webmaker/public/pwrule.js | 그대로 | 9a1ea70939fa58ce | +| public/setup.html | modules/webmaker/public/setup.html | 그대로 | 5c91302251e5cb7b | +| public/setup.js | modules/webmaker/public/setup.js | 그대로 | 9027313fdabe3be0 | +| public/site-admin.html | modules/webmaker/public/site-admin.html | 그대로 | d598563c7d617bbb | +| public/site-admin.js | modules/webmaker/public/site-admin.js | 그대로 | 762da69b23f1db85 | +| public/site-info.js | modules/webmaker/public/site-info.js | 그대로 | 99060343aaca0ab3 | +| public/style.css | modules/webmaker/public/style.css | 그대로 | 041d7f216677b02d | +| public/templates-ui.js | modules/webmaker/public/templates-ui.js | 그대로 | 6adc66d8e2204b1f | +| public/webmail.html | modules/webmaker/public/webmail.html | 그대로 | 981fee72b5c72bed | +| public/webmail.js | modules/webmaker/public/webmail.js | 그대로 | 6cb9a50f39c8d025 | +| public/wizard.js | modules/webmaker/public/wizard.js | 그대로 | 2ba97adfbb4f7cc1 | +| public/wm-modules.js | modules/webmaker/public/wm-modules.js | 그대로 | efc7526ba17c338d | +| public/wm-stats.js | modules/webmaker/public/wm-stats.js | 그대로 | 7e72375620ac125d | +| scripts/acme-dns-hook.js | modules/webmaker/scripts/acme-dns-hook.js | 그대로 | 4401e102dde4c3ec | +| scripts/admin.js | modules/webmaker/scripts/admin.js | 그대로 | 02adaf25dabad0ec | +| scripts/backup-data.js | modules/webmaker/scripts/backup-data.js | 그대로 | 94b331c3e9555664 | +| scripts/collect-backlog.js | modules/webmaker/scripts/collect-backlog.js | 그대로 | d5e4a0c3ca9bf7f0 | +| scripts/config.js | modules/webmaker/scripts/config.js | 그대로 | d4b90ec541922219 | +| scripts/i18n-extract.js | modules/webmaker/scripts/i18n-extract.js | 그대로 | 7b0a2c7d76155b06 | +| scripts/migrate-gitstore.sh | modules/webmaker/scripts/migrate-gitstore.sh | 그대로 | 6b2fda206e3acb80 | +| scripts/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c | +| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 | +| scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 | +| server.js | modules/webmaker/server.js | 그대로 | 4585fd7fb07d3b4b | +| setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 | +| templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 | +| test/dnszone-import.test.js | modules/webmaker/test/dnszone-import.test.js | 그대로 | 33e1209656a7bf3f | +| test/domains-process.test.js | modules/webmaker/test/domains-process.test.js | 그대로 | 57ff395fcf97fbab | +| test/lib.test.js | modules/webmaker/test/lib.test.js | 그대로 | d13ab84b5f8f8b0d | +| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 | +| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef | +| test/server.test.js | modules/webmaker/test/server.test.js | 그대로 | b931228730a8bd70 | + +## arimail + +- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail +- 고정 커밋: `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924` +- 대상 경로: `modules/arimail/` +- 파일 수: 88 (수정 0) +- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함. + +| 원본 경로 | 대상 경로 | 수정 | 원본 해시 | +|---|---|---|---| +| README.md | modules/arimail/README.md | 그대로 | 942037563d2ec90a | +| deploy/deploy.sh | modules/arimail/deploy/deploy.sh | 그대로 | cd7e1a5f26435839 | +| deploy/domain-nginx.sh | modules/arimail/deploy/domain-nginx.sh | 그대로 | c8322d284a055a68 | +| deploy/gateway.env.in | modules/arimail/deploy/gateway.env.in | 그대로 | 3255005cd674a261 | +| deploy/haraka-apply.sh | modules/arimail/deploy/haraka-apply.sh | 그대로 | 4a5700051eef5a3e | +| deploy/letsencrypt-deploy-hook.sh.in | modules/arimail/deploy/letsencrypt-deploy-hook.sh.in | 그대로 | 6d22293ac934c49f | +| deploy/lib.sh | modules/arimail/deploy/lib.sh | 그대로 | 4726339b779386e2 | +| deploy/new-site.sh | modules/arimail/deploy/new-site.sh | 그대로 | 77048380fc977d44 | +| deploy/nginx-apply.sh | modules/arimail/deploy/nginx-apply.sh | 그대로 | 9709a481130916b8 | +| deploy/nginx/mail-path.conf.in | modules/arimail/deploy/nginx/mail-path.conf.in | 그대로 | 893c6a395cfa79fb | +| deploy/nginx/mail.cloudflare.conf.in | modules/arimail/deploy/nginx/mail.cloudflare.conf.in | 그대로 | a6bd91d9ec547aa4 | +| deploy/nginx/mail.conf.in | modules/arimail/deploy/nginx/mail.conf.in | 그대로 | 55b039d39b838ae9 | +| deploy/nginx/mail.http-only.conf.in | modules/arimail/deploy/nginx/mail.http-only.conf.in | 그대로 | 3f327fdaa7c2ce9d | +| deploy/setup.sh | modules/arimail/deploy/setup.sh | 그대로 | 74fb725daf0b4481 | +| deploy/site-mail-path.sh | modules/arimail/deploy/site-mail-path.sh | 그대로 | 50b30b646233902f | +| deploy/systemd/mail-gateway.service.in | modules/arimail/deploy/systemd/mail-gateway.service.in | 그대로 | 1a129db88217c13a | +| deploy/systemd/mail-haraka.service.in | modules/arimail/deploy/systemd/mail-haraka.service.in | 그대로 | 9854efc3e9e6e877 | +| deploy/systemd/mail-tls.service.in | modules/arimail/deploy/systemd/mail-tls.service.in | 그대로 | 445add8875f3d99d | +| deploy/systemd/mail-tls.timer.in | modules/arimail/deploy/systemd/mail-tls.timer.in | 그대로 | 9eebee444b7d22a8 | +| deploy/tls-when-ready.sh | modules/arimail/deploy/tls-when-ready.sh | 그대로 | 8add122491a9c3d5 | +| deploy/webmaker-access.sh | modules/arimail/deploy/webmaker-access.sh | 그대로 | 4390f154d7b48dc4 | +| docs/SETUP.md | modules/arimail/docs/SETUP.md | 그대로 | 1362935eee8bcfb9 | +| docs/USAGE-API.md | modules/arimail/docs/USAGE-API.md | 그대로 | 622b2346d12972cd | +| haraka/README.md | modules/arimail/haraka/README.md | 그대로 | d6ce6e90a1466334 | +| haraka/package.json | modules/arimail/haraka/package.json | 그대로 | b29222d4d10a4246 | +| haraka/plugins/arimail.js | modules/arimail/haraka/plugins/arimail.js | 그대로 | c33939420a33d72e | +| haraka/run.mjs | modules/arimail/haraka/run.mjs | 그대로 | c48fa74615815a7e | +| lang/ko.json | modules/arimail/lang/ko.json | 그대로 | 5e6a8354e25662ff | +| migrations/0001_init.sql | modules/arimail/migrations/0001_init.sql | 그대로 | f6b1b10b05cdb85a | +| migrations/0002_imap.sql | modules/arimail/migrations/0002_imap.sql | 그대로 | 3e234e2410a7e152 | +| migrations/0003_accounts.sql | modules/arimail/migrations/0003_accounts.sql | 그대로 | 00e8cf661fe62d5e | +| migrations/0004_no_retention.sql | modules/arimail/migrations/0004_no_retention.sql | 그대로 | 6e45e554cb1af82d | +| migrations/0005_imap_uid_reassign.sql | modules/arimail/migrations/0005_imap_uid_reassign.sql | 그대로 | 9d3d33c65e05b33d | +| migrations/0006_session_scope.sql | modules/arimail/migrations/0006_session_scope.sql | 그대로 | 9277cff3a82c282b | +| migrations/0007_auto_actions.sql | modules/arimail/migrations/0007_auto_actions.sql | 그대로 | 88c3c02ebf1f38fe | +| migrations/0008_large_files.sql | modules/arimail/migrations/0008_large_files.sql | 그대로 | a2d11409da0006b4 | +| package.json | modules/arimail/package.json | 그대로 | f986ee70b4841c73 | +| server/api.mjs | modules/arimail/server/api.mjs | 그대로 | b51ef607e4c0e65b | +| server/auth.mjs | modules/arimail/server/auth.mjs | 그대로 | e2d3c1906a46f0e1 | +| server/automation.mjs | modules/arimail/server/automation.mjs | 그대로 | 1f9a370fad319815 | +| server/config.mjs | modules/arimail/server/config.mjs | 그대로 | 9f5cc526198f988d | +| server/db-migrate.mjs | modules/arimail/server/db-migrate.mjs | 그대로 | cbc5540c4a43e134 | +| server/db.mjs | modules/arimail/server/db.mjs | 그대로 | 35f5d6c1fc4c8ff2 | +| server/dns/common.mjs | modules/arimail/server/dns/common.mjs | 그대로 | c6ccc6ceeafa0bcf | +| server/dns/google.mjs | modules/arimail/server/dns/google.mjs | 그대로 | c825d4f04f7921c9 | +| server/dns/index.mjs | modules/arimail/server/dns/index.mjs | 그대로 | e3605c56e29aeb58 | +| server/dns/route53.mjs | modules/arimail/server/dns/route53.mjs | 그대로 | e31e330aa00af85f | +| server/domains.mjs | modules/arimail/server/domains.mjs | 그대로 | ee558ae6f234b27f | +| server/files.mjs | modules/arimail/server/files.mjs | 그대로 | 0425698a1fe8652b | +| server/imap-mime.mjs | modules/arimail/server/imap-mime.mjs | 그대로 | ad5a4c60908a7847 | +| server/imap.mjs | modules/arimail/server/imap.mjs | 그대로 | 10ec4d7c53048df2 | +| server/index.mjs | modules/arimail/server/index.mjs | 그대로 | 3866bb3952efd9a8 | +| server/limits.mjs | modules/arimail/server/limits.mjs | 그대로 | 016c3aecef9a6da7 | +| server/main.mjs | modules/arimail/server/main.mjs | 그대로 | 90ad71bdc1bca7c0 | +| server/mime.mjs | modules/arimail/server/mime.mjs | 그대로 | 485abda8645820fe | +| server/pop3.mjs | modules/arimail/server/pop3.mjs | 그대로 | 377dcf0370e5cf9e | +| server/providers/index.mjs | modules/arimail/server/providers/index.mjs | 그대로 | 5774cb75b5b1c9d4 | +| server/providers/resend.mjs | modules/arimail/server/providers/resend.mjs | 그대로 | 2b964265e2d0eb8d | +| server/providers/smtp-client.mjs | modules/arimail/server/providers/smtp-client.mjs | 그대로 | 308ef3b4dfc0b9e0 | +| server/providers/smtp.mjs | modules/arimail/server/providers/smtp.mjs | 그대로 | e7b475fc79c65834 | +| server/provision.mjs | modules/arimail/server/provision.mjs | 그대로 | d4c17584d1ef605d | +| server/resend.mjs | modules/arimail/server/resend.mjs | 그대로 | a6076fdcffd14f57 | +| server/sanitize.mjs | modules/arimail/server/sanitize.mjs | 그대로 | d14fd6ffd01e75e0 | +| server/setup.mjs | modules/arimail/server/setup.mjs | 그대로 | ece096ed19cbb679 | +| server/smtp.mjs | modules/arimail/server/smtp.mjs | 그대로 | 61e2e7187c636297 | +| server/store.mjs | modules/arimail/server/store.mjs | 그대로 | a5741af42349a2dd | +| server/usage.mjs | modules/arimail/server/usage.mjs | 그대로 | 29c97a4b5f627741 | +| server/user-cli.mjs | modules/arimail/server/user-cli.mjs | 그대로 | ba64bccf7f3892dc | +| server/util.mjs | modules/arimail/server/util.mjs | 그대로 | 6daf63506b35fb6c | +| server/webhook.mjs | modules/arimail/server/webhook.mjs | 그대로 | b3246cf3b830c4d8 | +| server/webmaker-snapshot.mjs | modules/arimail/server/webmaker-snapshot.mjs | 그대로 | ef6962db91b5b09a | +| server/webmaker.mjs | modules/arimail/server/webmaker.mjs | 그대로 | 6cde505d6949137e | +| test/_helpers.mjs | modules/arimail/test/_helpers.mjs | 그대로 | 3c8cf13bfc4fee0a | +| test/config.test.mjs | modules/arimail/test/config.test.mjs | 그대로 | 740db8ae5b3d105f | +| test/domains.test.mjs | modules/arimail/test/domains.test.mjs | 그대로 | 81c77eadfdfba0ea | +| test/gateway.test.mjs | modules/arimail/test/gateway.test.mjs | 그대로 | fc5ef627262d950f | +| test/imap.test.mjs | modules/arimail/test/imap.test.mjs | 그대로 | d8387e1386302866 | +| test/provision.test.mjs | modules/arimail/test/provision.test.mjs | 그대로 | 58a66582f5d35655 | +| test/smtp.test.mjs | modules/arimail/test/smtp.test.mjs | 그대로 | f92287ef6e7df130 | +| test/usage.test.mjs | modules/arimail/test/usage.test.mjs | 그대로 | 659271c6b54935b2 | +| test/webmaker.test.mjs | modules/arimail/test/webmaker.test.mjs | 그대로 | 2a6818a40b54cfee | +| web/app.js | modules/arimail/web/app.js | 그대로 | cbfb4a748cc100f4 | +| web/favicon.svg | modules/arimail/web/favicon.svg | 그대로 | 0c44f177404b4090 | +| web/index.html | modules/arimail/web/index.html | 그대로 | f7935713b9d37b8d | +| web/setup.css | modules/arimail/web/setup.css | 그대로 | ebf376321f3f9074 | +| web/setup.html | modules/arimail/web/setup.html | 그대로 | b07043a0c5b952ad | +| web/setup.js | modules/arimail/web/setup.js | 그대로 | 24f72af0d740a559 | +| web/style.css | modules/arimail/web/style.css | 그대로 | b63d6c779186c363 | + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a1aa76e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,121 @@ +# Ariweb Suite v0.1.1 — 아키텍처와 개발 규약 + +이 문서는 Ariweb Suite 의 모든 구성요소가 따르는 공통 규약이다. 하위 시스템을 나누어 개발할 때 이 문서가 인터페이스 계약이다. +개발 지시 원문은 `Ariweb-Suitle_Dev-Order_v0.1.1.md` 이며, 절 번호(예: §13.3)는 그 문서를 가리킨다. + +## 1. 구성요소와 프로세스 + +| 프로세스 | 진입점 | 포트(127.0.0.1) | 역할 | +|---|---|---|---| +| `ariweb-hal` | `hal/index.mjs` | 8900 | HAL 제어 평면: 릴리스 등록부, 서버·Node Agent 등록부, 내구성 작업 엔진, 모듈 배포·갱신·되돌리기, 운영자 콘솔(`hal.ariweb.net/ops`), 공개 상태 페이지(`/status`), 긴급 복구, 플랫폼 재해복구 | +| `ariweb-agent` | `agent/index.mjs` | (없음, HAL 에 long-poll) | 각 관리 서버의 Node Agent: 일회용 등록 토큰으로 HAL 에 등록, heartbeat·지표(CPU·메모리·디스크·inode·I/O·네트워크·시계 차이), HAL 이 내린 작업(모듈 설치·갱신·되돌리기·건강 확인·사이트 배포·백업·복원·이전) 실행. 외부 고객 서버에서는 **External Pull Agent** 모드(사이트 배포만) | +| `ariweb-portal` | `portal/index.mjs` | 8910 | 고객 포털(`my.ariweb.net`): 회원·사용자 그룹·상품·주문·사이트·배포·도메인·메일·백업·복원·이전·소유권·사용량·알림·감사. Webmaker·AriMail 로 가는 통합 진입점 | +| `webmaker` | `modules/webmaker/server.js` | 3080 | 도입 모듈(WebHosting_AI_agent 1.7.3). 사이트 제작·AI·도메인·DNS·TLS·Gitea·배포. 포털 SSO 로 로그인 없이 진입 | +| `webmaker-setup` | `modules/webmaker/setup-server.js` | 8795 | Webmaker 초기 설정 페이지(기존 그대로) | +| `arimail` | `modules/arimail/server/index.mjs` | 8790 + 25/110/143/465/587/993/995 | 도입 모듈(AriMail 1.0.11). 웹메일·IMAP·POP3·SMTP·사용량 API | +| nginx | — | 80/443 | 모든 공개 진입. vhost 는 HAL/Agent 가 템플릿으로 생성 | + +세 신규 프로세스(`hal`·`agent`·`portal`)는 같은 저장소의 `lib/` 를 공유하고 외부 npm 의존성이 없다(Node 22.13+, `node:sqlite`). +도입 모듈(`modules/*`)은 원본 저장소의 구조를 그대로 두고, 변경은 `IMPORTED_SOURCES.md` 에 파일 단위로 기록한다. + +## 2. 저장소 레이아웃 + +``` +lib/ 공통 라이브러리(ESM). db·http·session·status·jobs·quota·notify·audit·kst·redact·i18n +hal/ HAL 서버(api, ops 콘솔 UI, 릴리스·서버·작업·복구) +agent/ Node Agent(등록·heartbeat·작업 실행기·모듈 설치기·External Pull 모드) +portal/ 고객 포털(api, 화면, 도메인 로직) — portal/<영역>/*.mjs +modules/webmaker/ 도입: WebHosting_AI_agent @3b10859 (CommonJS) +modules/arimail/ 도입: AriMail @a07f69f (ESM) +catalog/ 출시 검증용 버전 고정 상품 Catalog(JSON) — 운영자가 HAL/포털 화면에서 관리하는 ProductVersion 의 시드 +deploy/ 설치 스크립트·systemd 유닛·nginx 템플릿·부트스트랩(HAL 최초 설치) +scripts/ import-sources(도입 소스 복사·manifest), release(릴리스 artifact 생성·검사) +test/ 회귀 테스트(node:test 없이 lib/test.mjs 의 작은 러너). `npm test` 가 전부 실행 +docs/ 이 문서, 운영 문서, 검증 기록 +VERSION_HISTORY.md, IMPORTED_SOURCES.md, README.md +``` + +## 3. 공통 라이브러리(lib/) 계약 + +- `lib/db.mjs` — `openDb(file, migrations[])`: `node:sqlite` 를 열고(WAL, foreign_keys) `migrations` 배열(`{id, sql}`)을 순서대로 적용한다. 반환 `{ db, get(sql, ...p), all(sql, ...p), run(sql, ...p), tx(fn) }`. 모든 저장은 SQLite 이며 JSON 열은 `json_*` 접미사. +- `lib/http.mjs` — `createApp()` → `{ route(method, pattern, handler), static(prefix, dir), listen(port, host) }`. 핸들러 시그니처 `(ctx) => body|Response`. `ctx = { req, res, method, path, params, query, body(json), cookies, ip, user, set(status, headers), json(obj, status), html(str), redirect(url), error(status, code, message) }`. 오류는 `{ ok:false, error:'', message }` JSON. 모든 응답 `Cache-Control: no-store`(정적 제외). 신뢰 프록시(nginx) 의 `X-Real-IP` 만 믿는다. +- `lib/session.mjs` — 서명 쿠키가 아니라 **DB 세션**(기기·IP·UA·발급·만료·마지막 사용, 개별/전체 종료 §3.2). `createSession(db, {userId, kind:'customer'|'operator', ...})`, `resolve(db, token)`, `revoke`, `revokeAll`. CSRF 는 세션에 묶인 HMAC 토큰(`X-CSRF` 헤더). +- `lib/status.mjs` — §2.2 사용자용 작업 상태 어휘와 전이 규칙. `STATUS = { NOT_READY:'준비 전', NEEDS_SETUP:'설정 필요', AWAITING_APPROVAL:'승인 대기', AWAITING_PAYMENT:'결제 대기', QUEUED:'처리 대기', RUNNING:'진행 중', AWAITING_EXTERNAL:'외부 작업 대기', VERIFYING:'검증 중', PAUSED:'일시 중지', DONE:'완료', FAILED:'실패', REVERTING:'되돌리는 중', REVERTED:'되돌림 완료', NEEDS_USER:'사용자 확인 필요' }`. 코드(영문 키)로 저장하고 화면에서 한국어로 바꾼다. 서비스 상태(`SERVICE_STATE`)·작업 상태(`STATUS`)·외부 건강 상태(`HEALTH`)는 **별도 필드**로 둔다(§2.2 "합치지 않는다"). +- `lib/jobs.mjs` — 내구성 작업 엔진. 작업은 `jobs` 표에 `{id, kind, subject(json), steps(json: [{id,title,state,startedAt,endedAt,detail}]), status, attempt, maxAttempts, nextRunAt, lockedBy, lockedAt, result(json), error, idempotencyKey, createdBy, groupId}` 로 저장된다. 실행기는 `registerKind(kind, { run(job, ctx), revert?(job, ctx), steps:[...] })` 로 종류를 등록하고, `ctx.step(id, fn)` 으로 단계를 기록한다(단계마다 저장 → 프로세스 재시작 뒤 이어서 실행 §29). 같은 `idempotencyKey` 의 작업은 하나만 만든다(§36). 일시 오류는 `retryable` 표시로 지수 백오프 재시도(횟수·다음 시각 표시 §2.2). `ctx.waitExternal(reason)` 은 `AWAITING_EXTERNAL` 로 둔다. +- `lib/quota.mjs` — §13 보장량·예약. 자원 종류 `RESOURCE = sites | web_disk | web_out | mail_storage | mail_msgs | mailboxes | mail_services | source | backup`. 표 `entitlements`(그룹별 구매 총량, 상품정책 버전 참조), `allocations`(서비스별 배정량), `reservations`(진행 중 작업 예약: `{id, groupId, resource, amount, state: available|reserved|in_use|returning|returned, jobId, expiresAt}`). `reserve()` 는 단일 트랜잭션에서 `구매총량 - (배정 + 예약)` 을 검사해 **동시 요청 중 하나만 성공**(§13.3, 검증 27). `commit()`→in_use, `release()`→returned. `warnLevel(used, limit)` → 0|70|80|90|95|100. +- `lib/kst.mjs` — KST 월 경계(`monthStart(at)`, `nextMonthStart(at)`), 고정 시각 입력 가능(테스트). +- `lib/redact.mjs` — 비밀로 보이는 키·값 가리기. 감사 기록·알림·작업 결과·로그 **모두** 이 함수를 거친다(§37). +- `lib/notify.mjs` — 알림 센터(§24.1): 분류 `category`, 사건 묶음 `incidentKey`(같은 원인은 하나로 갱신), 필수 알림(`mandatory`), 외부 이메일 발송 훅(`mailer`, AriMail/Resend 는 포털 설정). 링크는 조치 화면으로. +- `lib/audit.mjs` — 감사 기록(§24.2): `{at, actorType: user|operator|system, actorId, groupId, subjectType, subjectId, action, outcome, detail(redacted), immutable}`. 권한·소유권·배포·해지·복원·이전·결제 승인은 `immutable=1`(삭제 불가). +- `lib/i18n.mjs` — 한국어 문구 사전(`lang/ko.json`). 화면 문구는 코드에 박지 않고 여기서 읽는다. +- `lib/test.mjs` — 작은 테스트 러너: `test(name, fn)`, `eq`, `ok`, `rejects`, 임시 DB 생성. + +## 4. 데이터 모델(포털 SQLite, `portal/schema.mjs`) + +핵심 표(열은 요지만, 상세는 스키마 파일): + +- `users`(id, email, email_verified_at, display_name, pass_hash(scrypt), pass_salt, mfa_json, failed_logins, lock_until, created_at) · `email_tokens`(verify/reset, 만료·사용 완료) · `sessions` +- `groups`(id, name, kind: personal|team, billing_contact, tech_contact, owner_user_id) · `memberships`(group_id, user_id, role: owner|admin|member|billing|viewer, alias `user-01`, status) · `invites`(email, role, token_hash, expires_at, accepted_at) +- `site_permissions`(site_id, user_id, perms_json: view|edit|deploy|domain_mail|billing|admin) +- `product_versions`(id, product_id, version, name, audience, billing_cycle, price, currency, tax_rate, limits_json, options_json, warn_levels_json [70,80,90,95], approval: manual, effective_from, published) — 코드에 가격을 고정하지 않는다(§6.1, §27.2) +- `orders`(id, group_id, product_version_id, items_json, amount, tax, total, currency, status: awaiting_payment|awaiting_approval|approved|rejected|expired|cancelled, payment_ref, due_at, approved_by, idempotency_key) · `entitlements`(group_id, resource, amount, source_order_id, starts_at, ends_at) +- `sites`(id, group_id, name, purpose, webmaker_project_id, lifecycle: draft|building|deploying|live|paused|cancel_scheduled|readonly_retention|pending_delete|deleted, deploy_mode: managed|external|null, primary_domain, site_count_state: none|reserved|in_use|returning, server_id, current_version, health, settings_json) +- `deployments`(id, site_id, version_ref(저장본), mode, target(server/external agent), status(job), verify_state: verified|needs_domain|public, result_json) +- `domains`(id, group_id, name, purpose: web|mail|both, site_id, mail_service_id, ownership_state, dns_state, cert_state, mail_dns_state, last_checked_at, is_primary) +- `mail_services`(id, group_id, kind: site_bound|standalone, site_id, domain, arimail_server_id, status, allocations…) · `mailboxes`(mail_service_id, address, status, quota_bytes, …) — 실제 계정·메일 데이터는 AriMail 이 가지며 포털은 상태·배정만 둔다 +- `backup_options`(site_id, product_version_id, schedule_json, retention_json, status, first_backup_due_at) · `backups`(id, site_id, created_at, scope_json, verify_state, expires_at, location) · `restores`(id, site_id, backup_id, status, cost, approval…) +- `relocations`(id, subject_type, subject_id, from_server, to_server, kind: auto_static|approved|customer_paid, status, boundaries_json) +- `ownership_transfers`(id, from_group, to_group, scope_json, status: requested|accepted|reviewing|approved|completed|rejected|expired|disputed, expires_at) +- `jobs`, `reservations`, `allocations`, `notifications`, `audit_log`, `usage_samples`(site/mail 별 측정값, measured_at) + +## 5. HAL ↔ Node Agent 프로토콜(`docs/HAL-AGENT.md` 에 상세) + +- 등록: 운영자가 HAL 에서 서버(역할·위치) 생성 → **일회용 등록 토큰**(10분, 1회) 발급 → 서버에서 `ariweb-agent register --hal https://hal.ariweb.net --token …` → Agent 가 키쌍 생성, 공개키 등록, HAL 이 Agent 자격(agent_id + 비밀, 응답에 1회) 발급 → 운영자 승인 후 배치 가능. +- 인증: 모든 Agent 요청은 `Authorization: Bearer ` + 요청 서명(HMAC: method|path|ts|body-sha256). 시계 차이 ±5분. +- heartbeat: `POST /agent/v1/heartbeat` 30초마다 `{version, modules:{name:{version,active,health}}, metrics:{cpu,mem,disk,inode,io,net,clockSkewMs}, jobs:{running}}`. +- 작업: Agent 가 `GET /agent/v1/jobs/next?wait=25` 로 long-poll → HAL 이 `agent_jobs` 에서 배정 → Agent 실행 → `POST /agent/v1/jobs//progress|result`. Agent 재시작 시 미완료 작업은 재개 가능한 단계부터 다시 하거나 `NEEDS_USER` 로 보고한다. +- artifact: `GET /agent/v1/releases//artifact` (tar.gz, sha256 검증). 모듈 설치 레이아웃 `/srv/ariweb//releases/` + `current` 심링크, 유닛 `ariweb-.service`(도입 모듈은 기존 유닛 이름 `webmaker`·`arimail` 호환). + +## 6. 포털 ↔ HAL + +포털은 관리형 작업(사이트 배포·백업·복원·이전·사용량 수집)을 HAL API(`/portal/v1/*`, 서비스 키 + HMAC)로 요청하고 작업 ID 를 받는다. 포털 `jobs` 표의 작업이 HAL 작업을 참조(`hal_job_id`)하며 상태는 HAL 이 webhook(`/internal/hal-events`)으로 밀고, 포털도 주기 조회로 맞춘다(둘 중 빠른 쪽). HAL 이 응답하지 않으면 포털은 `정보 확인 지연` 을 표시하고 신규 증가를 허용하지 않는다(§13.4, §26.2). + +## 7. 포털 ↔ Webmaker / AriMail + +- **Webmaker SSO**(§0.2): 포털이 사용자별 Webmaker 계정(`aw--`)을 만들어 두고, "제작 화면 열기" 때 포털 서명 티켓(`/sso/webmaker?ticket=…`)으로 Webmaker 의 `/api/auth/sso` (신규, 포털 공유 비밀로 검증)에서 세션 쿠키를 받아 들어간다. Webmaker 의 `users.json` 비밀번호는 무작위이며 사용자는 모른다(별도 로그인 없음). 현재 사용자 그룹 이름을 Webmaker 상단에 표시한다(`X-Ariweb-Group` 배너). +- **Webmaker Project ↔ Site**: 포털 `sites.webmaker_project_id` ↔ Webmaker `data/sites/.json`. Webmaker 프로젝트 삭제 API 에 포털 영향 확인 훅(`beforeDelete` → 포털 `/internal/webmaker/project-impact`)을 둔다. +- **AriMail**: 관리자 API(계정 CRUD)·사용량 API(`/api/usage`)·전체 설정 API(도메인 추가)를 포털 `portal/mail/arimail.mjs` 어댑터가 호출한다. 비밀 키는 포털 vault(`lib/vault.mjs`, AES-256-GCM, 데이터 디렉터리의 `vault.key`)에만 둔다. + +## 8. 보안 규칙(§37) + +1. 비밀값(비밀번호·토큰·API 키·개인키·복구 코드·메일 원문)은 소스·문서·로그·테스트·커밋·보고서에 쓰지 않는다. 시험용 값도 `example`·무작위 생성만. +2. 모든 기록 경로(감사·알림·작업 결과·오류 메시지)는 `redact()` 를 거친다. +3. 비밀값은 저장 후 다시 읽을 수 없고 재발급만 제공한다(일회용 표시 뒤 해시만 저장). +4. 그룹 경계: 모든 조회 SQL 은 `group_id` 조건을 포함한다. 검색·자동완성도 같다. +5. 운영자 콘솔은 별도 계정·별도 세션(`kind='operator'`)·제한 경로(`/ops`)·허용 네트워크(사설망/VPN CIDR 또는 기기 귀속 단기 접근권) 없이는 로그인 화면도 열리지 않는다(§25). +6. 고위험 작업은 최근 재인증(10분) + 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행·되돌리기 가능 여부)을 거친다. + +## 9. 코딩 규약 + +- ESM(`.mjs`), 외부 의존성 없음, Node 22.13+. +- 모든 파일 머리에 모듈 목적 주석, 모든 함수·클래스에 목적·매개변수·반환·비자명한 논리 이유를 **한국어**로 적는다. +- 화면 문구는 `lang/ko.json`(포털·HAL) 에 둔다. 상태는 코드로 저장하고 표시 때 번역한다. +- 각 영역은 `portal/<영역>/{model,api,ui}.mjs` 로 나눈다. `api.mjs` 는 `register(app, deps)` 를 내보낸다. +- 테스트는 `test/<영역>.test.mjs`, `npm test` 로 전부 실행. 외부 네트워크·실서버에 닿는 테스트는 `ARIWEB_LIVE=1` 일 때만. +- 날짜·시각은 ISO 8601 UTC 로 저장하고 화면에서 KST 로 표시한다. 월간 사용량 경계는 KST. + +## 10. 배포 레이아웃(관리 서버) + +``` +/srv/ariweb/ + hal/ releases// current -> … data/(hal.sqlite, vault.key, artifacts/) + portal/ releases// current data/(portal.sqlite, vault.key) + agent/ releases// current data/(agent.json: id·공개키·HAL 주소; 비밀은 0600) + webmaker/ (도입 모듈) releases// current 데이터는 기존 /data/webmaker 유지 + arimail/ (도입 모듈) releases// current 데이터는 기존 경로 유지 + backups/ 관리형 사이트 백업 저장소(운영 서버와 다른 서버가 원칙) +systemd: ariweb-hal.service · ariweb-portal.service · ariweb-agent.service · webmaker.service · arimail(또는 기존 유닛명).service +nginx: /etc/nginx/sites-enabled/ariweb-hal.conf (hal.ariweb.net) · ariweb-portal.conf (my.ariweb.net) · wh-.conf (사이트) +``` + +HAL 최초 설치(부트스트랩, §32.2)만 `deploy/bootstrap-hal.sh` 로 수행한다. 그 뒤 모든 모듈은 HAL 작업으로만 설치·갱신한다. diff --git a/lang/ko/common.json b/lang/ko/common.json new file mode 100644 index 0000000..2861506 --- /dev/null +++ b/lang/ko/common.json @@ -0,0 +1,7 @@ +{ + "app": { "name": "Ariweb Suite", "portal": "Ariweb 고객 포털", "ops": "Ariweb 운영자 콘솔", "hal": "HAL" }, + "nav": { "dashboard": "대시보드", "sites": "사이트", "mail": "메일", "domains": "도메인", "backup": "백업", "billing": "상품 및 결제", "usage": "사용량", "group": "사용자 그룹 설정", "notifications": "알림", "help": "도움말", "account": "내 계정", "logout": "로그아웃" }, + "state": { "loading": "불러오는 중", "empty": "비어 있음", "stale": "정보 확인 지연", "partial": "부분 실패", "failed": "전체 실패", "forbidden": "권한 없음", "notProvided": "제공되지 않음" }, + "common": { "save": "저장", "cancel": "취소", "confirm": "확인", "retry": "다시 시도", "back": "뒤로", "next": "다음", "close": "닫기", "delete": "삭제", "edit": "수정", "create": "만들기", "refresh": "새로 고침", "lastUpdated": "마지막 갱신", "updatedAt": "갱신 시각", "actions": "작업", "status": "상태", "name": "이름", "more": "더 보기", "none": "없음", "yes": "예", "no": "아니요", "required": "필수", "optional": "선택", "help": "도움말", "copy": "복사", "copied": "복사했습니다" }, + "error": { "generic": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요", "forbidden": "이 작업을 할 권한이 없습니다. 필요한 역할: {role}", "notFound": "요청한 항목을 찾을 수 없습니다", "csrf": "보안 토큰이 맞지 않습니다. 화면을 새로 고친 뒤 다시 시도하세요", "unauthenticated": "로그인이 필요합니다", "groupRequired": "사용자 그룹을 먼저 선택하세요" } +} diff --git a/lib/audit.mjs b/lib/audit.mjs new file mode 100644 index 0000000..a47485f --- /dev/null +++ b/lib/audit.mjs @@ -0,0 +1,45 @@ +/** + * lib/audit.mjs — 감사 기록(§24.2). + * + * 누가(actor)·언제·어떤 그룹과 대상(subject)에서·무엇을(action)·자동/사용자 작업인지·결과(outcome)를 남긴다. + * 비밀값은 redact() 로 거른다. 권한·소유권·배포·해지·복원·이전·결제 승인은 immutable=1 로 두어 삭제 API 가 거부한다. + */ +import { newId } from './crypto.mjs'; +import { redact } from './redact.mjs'; +import { nowIso, toJson, parseJson } from './db.mjs'; + +export const MIGRATIONS = [{ id: 'lib_audit_0001', sql: ` +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, at TEXT NOT NULL, actor_type TEXT NOT NULL, actor_id TEXT, actor_label TEXT, group_id TEXT, + subject_type TEXT, subject_id TEXT, action TEXT NOT NULL, outcome TEXT NOT NULL, automatic INTEGER NOT NULL DEFAULT 0, + immutable INTEGER NOT NULL DEFAULT 0, detail_json TEXT, ip TEXT +); +CREATE INDEX IF NOT EXISTS audit_group ON audit_log(group_id, at); +CREATE INDEX IF NOT EXISTS audit_subject ON audit_log(subject_type, subject_id, at);` }]; + +/** 삭제할 수 없는 기록으로 남기는 행동 접두어 */ +const IMMUTABLE_PREFIX = /^(permission|ownership|deploy|cancel|terminate|restore|relocate|order\.approve|order\.reject|payment|refund|member\.role|site_count|emergency|recovery)/; + +/** + * 기록 남기기. + * @param {object} store lib/db + * @param {object} e { actorType: 'user'|'operator'|'system'|'agent', actorId, actorLabel, groupId, subjectType, subjectId, action, outcome: 'ok'|'failed'|'cancelled'|'denied', automatic, detail, ip } + */ +export function audit(store, e) { + const id = newId('a_'); + store.run('INSERT INTO audit_log(id, at, actor_type, actor_id, actor_label, group_id, subject_type, subject_id, action, outcome, automatic, immutable, detail_json, ip) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', + id, nowIso(), e.actorType || 'system', e.actorId || null, e.actorLabel || null, e.groupId || null, e.subjectType || null, e.subjectId || null, e.action, e.outcome || 'ok', + e.automatic ? 1 : 0, e.immutable || IMMUTABLE_PREFIX.test(e.action) ? 1 : 0, toJson(redact(e.detail || null)), e.ip || null); + return id; +} +/** 조회(그룹·대상·행동·기간 필터) */ +export function listAudit(store, { groupId, subjectType, subjectId, action, since, until, limit = 200 } = {}) { + const w = []; const p = []; + if (groupId) { w.push('group_id=?'); p.push(groupId); } + if (subjectType) { w.push('subject_type=?'); p.push(subjectType); } + if (subjectId) { w.push('subject_id=?'); p.push(subjectId); } + if (action) { w.push('action LIKE ?'); p.push(action + '%'); } + if (since) { w.push('at>=?'); p.push(since); } + if (until) { w.push('at<=?'); p.push(until); } + return store.all(`SELECT * FROM audit_log ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) })); +} diff --git a/lib/crypto.mjs b/lib/crypto.mjs new file mode 100644 index 0000000..d24e5e7 --- /dev/null +++ b/lib/crypto.mjs @@ -0,0 +1,29 @@ +/** + * lib/crypto.mjs — 비밀번호 해시·토큰·HMAC 공통. + * + * - 비밀번호: scrypt(N=2^15) + 16바이트 salt. Webmaker 와 같은 계열의 알고리즘이라 SSO 계정 동기화 때 혼동이 없다. + * - 토큰: 32바이트 무작위 base64url. 저장은 항상 sha256 해시만(원문은 발급 응답에 한 번). + * - HMAC: Agent 요청 서명·SSO 티켓 서명. 비교는 항상 timingSafeEqual. + */ +import { scryptSync, randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto'; + +/** 비밀번호 해시. @returns {{salt:string, hash:string}} */ +export function hashPassword(password) { const salt = randomBytes(16).toString('hex'); return { salt, hash: scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }).toString('hex') }; } +/** 비밀번호 검증(상수 시간). */ +export function verifyPassword(password, salt, hash) { + if (!salt || !hash) return false; + const a = scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); const b = Buffer.from(hash, 'hex'); + return a.length === b.length && timingSafeEqual(a, b); +} +/** 무작위 토큰(원문). bytes 기본 32. */ +export const randomToken = (bytes = 32) => randomBytes(bytes).toString('base64url'); +/** 짧은 식별자(ULID 대용): 시각 정렬 가능한 16진 26자 — 앞 10자는 ms 시각. */ +export function newId(prefix = '') { const t = Date.now().toString(16).padStart(12, '0'); return prefix + t + randomBytes(7).toString('hex'); } +/** 토큰 저장용 해시 */ +export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex'); +/** HMAC-SHA256 (base64url) */ +export const hmac = (secret, data) => createHmac('sha256', secret).update(String(data)).digest('base64url'); +/** 문자열 상수 시간 비교 */ +export function safeEqual(a, b) { const x = Buffer.from(String(a || '')); const y = Buffer.from(String(b || '')); return x.length === y.length && x.length > 0 && timingSafeEqual(x, y); } +/** 6자리 숫자 코드(이메일 확인·MFA 복구 등) */ +export const numericCode = (digits = 6) => String(parseInt(randomBytes(4).toString('hex'), 16) % 10 ** digits).padStart(digits, '0'); diff --git a/lib/db.mjs b/lib/db.mjs new file mode 100644 index 0000000..2cae739 --- /dev/null +++ b/lib/db.mjs @@ -0,0 +1,74 @@ +/** + * lib/db.mjs — SQLite 저장소 공통 계층 (node:sqlite, 외부 의존성 없음). + * + * 목적 + * HAL·포털·Agent 가 모두 같은 방식으로 DB 를 열고 마이그레이션을 적용하게 한다. + * - WAL 모드: 읽기와 쓰기가 서로 막지 않아 화면 조회 중 작업 엔진이 기록해도 된다. + * - foreign_keys ON: 그룹·사이트·작업 사이의 참조 무결성을 DB 가 지킨다. + * - busy_timeout: 짧은 잠금 경쟁(두 요청이 동시에 예약을 시도)은 기다렸다가 처리한다(§13.3 동시 요청). + * + * 사용 + * const store = openDb('/srv/ariweb/portal/data/portal.sqlite', [{ id: '0001_init', sql: '...' }]); + * store.get('SELECT * FROM users WHERE id=?', id) → 한 행 또는 undefined + * store.all(...) → 행 배열 + * store.run(...) → { changes, lastInsertRowid } + * store.tx(() => { ... }) → BEGIN IMMEDIATE … COMMIT (예외 시 ROLLBACK). 중첩되면 바깥 트랜잭션에 합류한다. + * + * 마이그레이션 + * `_migrations` 표에 적용한 id 를 남기고, 아직 없는 것만 순서대로 적용한다. 각 모듈(lib/session·jobs·quota·notify·audit)은 + * 자기 표의 마이그레이션을 `MIGRATIONS` 로 내보내고, 각 프로세스의 schema 파일이 그것을 모아 openDb 에 넘긴다. + */ +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +/** + * DB 를 열고 마이그레이션을 적용한다. + * @param {string} file 파일 경로. ':memory:' 면 메모리 DB(테스트). + * @param {Array<{id:string, sql:string}>} migrations 적용할 마이그레이션(순서대로) + * @returns {{db: DatabaseSync, get: Function, all: Function, run: Function, exec: Function, tx: Function, close: Function, file: string}} + */ +export function openDb(file, migrations = []) { + if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + // 운영 안정성 설정. WAL 은 파일 DB 에서만 의미가 있다. + db.exec('PRAGMA foreign_keys = ON'); + if (file !== ':memory:') { db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA synchronous = NORMAL'); } + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)'); + const applied = new Set(db.prepare('SELECT id FROM _migrations').all().map((r) => r.id)); + for (const m of migrations) { + if (applied.has(m.id)) continue; + // 한 마이그레이션은 한 트랜잭션: 중간에 실패하면 전부 되돌려 반쪽 스키마가 남지 않게 한다. + db.exec('BEGIN'); + try { db.exec(m.sql); db.prepare('INSERT INTO _migrations(id, applied_at) VALUES(?, ?)').run(m.id, new Date().toISOString()); db.exec('COMMIT'); } + catch (e) { db.exec('ROLLBACK'); throw new Error(`마이그레이션 실패 (${m.id}): ${e.message}`); } + } + let depth = 0; // 중첩 tx 깊이 — 안쪽 tx 는 BEGIN 을 다시 하지 않는다 + const store = { + db, file, + get: (sql, ...p) => db.prepare(sql).get(...p), + all: (sql, ...p) => db.prepare(sql).all(...p), + run: (sql, ...p) => db.prepare(sql).run(...p), + exec: (sql) => db.exec(sql), + /** + * 즉시 잠금 트랜잭션. 쓰기 경쟁(예약·주문 중복)을 DB 수준에서 직렬화한다. + * 동기 함수만 받는다(node:sqlite 는 동기 API 라 await 사이에 잠금을 쥐고 있으면 안 된다). + */ + tx(fn) { + if (depth > 0) return fn(store); + db.exec('BEGIN IMMEDIATE'); depth++; + try { const r = fn(store); db.exec('COMMIT'); depth--; return r; } + catch (e) { depth--; try { db.exec('ROLLBACK'); } catch (_) {} throw e; } + }, + close: () => db.close(), + }; + return store; +} + +/** JSON 열 읽기 도우미: 깨진 값은 fallback(화면이 깨지지 않게). */ +export function parseJson(text, fallback = null) { if (text == null || text === '') return fallback; try { return JSON.parse(text); } catch (_) { return fallback; } } +/** JSON 열 쓰기 도우미 */ +export const toJson = (v) => (v === undefined ? null : JSON.stringify(v)); +/** 현재 시각(ISO, UTC) */ +export const nowIso = () => new Date().toISOString(); diff --git a/lib/http.mjs b/lib/http.mjs new file mode 100644 index 0000000..db51cd5 --- /dev/null +++ b/lib/http.mjs @@ -0,0 +1,157 @@ +/** + * lib/http.mjs — 의존성 없는 작은 HTTP 앱 프레임워크(라우터·JSON·쿠키·정적 파일·오류 형식). + * + * 왜 직접 만드나: 형제 제품(Webmaker·AriMail)과 같이 외부 npm 의존성 없이 운영하기 위해서다. 필요한 기능은 + * 경로 매개변수(`/api/sites/:id`), JSON 본문, 쿠키, 신뢰 프록시 IP, 정적 파일, 일관된 오류 응답뿐이다. + * + * 핸들러: async (ctx) => 반환값 + * - 객체를 돌려주면 JSON(200). `ctx.json(obj, status)`·`ctx.html(str)`·`ctx.redirect(url)` 로 다른 응답. + * - 예외: `ctx.error(status, code, message)` 가 만든 HttpError → { ok:false, error, message, detail? }. 그 밖의 예외는 500 + 기록. + * 미들웨어: app.use(async (ctx) => { ... }) — 모든 라우트 전에 실행(세션 해석 등). 반환값이 있으면 그것으로 응답(차단). + * 보안 기본값: 모든 동적 응답 `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `X-Frame-Options: SAMEORIGIN`. + * 접속 IP: nginx 가 붙인 `X-Real-IP` 를 소켓 주소가 루프백일 때만 믿는다(클라이언트가 넣은 헤더를 믿지 않음). + */ +import http from 'node:http'; +import { readFileSync, statSync, createReadStream, existsSync } from 'node:fs'; +import { join, normalize, extname } from 'node:path'; + +export class HttpError extends Error { + /** @param {number} status @param {string} code 기계용 오류 코드 @param {string} message 사용자용 설명 @param {object} [detail] 화면이 쓸 추가 정보(비밀 없음) */ + constructor(status, code, message, detail) { super(message || code); this.status = status; this.code = code; this.detail = detail; } +} +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.woff2': 'font/woff2' }; +const MAX_BODY = 2 * 1024 * 1024; // JSON 본문 상한(2MB). 파일 업로드는 별도 스트리밍 라우트가 맡는다. + +/** 쿠키 헤더 파싱 */ +export function parseCookies(header) { const out = {}; for (const part of String(header || '').split(';')) { const i = part.indexOf('='); if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } return out; } +/** Set-Cookie 문자열 만들기 */ +export function cookie(name, value, { maxAge = 0, path = '/', secure = true, sameSite = 'Lax', httpOnly = true } = {}) { + return `${name}=${encodeURIComponent(value)}; Path=${path}; ${httpOnly ? 'HttpOnly; ' : ''}SameSite=${sameSite}${secure ? '; Secure' : ''}${maxAge ? `; Max-Age=${maxAge}` : '; Max-Age=0'}`; +} +/** 경로 패턴(`/a/:id/b`)을 정규식으로 */ +function compile(pattern) { + const keys = []; const re = new RegExp('^' + pattern.replace(/\/+$/, '').replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '(.*)').replace(/:([a-zA-Z_]+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '/?$'); + return { re, keys }; +} + +/** + * 앱 만들기. + * @param {{ log?: Function, trustProxy?: boolean, onError?: Function }} opts + */ +export function createApp(opts = {}) { + const routes = []; const middlewares = []; const statics = []; + const log = opts.log || ((...a) => console.log(...a)); + const app = { + /** 라우트 등록. method 는 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'*' */ + route(method, pattern, handler) { routes.push({ method: method.toUpperCase(), ...compile(pattern), pattern, handler }); return app; }, + get: (p, h) => app.route('GET', p, h), post: (p, h) => app.route('POST', p, h), put: (p, h) => app.route('PUT', p, h), patch: (p, h) => app.route('PATCH', p, h), delete: (p, h) => app.route('DELETE', p, h), + use(fn) { middlewares.push(fn); return app; }, + /** 정적 파일: prefix 아래 요청을 dir 에서 낸다(경로 탈출 차단). */ + static(prefix, dir) { statics.push({ prefix: prefix.replace(/\/$/, ''), dir }); return app; }, + /** 요청 하나 처리(테스트에서도 직접 부른다). */ + async handle(req, res) { + const started = Date.now(); + const url = new URL(req.url, 'http://localhost'); + const ctx = makeCtx(req, res, url, opts); + try { + for (const s of statics) { + if (ctx.method === 'GET' && (url.pathname === s.prefix || url.pathname.startsWith(s.prefix + '/'))) { + const rel = normalize(url.pathname.slice(s.prefix.length) || '/').replace(/^(\.\.(\/|\\|$))+/, ''); + const file = join(s.dir, rel); + if (!file.startsWith(s.dir)) throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다'); + if (existsSync(file) && statSync(file).isFile()) return sendFile(res, file); + } + } + for (const m of middlewares) { const r = await m(ctx); if (r !== undefined) return finish(ctx, r); if (ctx.ended) return; } + for (const r of routes) { + if (r.method !== '*' && r.method !== ctx.method) continue; + const m = r.re.exec(url.pathname); if (!m) continue; + r.keys.forEach((k, i) => { ctx.params[k] = decodeURIComponent(m[i + 1]); }); + const out = await r.handler(ctx); + return finish(ctx, out); + } + throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다'); + } catch (e) { return sendError(ctx, e, log, opts); } + finally { if (opts.access) log(`${ctx.method} ${url.pathname} ${res.statusCode} ${Date.now() - started}ms`); } + }, + /** 서버 시작 */ + listen(port, host = '127.0.0.1') { const server = http.createServer((req, res) => app.handle(req, res)); server.keepAliveTimeout = 65000; return new Promise((resolve) => server.listen(port, host, () => resolve(server))); }, + routes, + }; + return app; +} + +/** 요청 컨텍스트 */ +function makeCtx(req, res, url, opts) { + const sock = req.socket?.remoteAddress || ''; + const loop = sock === '127.0.0.1' || sock === '::1' || sock === '::ffff:127.0.0.1'; + const ip = (opts.trustProxy !== false && loop && req.headers['x-real-ip']) ? String(req.headers['x-real-ip']) : sock.replace(/^::ffff:/, ''); + let bodyCache; + const ctx = { + req, res, method: req.method.toUpperCase(), path: url.pathname, url, params: {}, query: Object.fromEntries(url.searchParams), cookies: parseCookies(req.headers.cookie), ip, headers: req.headers, + user: null, session: null, ended: false, status: 200, outHeaders: {}, + secure: req.headers['x-forwarded-proto'] === 'https' || !!req.socket?.encrypted, + /** JSON 본문(한 번만 읽고 캐시). 비어 있으면 {} */ + async body() { + if (bodyCache !== undefined) return bodyCache; + const chunks = []; let size = 0; + for await (const c of req) { size += c.length; if (size > MAX_BODY) throw new HttpError(413, 'body_too_large', '요청 본문이 너무 큽니다'); chunks.push(c); } + const text = Buffer.concat(chunks).toString('utf8'); + if (!text) return (bodyCache = {}); + const ct = String(req.headers['content-type'] || ''); + if (ct.includes('application/x-www-form-urlencoded')) return (bodyCache = Object.fromEntries(new URLSearchParams(text))); + try { return (bodyCache = JSON.parse(text)); } catch (_) { throw new HttpError(400, 'bad_json', '요청 본문이 올바른 JSON 이 아닙니다'); } + }, + set(status, headers = {}) { ctx.status = status; Object.assign(ctx.outHeaders, headers); }, + setCookie(str) { const prev = ctx.outHeaders['Set-Cookie']; ctx.outHeaders['Set-Cookie'] = prev ? [].concat(prev, str) : str; }, + json(obj, status = 200) { ctx.status = status; return { __json: obj }; }, + html(str, status = 200) { ctx.status = status; return { __html: str }; }, + text(str, status = 200) { ctx.status = status; return { __text: str }; }, + redirect(url, status = 302) { ctx.status = status; ctx.outHeaders.Location = url; return { __text: '' }; }, + error(status, code, message, detail) { return new HttpError(status, code, message, detail); }, + }; + return ctx; +} +function baseHeaders(ctx) { return { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'SAMEORIGIN', ...ctx.outHeaders }; } +function finish(ctx, out) { + if (ctx.ended) return; ctx.ended = true; + const h = baseHeaders(ctx); + if (out && out.__html !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/html; charset=utf-8', ...h }); return ctx.res.end(out.__html); } + if (out && out.__text !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/plain; charset=utf-8', ...h }); return ctx.res.end(out.__text); } + const body = out && out.__json !== undefined ? out.__json : out; + ctx.res.writeHead(ctx.status, { 'Content-Type': 'application/json; charset=utf-8', ...h }); + ctx.res.end(JSON.stringify(body === undefined ? { ok: true } : body)); +} +function sendError(ctx, e, log, opts) { + if (ctx.ended) return; + const status = e instanceof HttpError ? e.status : (e && e.status) || 500; + const code = e instanceof HttpError ? e.code : (e && e.code) || 'server_error'; + if (status >= 500) { log(`[http] ${ctx.method} ${ctx.path} 500: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); if (opts.onError) try { opts.onError(e, ctx); } catch (_) {} } + ctx.ended = true; + ctx.res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...baseHeaders(ctx) }); + ctx.res.end(JSON.stringify({ ok: false, error: code, message: status >= 500 ? '일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요' : (e.message || code), ...(e.detail ? { detail: e.detail } : {}) })); +} +function sendFile(res, file) { + const type = MIME[extname(file).toLowerCase()] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' }); + createReadStream(file).pipe(res); +} +/** 간단한 HTML 이스케이프 */ +export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +/** 테스트용: 앱에 가짜 요청을 보내고 { status, headers, json, text } 를 돌려준다(소켓 없이). */ +export async function invoke(app, method, path, { body, headers = {}, cookies = {}, ip = '127.0.0.1' } = {}) { + const { PassThrough } = await import('node:stream'); + const req = new PassThrough(); req.method = method; req.url = path; + req.headers = { 'content-type': 'application/json', ...headers }; + if (Object.keys(cookies).length) req.headers.cookie = Object.entries(cookies).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; '); + req.socket = { remoteAddress: ip }; + if (body !== undefined) req.end(typeof body === 'string' ? body : JSON.stringify(body)); else req.end(); + const out = { status: 0, headers: {}, chunks: [] }; + const res = { writeHead(s, h) { out.status = s; out.headers = h || {}; }, end(d) { if (d) out.chunks.push(Buffer.from(d)); out.done = true; resolve(); }, write(d) { out.chunks.push(Buffer.from(d)); }, statusCode: 200, on() {}, once() {}, emit() {} }; + let resolve; const p = new Promise((r) => { resolve = r; }); + await app.handle(req, res); await p; + const text = Buffer.concat(out.chunks).toString('utf8'); + let json = null; try { json = JSON.parse(text); } catch (_) {} + const setCookie = [].concat(out.headers['Set-Cookie'] || []); + return { status: out.status, headers: out.headers, text, json, cookies: Object.fromEntries(setCookie.map((c) => { const [kv] = c.split(';'); const i = kv.indexOf('='); return [kv.slice(0, i), decodeURIComponent(kv.slice(i + 1))]; })) }; +} diff --git a/lib/i18n.mjs b/lib/i18n.mjs new file mode 100644 index 0000000..3a0b984 --- /dev/null +++ b/lib/i18n.mjs @@ -0,0 +1,27 @@ +/** + * lib/i18n.mjs — 화면 문구 사전. 문구를 코드에 박지 않고 lang/ko.json 에서 읽는다(§9 규약). + * t('auth.login.failed') → 문자열. {name} 자리표시자는 vars 로 치환. 없는 키는 키 그대로 돌려줘 누락이 화면에 드러난다. + */ +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +let dict = null; let loadedAt = 0; +/** lang/ko/*.json 을 모두 읽어 깊게 합친다(영역마다 파일을 나눠 충돌 없이 관리). 운영 중 수정은 5초 안에 반영. */ +function load() { + if (dict && Date.now() - loadedAt < 5000) return dict; + const dir = join(ROOT, 'lang', 'ko'); const merged = {}; + const deepMerge = (a, b) => { for (const [k, v] of Object.entries(b)) { if (v && typeof v === 'object' && !Array.isArray(v) && a[k] && typeof a[k] === 'object') deepMerge(a[k], v); else a[k] = v; } return a; }; + try { for (const f of readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) { try { deepMerge(merged, JSON.parse(readFileSync(join(dir, f), 'utf8'))); } catch (e) { console.error(`[i18n] ${f} 읽기 실패: ${e.message}`); } } } catch (_) {} + dict = merged; loadedAt = Date.now(); return dict; +} +/** 점(.)으로 중첩 키를 찾는다 */ +function lookup(obj, key) { return key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj); } +export function t(key, vars = {}) { + const v = lookup(load(), key); + const s = typeof v === 'string' ? v : key; + return s.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? String(vars[k]) : `{${k}}`)); +} +/** 사전 전체(화면 JS 에 내려보낼 때) */ +export function all() { return load(); } diff --git a/lib/jobs.mjs b/lib/jobs.mjs new file mode 100644 index 0000000..a801b48 --- /dev/null +++ b/lib/jobs.mjs @@ -0,0 +1,212 @@ +/** + * lib/jobs.mjs — 내구성 작업 엔진(§2.2·§29·§36). + * + * 생각의 틀 + * 배포·복원·이전·백업·주문 처리처럼 "오래 걸리고 중간에 프로세스가 죽을 수 있는" 일은 모두 작업(job)이다. + * 작업은 DB 행이며 단계(step)마다 결과를 저장한다. 프로세스가 재시작되면 끝난 단계는 건너뛰고 다음 단계부터 이어서 실행한다. + * 완료되지 않은 작업을 실패로 숨기지 않고, 재시도 횟수·다음 시각·외부 대기 사유·사용자 확인 필요를 상태로 드러낸다. + * + * 핵심 규약 + * - register(kind, def): def = { title, steps:[{id,title}], run(job, ctx), revert?(job, ctx), maxAttempts?, retryable?(err) } + * - run() 은 **재진입 가능**해야 한다: 같은 작업으로 여러 번 불릴 수 있으므로 부수 효과가 있는 일은 ctx.step(id, fn) 안에서 한다. + * ctx.step 은 이미 done 인 단계면 저장된 결과를 돌려주고 fn 을 실행하지 않는다. + * - 같은 idempotencyKey 로 enqueue 하면 새 작업을 만들지 않고 기존 작업을 돌려준다(버튼 연타·새로 고침 §36). + * - 일시 오류(err.retryable=true 또는 def.retryable(err)) 는 지수 백오프로 재시도하고 attempt·next_run_at 을 보인다. + * - ctx.waitExternal(reason, {resumeAt}) → AWAITING_EXTERNAL. ctx.needsUser(message, actions) → NEEDS_USER. resume(id) 로 다시 QUEUED. + * - 되돌리기: revert(id) 는 def.revert 를 실행하며 REVERTING → REVERTED(실패하면 FAILED + error). 되돌리기 전 데이터 영향은 호출자가 안내한다. + * - 부모/자식: parentId 로 대상별 작업(여러 서버 모듈 갱신 §29)을 묶고 childrenSummary 로 대상별 성공·실패·대기를 보인다. 일부 실패를 전체 성공으로 보이지 않는다. + */ +import { newId } from './crypto.mjs'; +import { redact, safeError } from './redact.mjs'; +import { nowIso, toJson, parseJson } from './db.mjs'; +import { JOB_STATUS as S, TERMINAL, CANCELLABLE } from './status.mjs'; + +export const MIGRATIONS = [{ id: 'lib_jobs_0001', sql: ` +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, kind TEXT NOT NULL, status TEXT NOT NULL, label TEXT, subject_json TEXT, steps_json TEXT, attempt INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 1, + next_run_at TEXT, locked_by TEXT, locked_at TEXT, result_json TEXT, error TEXT, error_code TEXT, wait_reason TEXT, user_message TEXT, actions_json TEXT, eta_at TEXT, progress INTEGER NOT NULL DEFAULT 0, + idempotency_key TEXT UNIQUE, created_by TEXT, actor_type TEXT, group_id TEXT, subject_type TEXT, subject_id TEXT, parent_id TEXT, external_ref TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, started_at TEXT, ended_at TEXT, log_json TEXT +); +CREATE INDEX IF NOT EXISTS jobs_status ON jobs(status, next_run_at); +CREATE INDEX IF NOT EXISTS jobs_group ON jobs(group_id, created_at); +CREATE INDEX IF NOT EXISTS jobs_subject ON jobs(subject_type, subject_id, created_at); +CREATE INDEX IF NOT EXISTS jobs_parent ON jobs(parent_id);` }]; + +/** run() 안에서 흐름을 바꾸는 신호(예외로 전달) */ +class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } } +export class JobError extends Error { constructor(code, message, { retryable = false, detail } = {}) { super(message || code); this.code = code; this.retryable = retryable; this.detail = detail; } } + +const BACKOFF_BASE_MS = 30 * 1000; const BACKOFF_MAX_MS = 60 * 60 * 1000; const STALE_LOCK_MS = 10 * 60 * 1000; + +/** + * 작업 엔진 만들기. + * @param {object} store lib/db + * @param {{workerId?:string, pollMs?:number, log?:Function}} opts + */ +export function createJobEngine(store, opts = {}) { + const kinds = new Map(); const listeners = new Set(); + const workerId = opts.workerId || `${process.pid}@${(await0() || 'host')}`; const log = opts.log || (() => {}); + let timer = null; let running = 0; let stopped = true; const concurrency = opts.concurrency || 4; + + function row2job(r) { if (!r) return null; return { ...r, subject: parseJson(r.subject_json, {}), steps: parseJson(r.steps_json, []), result: parseJson(r.result_json, null), actions: parseJson(r.actions_json, null), log: parseJson(r.log_json, []) }; } + function emit(job) { for (const fn of listeners) { try { fn(job); } catch (e) { log('[jobs] listener error', e.message); } } } + function save(id, patch) { + const sets = []; const vals = []; + for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); } + sets.push('updated_at=?'); vals.push(nowIso()); + store.run(`UPDATE jobs SET ${sets.join(', ')} WHERE id=?`, ...vals, id); + const j = engine.get(id); emit(j); return j; + } + const engine = { + workerId, + register(kind, def) { kinds.set(kind, { maxAttempts: 1, steps: [], ...def }); return engine; }, + kinds: () => [...kinds.keys()], + onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }, + /** + * 작업 넣기. idempotencyKey 가 같은 작업이 있으면 그것을 돌려준다(created=false). + * @returns {{job:object, created:boolean}} + */ + enqueue({ kind, subject = {}, idempotencyKey = null, createdBy = null, actorType = 'user', groupId = null, subjectType = null, subjectId = null, label = null, parentId = null, runAt = null, maxAttempts = null, initialStatus = null }) { + const def = kinds.get(kind); if (!def) throw new JobError('unknown_job_kind', `알 수 없는 작업 종류: ${kind}`); + return store.tx(() => { + if (idempotencyKey) { const ex = store.get('SELECT * FROM jobs WHERE idempotency_key=?', idempotencyKey); if (ex) return { job: row2job(ex), created: false }; } + const id = newId('j_'); const now = nowIso(); + const steps = (def.steps || []).map((s) => ({ id: s.id, title: s.title, state: 'pending' })); + store.run('INSERT INTO jobs(id, kind, status, label, subject_json, steps_json, attempt, max_attempts, next_run_at, idempotency_key, created_by, actor_type, group_id, subject_type, subject_id, parent_id, created_at, updated_at, log_json) VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?,?,?,?)', + id, kind, initialStatus || S.QUEUED, label || def.title || kind, toJson(subject), toJson(steps), maxAttempts || def.maxAttempts || 1, runAt || now, idempotencyKey, createdBy, actorType, groupId, subjectType, subjectId, parentId, now, now, '[]'); + const job = engine.get(id); emit(job); return { job, created: true }; + }); + }, + get: (id) => row2job(store.get('SELECT * FROM jobs WHERE id=?', id)), + list({ groupId, status, kind, subjectType, subjectId, parentId, limit = 100 } = {}) { + const w = []; const p = []; + if (groupId) { w.push('group_id=?'); p.push(groupId); } if (status) { w.push(Array.isArray(status) ? `status IN (${status.map(() => '?').join(',')})` : 'status=?'); p.push(...[].concat(status)); } + if (kind) { w.push('kind=?'); p.push(kind); } if (subjectType) { w.push('subject_type=?'); p.push(subjectType); } if (subjectId) { w.push('subject_id=?'); p.push(subjectId); } if (parentId) { w.push('parent_id=?'); p.push(parentId); } + return store.all(`SELECT * FROM jobs ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY created_at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map(row2job); + }, + /** 자식 작업 요약(대상별 성공·실패·대기). 일부 실패는 'partial' */ + childrenSummary(parentId) { + const kids = engine.list({ parentId, limit: 1000 }); const by = {}; for (const k of kids) by[k.status] = (by[k.status] || 0) + 1; + const failed = kids.filter((k) => ['failed', 'needs_user'].includes(k.status)).length; const done = kids.filter((k) => k.status === 'done').length; + return { total: kids.length, byStatus: by, overall: kids.length === 0 ? 'none' : done === kids.length ? 'done' : failed && done ? 'partial' : failed === kids.length ? 'failed' : 'running', children: kids.map((k) => ({ id: k.id, label: k.label, status: k.status, subject: k.subject, error: k.error, attempt: k.attempt, next_run_at: k.next_run_at })) }; + }, + /** 외부 대기·사용자 확인·일시 중지 상태의 작업을 다시 큐에 넣는다 */ + resume(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.AWAITING_EXTERNAL, S.NEEDS_USER, S.PAUSED, S.AWAITING_APPROVAL, S.AWAITING_PAYMENT, S.NOT_READY, S.NEEDS_SETUP].includes(j.status)) throw new JobError('job_not_resumable', `지금 상태(${j.status})에서는 이어서 할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), wait_reason: null, user_message: null, actions_json: null, log_json: appendLog(j, `재개 (${by || 'system'})`) }); }, + /** 실패한 작업 재시도(사용자·운영자 명시) */ + retry(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_retryable', `지금 상태(${j.status})에서는 재시도할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), error: null, error_code: null, max_attempts: j.attempt + 1, log_json: appendLog(j, `재시도 요청 (${by || 'system'})`) }); }, + pause(id, by = null) { const j = engine.get(id); if (!j || ![S.QUEUED, S.AWAITING_EXTERNAL].includes(j.status)) throw new JobError('job_not_pausable', '지금 상태에서는 일시 중지할 수 없습니다'); return save(id, { status: S.PAUSED, log_json: appendLog(j, `일시 중지 (${by || 'system'})`) }); }, + /** 취소: 대기 상태면 즉시, 실행 중이면 취소 요청 표시(ctx.checkCancel 에서 멈춤) */ + cancel(id, by = null) { + const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); + if (CANCELLABLE.has(j.status)) return save(id, { status: S.CANCELLED, ended_at: nowIso(), log_json: appendLog(j, `취소 (${by || 'system'})`) }); + if (j.status === S.RUNNING) return save(id, { cancel_requested: 1, log_json: appendLog(j, `취소 요청 (${by || 'system'})`) }); + throw new JobError('job_not_cancellable', `지금 상태(${j.status})에서는 취소할 수 없습니다`); + }, + /** 되돌리기 */ + async revert(id, by = null) { + const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); + const def = kinds.get(j.kind); if (!def || !def.revert) throw new JobError('job_not_revertible', '이 작업은 되돌리기를 지원하지 않습니다'); + if (![S.DONE, S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_revertible', `지금 상태(${j.status})에서는 되돌릴 수 없습니다`); + save(id, { status: S.REVERTING, log_json: appendLog(j, `되돌리기 시작 (${by || 'system'})`) }); + const ctx = makeCtx(engine.get(id), { reverting: true }); + try { await def.revert(engine.get(id), ctx); return save(id, { status: S.REVERTED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs() }); } + catch (e) { return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'revert_failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso() }); } + }, + /** 외부(HAL 등)에서 진행 상태를 밀어 넣을 때 */ + update(id, patch) { return save(id, patch); }, + /** 프로세스 시작 시: 이전 실행에서 RUNNING 으로 남은 작업(잠금 소유자 불문, 단일 프로세스 운영)을 QUEUED 로 되돌려 이어서 실행한다 */ + recover() { + const stale = store.all('SELECT * FROM jobs WHERE status=?', S.RUNNING); + for (const r of stale) { const j = row2job(r); save(j.id, { status: S.QUEUED, locked_by: null, locked_at: null, next_run_at: nowIso(), log_json: appendLog(j, '프로세스 재시작 — 끝난 단계 다음부터 이어서 실행') }); } + return stale.length; + }, + /** 실행 가능한 작업 하나를 집어 실행(테스트에서도 직접 부름). 돌아온 값: 실행한 작업 또는 null */ + async tick() { + if (running >= concurrency) return null; + const now = nowIso(); + const cand = store.get('SELECT id FROM jobs WHERE status=? AND (next_run_at IS NULL OR next_run_at<=?) ORDER BY next_run_at ASC, created_at ASC LIMIT 1', S.QUEUED, now); + if (!cand) return null; + const got = store.run('UPDATE jobs SET status=?, locked_by=?, locked_at=?, started_at=COALESCE(started_at, ?), attempt=attempt+1, updated_at=? WHERE id=? AND status=?', S.RUNNING, workerId, now, now, now, cand.id, S.QUEUED).changes; + if (!got) return null; + running++; + try { return await execute(cand.id); } finally { running--; } + }, + /** 모든 큐 작업을 비울 때까지 tick(테스트용) */ + async drain(max = 100) { let n = 0; while (n < max) { const r = await engine.tick(); if (!r) break; n++; } return n; }, + start() { stopped = false; engine.recover(); const loop = async () => { if (stopped) return; try { let n = 0; while (n < concurrency && (await engine.tick())) n++; } catch (e) { log('[jobs] tick error', safeError(e)); } timer = setTimeout(loop, opts.pollMs || 1000); }; loop(); return engine; }, + stop() { stopped = true; if (timer) clearTimeout(timer); }, + }; + function appendLog(job, line) { const l = (job.log || []).slice(-200); l.push({ at: nowIso(), line: String(line).slice(0, 500) }); return l; } + function makeCtx(job, { reverting = false } = {}) { + const steps = (job.steps || []).map((s) => ({ ...s })); const logs = (job.log || []).slice(-200); + const ctx = { + job, reverting, workerId, + /** 단계 실행. 이미 done 이면 저장된 결과를 돌려준다. 결과는 JSON 직렬화 가능해야 한다. */ + async step(id, fn, title) { + let s = steps.find((x) => x.id === id); if (!s) { s = { id, title: title || id, state: 'pending' }; steps.push(s); } + if (s.state === 'done' && !reverting) return s.result; + s.state = 'running'; s.startedAt = nowIso(); delete s.error; ctx.flush(); + try { const result = await fn(); s.state = 'done'; s.endedAt = nowIso(); s.result = result === undefined ? null : redact(result); ctx.flush(); return result; } + catch (e) { if (e instanceof Signal) { s.state = e.signal === 'wait' ? 'waiting' : e.signal === 'needs_user' ? 'needs_user' : 'pending'; s.detail = e.payload.reason || e.payload.message || ''; ctx.flush(); throw e; } s.state = 'failed'; s.endedAt = nowIso(); s.error = safeError(e); ctx.flush(); throw e; } + }, + log(line) { logs.push({ at: nowIso(), line: String(line).slice(0, 500) }); ctx.flush(); }, + progress(pct) { store.run('UPDATE jobs SET progress=?, updated_at=? WHERE id=?', Math.max(0, Math.min(100, Math.round(pct))), nowIso(), job.id); }, + eta(at) { store.run('UPDATE jobs SET eta_at=?, updated_at=? WHERE id=?', at || null, nowIso(), job.id); }, + /** 외부 작업(사용자 DNS 변경·운영자 승인 등)을 기다린다. run() 은 여기서 끝나고 resume() 뒤 다시 불린다. */ + waitExternal(reason, { resumeAt = null } = {}) { throw new Signal('wait', { reason, resumeAt }); }, + /** 사용자 확인이 필요해 멈춘다. actions: [{id,label,description}] */ + needsUser(message, actions = null) { throw new Signal('needs_user', { message, actions }); }, + /** 일시 오류로 나중에 다시(지수 백오프 대신 지정 시각) */ + retryLater(ms, reason) { throw new Signal('retry', { ms, reason }); }, + /** 취소 요청이 들어왔으면 멈춘다 */ + checkCancel() { const r = store.get('SELECT cancel_requested FROM jobs WHERE id=?', job.id); if (r && r.cancel_requested) throw new Signal('cancel', {}); }, + steps: () => steps, logs: () => logs, + flush() { store.run('UPDATE jobs SET steps_json=?, log_json=?, updated_at=? WHERE id=?', toJson(steps), toJson(logs), nowIso(), job.id); }, + }; + return ctx; + } + async function execute(id) { + const job = engine.get(id); const def = kinds.get(job.kind); + if (!def) return save(id, { status: S.FAILED, error: `알 수 없는 작업 종류: ${job.kind}`, error_code: 'unknown_job_kind', ended_at: nowIso() }); + const ctx = makeCtx(job); + try { + const result = await def.run(job, ctx); + return save(id, { status: S.DONE, result_json: redact(result === undefined ? null : result), progress: 100, steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null, error: null, error_code: null }); + } catch (e) { + if (e instanceof Signal) { + // 단계 밖에서 대기·확인 신호가 나면 다음 미완료 단계에 그 상태를 표시해 화면이 "현재 단계" 를 보일 수 있게 한다 + if (['wait', 'needs_user'].includes(e.signal)) { const st = ctx.steps(); if (!st.some((x) => ['waiting', 'needs_user'].includes(x.state))) { const nx = st.find((x) => x.state === 'pending'); if (nx) { nx.state = e.signal === 'wait' ? 'waiting' : 'needs_user'; nx.detail = e.payload.reason || e.payload.message || ''; } } } + if (e.signal === 'wait') return save(id, { status: S.AWAITING_EXTERNAL, wait_reason: e.payload.reason || null, eta_at: e.payload.resumeAt || null, next_run_at: e.payload.resumeAt || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null }); + if (e.signal === 'needs_user') return save(id, { status: S.NEEDS_USER, user_message: e.payload.message || null, actions_json: e.payload.actions || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null }); + if (e.signal === 'cancel') return save(id, { status: S.CANCELLED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null, cancel_requested: 0 }); + if (e.signal === 'retry') { const at = new Date(Date.now() + (e.payload.ms || BACKOFF_BASE_MS)).toISOString(); return save(id, { status: S.QUEUED, next_run_at: at, wait_reason: e.payload.reason || null, max_attempts: Math.max(job.max_attempts, job.attempt + 1), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null }); } + } + const retryable = !!(e && e.retryable) || (def.retryable ? !!def.retryable(e) : false); + const fresh = engine.get(id); + if (retryable && fresh.attempt < fresh.max_attempts) { + const backoff = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (fresh.attempt - 1)); + return save(id, { status: S.QUEUED, next_run_at: new Date(Date.now() + backoff).toISOString(), error: safeError(e), error_code: e.code || 'transient', steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null }); + } + log(`[jobs] ${job.kind} ${id} 실패: ${safeError(e)}`); + return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null }); + } + } + return engine; +} +function await0() { try { return (process.env.HOSTNAME || '').slice(0, 20); } catch (_) { return ''; } } +/** 화면용 요약: 현재 단계·완료 단계·다음 단계·재시도 정보(§2.2) */ +export function describe(job) { + if (!job) return null; + const steps = job.steps || []; const doneSteps = steps.filter((s) => s.state === 'done'); const current = steps.find((s) => ['running', 'waiting', 'needs_user', 'failed'].includes(s.state)) || steps.find((s) => s.state === 'pending') || null; + const idx = current ? steps.indexOf(current) : steps.length; const next = steps[idx + 1] || null; + return { + id: job.id, kind: job.kind, label: job.label, status: job.status, progress: job.progress, attempt: job.attempt, maxAttempts: job.max_attempts, nextRunAt: job.next_run_at, etaAt: job.eta_at || null, + etaUnknownReason: job.eta_at ? null : '외부 반영 시간에 따라 달라져 산정할 수 없습니다', updatedAt: job.updated_at, startedAt: job.started_at, endedAt: job.ended_at, + currentStep: current ? { id: current.id, title: current.title, state: current.state, detail: current.detail || current.error || null } : null, + completedSteps: doneSteps.map((s) => ({ id: s.id, title: s.title })), nextStep: next ? { id: next.id, title: next.title } : null, steps, + waitReason: job.wait_reason, userMessage: job.user_message, actions: job.actions, error: job.error, errorCode: job.error_code, + canCancel: CANCELLABLE.has(job.status) || job.status === 'running', canRetry: ['failed', 'needs_user'].includes(job.status), canResume: ['awaiting_external', 'needs_user', 'paused'].includes(job.status), terminal: TERMINAL.has(job.status), + stale: job.status === 'running' && Date.now() - Date.parse(job.updated_at) > 10 * 60 * 1000, + }; +} diff --git a/lib/kst.mjs b/lib/kst.mjs new file mode 100644 index 0000000..90ccad4 --- /dev/null +++ b/lib/kst.mjs @@ -0,0 +1,25 @@ +/** + * lib/kst.mjs — 한국 표준시(KST, UTC+9) 월 경계 계산. + * + * 월간 사용량(웹 아웃바운드·메일 통수)은 "KST 월" 기준으로 집계·초기화한다(§13.3·§13.4·§17.2). + * UTC 로 저장된 시각을 KST 월로 나누려면 9시간을 더해 날짜를 구하고, 월 시작을 다시 UTC 로 돌린다. + * 테스트가 고정 시각으로 검증할 수 있게 모든 함수는 `at` 을 인자로 받는다(AriMail usage.test 의 월 경계 오류 재발 방지). + */ +const KST_OFFSET_MS = 9 * 3600 * 1000; + +/** at 이 속한 KST 월의 시작(UTC ISO). 예: 2026-10-31T16:00:00Z(=11-01 01:00 KST) → 2026-10-31T15:00:00.000Z */ +export function monthStart(at = new Date()) { + const k = new Date(new Date(at).getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), 1) - KST_OFFSET_MS).toISOString(); +} +/** 다음 KST 월 시작(UTC ISO) = 월간 사용량 초기화 시각 */ +export function nextMonthStart(at = new Date()) { + const k = new Date(new Date(at).getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth() + 1, 1) - KST_OFFSET_MS).toISOString(); +} +/** KST 기준 YYYY-MM-DD */ +export function kstDate(at = new Date()) { return new Date(new Date(at).getTime() + KST_OFFSET_MS).toISOString().slice(0, 10); } +/** KST 기준 YYYY-MM */ +export function kstMonth(at = new Date()) { return kstDate(at).slice(0, 7); } +/** 화면 표시용 "YYYY-MM-DD HH:mm (KST)" */ +export function fmtKst(at) { if (!at) return ''; const k = new Date(new Date(at).getTime() + KST_OFFSET_MS); return k.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; } diff --git a/lib/notify.mjs b/lib/notify.mjs new file mode 100644 index 0000000..1c57e68 --- /dev/null +++ b/lib/notify.mjs @@ -0,0 +1,59 @@ +/** + * lib/notify.mjs — 알림 센터(§24.1). + * + * 분류(category): billing | site | domain | mail | usage | backup | relocation | security | incident | membership | ops + * 중요도(severity): info | warning | critical. mandatory=1 은 필수 알림(서비스 중단·보안·결제·데이터 삭제 예정) → 외부 이메일도 보낸다. + * 같은 원인의 반복 알림은 incident_key 로 하나의 사건으로 묶어 count·last_at·title/body 를 갱신한다(§24.1). + * link 는 사용자가 조치할 정확한 화면(§2.3). group_id 가 없으면 운영자 알림. + * 외부 이메일은 setMailer(fn) 로 끼운 발송기(포털이 AriMail/Resend 로 보냄)가 담당하며, 실패해도 알림 자체는 남는다. + */ +import { newId } from './crypto.mjs'; +import { redact } from './redact.mjs'; +import { nowIso, toJson, parseJson } from './db.mjs'; + +export const MIGRATIONS = [{ id: 'lib_notify_0001', sql: ` +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, group_id TEXT, user_id TEXT, category TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'info', mandatory INTEGER NOT NULL DEFAULT 0, + incident_key TEXT, title TEXT NOT NULL, body TEXT, link TEXT, subject_type TEXT, subject_id TEXT, action_required TEXT, + state TEXT NOT NULL DEFAULT 'open', count INTEGER NOT NULL DEFAULT 1, first_at TEXT NOT NULL, last_at TEXT NOT NULL, read_at TEXT, resolved_at TEXT, email_sent_at TEXT, email_error TEXT, detail_json TEXT +); +CREATE INDEX IF NOT EXISTS notif_group ON notifications(group_id, state, last_at); +CREATE INDEX IF NOT EXISTS notif_incident ON notifications(incident_key, state);` }]; + +let mailer = null; +/** 외부 이메일 발송기 등록: async ({groupId, userId, title, body, link}) => void */ +export function setMailer(fn) { mailer = fn; } + +/** + * 알림 만들기(또는 같은 사건 갱신). + * @returns {{id:string, merged:boolean}} + */ +export function notify(store, n) { + const now = nowIso(); const detail = toJson(redact(n.detail || null)); + if (n.incidentKey) { + const open = store.get('SELECT id, count FROM notifications WHERE incident_key=? AND state=\'open\'', n.incidentKey); + if (open) { store.run('UPDATE notifications SET count=count+1, last_at=?, title=?, body=?, link=COALESCE(?, link), severity=?, action_required=?, detail_json=?, read_at=NULL WHERE id=?', now, n.title, n.body || null, n.link || null, n.severity || 'info', n.actionRequired || null, detail, open.id); return { id: open.id, merged: true }; } + } + const id = newId('n_'); + store.run('INSERT INTO notifications(id, group_id, user_id, category, severity, mandatory, incident_key, title, body, link, subject_type, subject_id, action_required, state, count, first_at, last_at, detail_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,\'open\',1,?,?,?)', + id, n.groupId || null, n.userId || null, n.category, n.severity || 'info', n.mandatory ? 1 : 0, n.incidentKey || null, n.title, n.body || null, n.link || null, n.subjectType || null, n.subjectId || null, n.actionRequired || null, now, now, detail); + // 필수 알림·critical 은 외부 이메일(§24.1). 발송 실패는 email_error 에 남긴다(알림은 유지). + if ((n.mandatory || n.severity === 'critical' || n.email) && mailer) { + Promise.resolve().then(() => mailer({ groupId: n.groupId, userId: n.userId, title: n.title, body: n.body, link: n.link })) + .then(() => store.run('UPDATE notifications SET email_sent_at=? WHERE id=?', nowIso(), id)) + .catch((e) => { try { store.run('UPDATE notifications SET email_error=? WHERE id=?', String(e.message || e).slice(0, 300), id); } catch (_) {} }); + } + return { id, merged: false }; +} +/** 사건 종료(같은 incident_key 의 열린 알림을 resolved 로) */ +export function resolveIncident(store, incidentKey, note = '') { return store.run('UPDATE notifications SET state=\'resolved\', resolved_at=?, body=CASE WHEN ?<>\'\' THEN ? ELSE body END WHERE incident_key=? AND state=\'open\'', nowIso(), note, note, incidentKey).changes; } +/** 목록(그룹 기준, 최신순). unreadOnly / category 필터 */ +export function listNotifications(store, { groupId, userId, category, unreadOnly = false, limit = 100 } = {}) { + const w = []; const p = []; + if (groupId !== undefined) { w.push(groupId === null ? 'group_id IS NULL' : '(group_id=? OR (group_id IS NULL AND user_id=?))'); if (groupId !== null) p.push(groupId, userId || ''); } + if (category) { w.push('category=?'); p.push(category); } + if (unreadOnly) w.push('read_at IS NULL'); + return store.all(`SELECT * FROM notifications ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY last_at DESC LIMIT ?`, ...p, Math.min(limit, 500)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) })); +} +export function markRead(store, id, groupId) { return store.run('UPDATE notifications SET read_at=? WHERE id=? AND (group_id=? OR group_id IS NULL) AND read_at IS NULL', nowIso(), id, groupId || null).changes; } +export function unreadCount(store, groupId) { return store.get('SELECT COUNT(*) AS n FROM notifications WHERE group_id=? AND read_at IS NULL AND state=\'open\'', groupId)?.n || 0; } diff --git a/lib/quota.mjs b/lib/quota.mjs new file mode 100644 index 0000000..b1be16e --- /dev/null +++ b/lib/quota.mjs @@ -0,0 +1,150 @@ +/** + * lib/quota.mjs — 상품 권리(entitlement)·서비스별 배정(allocation)·진행 중 예약(reservation) (§13·§14·§6.4). + * + * 세 층 + * entitlement 그룹이 구매해 활성화된 총량(상품정책 버전 참조). 결제·승인 전에는 만들지 않는다(§6.2). + * allocation 사이트·메일 서비스에 배정된 양(사용 중). 실제 사용량(usage)보다 작게 줄일 수 없다. + * reservation 진행 중 작업(최초 공개 배포·메일 생성 등)이 잡아 둔 양. 성공하면 commit → allocation, 실패·취소·되돌리기면 release → 반환. + * 불변식: Σ(allocation) + Σ(reserved) ≤ Σ(active entitlement). reserve/commit/setAllocation 은 BEGIN IMMEDIATE 트랜잭션 안에서 검사해 + * 마지막 남은 단위를 두 작업이 동시에 요청하면 하나만 성공한다(§13.3, 검증 27). + * 자원 종류(RESOURCES): sites(개) web_disk(바이트) web_out(바이트/월) mail_storage(바이트) mail_msgs(통/월) mailboxes(개) mail_services(개) source(개) backup(개) + */ +import { newId } from './crypto.mjs'; +import { nowIso } from './db.mjs'; + +export const RESOURCES = Object.freeze({ + sites: { unit: 'count', label: '운영 사이트 수' }, web_disk: { unit: 'bytes', label: '웹 디스크' }, web_out: { unit: 'bytes_month', label: '월간 웹 아웃바운드' }, + mail_storage: { unit: 'bytes', label: '메일 저장 공간' }, mail_msgs: { unit: 'count_month', label: '월간 메일 통수(송신+수신)' }, mailboxes: { unit: 'count', label: '메일함 수' }, + mail_services: { unit: 'count', label: '독립 메일 서비스' }, source: { unit: 'count', label: 'Source 옵션' }, backup: { unit: 'count', label: '사이트 백업 옵션' }, +}); +export const DEFAULT_WARN_LEVELS = [70, 80, 90, 95]; + +export const MIGRATIONS = [{ id: 'lib_quota_0001', sql: ` +CREATE TABLE IF NOT EXISTS quota_entitlements ( + id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, source_type TEXT, source_id TEXT, product_version_id TEXT, + starts_at TEXT NOT NULL, ends_at TEXT, note TEXT, created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS qe_group ON quota_entitlements(group_id, resource); +CREATE TABLE IF NOT EXISTS quota_allocations ( + id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, amount INTEGER NOT NULL, + state TEXT NOT NULL DEFAULT 'in_use', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, return_due_at TEXT, + UNIQUE(group_id, resource, subject_type, subject_id) +); +CREATE TABLE IF NOT EXISTS quota_reservations ( + id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'reserved', + subject_type TEXT, subject_id TEXT, job_id TEXT, reason TEXT, created_at TEXT NOT NULL, expires_at TEXT, committed_at TEXT, released_at TEXT, return_due_at TEXT +); +CREATE INDEX IF NOT EXISTS qr_group ON quota_reservations(group_id, resource, state); +CREATE TABLE IF NOT EXISTS quota_usage ( + group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, used INTEGER NOT NULL, period_start TEXT, measured_at TEXT NOT NULL, source TEXT, + PRIMARY KEY(group_id, resource, subject_type, subject_id) +);` }]; + +export class QuotaError extends Error { constructor(code, message, detail) { super(message); this.code = code; this.status = 409; this.detail = detail; } } + +/** 권리 부여(주문 승인 후). ends_at 이 없으면 무기한(갱신 때 갱신). */ +export function grantEntitlement(store, { groupId, resource, amount, sourceType = 'order', sourceId = null, productVersionId = null, startsAt = null, endsAt = null, note = null }) { + if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`); + const id = newId('qe_'); store.run('INSERT INTO quota_entitlements(id, group_id, resource, amount, source_type, source_id, product_version_id, starts_at, ends_at, note, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, groupId, resource, Math.round(amount), sourceType, sourceId, productVersionId, startsAt || nowIso(), endsAt, note, nowIso()); return id; +} +/** 권리 종료(해지·축소 적용) */ +export function endEntitlement(store, id, at = nowIso()) { return store.run('UPDATE quota_entitlements SET ends_at=? WHERE id=? AND (ends_at IS NULL OR ends_at>?)', at, id, at).changes; } + +const sumSql = { purchased: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_entitlements WHERE group_id=? AND resource=? AND starts_at<=? AND (ends_at IS NULL OR ends_at>?)', allocated: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_allocations WHERE group_id=? AND resource=? AND state IN (\'in_use\',\'returning\')', reserved: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_reservations WHERE group_id=? AND resource=? AND state=\'reserved\' AND (expires_at IS NULL OR expires_at>?)' }; +/** 자원 하나의 수치 */ +export function figures(store, groupId, resource, at = nowIso()) { + const purchased = store.get(sumSql.purchased, groupId, resource, at, at).n; const allocated = store.get(sumSql.allocated, groupId, resource).n; const reserved = store.get(sumSql.reserved, groupId, resource, at).n; + const used = store.get('SELECT COALESCE(SUM(used),0) AS n FROM quota_usage WHERE group_id=? AND resource=?', groupId, resource).n; + return { resource, unit: RESOURCES[resource].unit, label: RESOURCES[resource].label, purchased, allocated, reserved, unallocated: Math.max(0, purchased - allocated - reserved), used }; +} +/** 그룹 전체 요약(§13.2) */ +export function summary(store, groupId, at = nowIso()) { return Object.keys(RESOURCES).map((r) => figures(store, groupId, r, at)); } + +/** + * 예약(§13.3). 트랜잭션 안에서 가용량을 검사한다. 부족하면 QuotaError('quota_insufficient', shortage). + * 같은 subject·resource 의 활성 예약이 있으면 그것을 돌려준다(중복 예약 금지). + */ +export function reserve(store, { groupId, resource, amount, subjectType = null, subjectId = null, jobId = null, reason = null, ttlMs = 24 * 3600 * 1000 }) { + if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`); + amount = Math.round(amount); if (amount <= 0) throw new QuotaError('bad_amount', '예약량은 0보다 커야 합니다'); + return store.tx(() => { + if (subjectId) { const ex = store.get('SELECT * FROM quota_reservations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=? AND state=\'reserved\'', groupId, resource, subjectType, subjectId); if (ex) return { ...ex, existing: true }; } + const f = figures(store, groupId, resource); + if (amount > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label}이(가) 부족합니다 (필요 ${amount}, 사용 가능 ${f.unallocated})`, { resource, requested: amount, available: f.unallocated, shortage: amount - f.unallocated, purchased: f.purchased, allocated: f.allocated, reserved: f.reserved }); + const id = newId('qr_'); const now = nowIso(); + store.run('INSERT INTO quota_reservations(id, group_id, resource, amount, state, subject_type, subject_id, job_id, reason, created_at, expires_at) VALUES(?,?,?,?,\'reserved\',?,?,?,?,?,?)', id, groupId, resource, amount, subjectType, subjectId, jobId, reason, now, ttlMs ? new Date(Date.now() + ttlMs).toISOString() : null); + return store.get('SELECT * FROM quota_reservations WHERE id=?', id); + }); +} +/** 예약 확정 → 배정(in_use). 배정이 이미 있으면 양을 더한다. */ +export function commit(store, reservationId) { + return store.tx(() => { + const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) throw new QuotaError('reservation_not_found', '예약이 없습니다'); + if (r.state === 'in_use') return r; + if (r.state !== 'reserved') throw new QuotaError('reservation_not_active', `예약 상태(${r.state})에서는 확정할 수 없습니다`); + const now = nowIso(); + store.run('UPDATE quota_reservations SET state=\'in_use\', committed_at=? WHERE id=?', now, r.id); + const a = store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id); + if (a) store.run('UPDATE quota_allocations SET amount=amount+?, state=\'in_use\', updated_at=? WHERE id=?', r.amount, now, a.id); + else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id, r.amount, now, now); + return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id); + }); +} +/** 예약 반환(실패·취소·되돌리기). returning=true 면 정리 전 '반환 중' 으로 두고 returnDueAt 을 표시한다(§11.2 "반환 예정 시각"). */ +export function release(store, reservationId, { returning = false, returnDueAt = null } = {}) { + const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) return null; + if (r.state === 'returned') return r; + if (returning) store.run('UPDATE quota_reservations SET state=\'returning\', return_due_at=? WHERE id=?', returnDueAt, r.id); + else store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', nowIso(), r.id); + return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id); +} +/** 만료된 예약 자동 반환(주기 작업). 반환된 예약 목록을 돌려줘 호출자가 알림을 만든다. */ +export function expireReservations(store, at = nowIso()) { + const rows = store.all('SELECT * FROM quota_reservations WHERE state=\'reserved\' AND expires_at IS NOT NULL AND expires_at<=?', at); + for (const r of rows) store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', at, r.id); + return rows; +} +/** 대상의 배정 읽기 */ +export function allocationOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; } +/** + * 배정량 변경(§13.3). 늘릴 때는 미배정량 안에서, 줄일 때는 실제 사용량(usedNow)과 이번 달 누적(monthUsed, 월간 자원)보다 작게 할 수 없다. + * @returns {{allocation:object, before:number, after:number}} + */ +export function setAllocation(store, { groupId, resource, subjectType, subjectId, amount, usedNow = 0, monthUsed = 0 }) { + if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`); + amount = Math.round(amount); if (amount < 0) throw new QuotaError('bad_amount', '배정량은 0 이상이어야 합니다'); + return store.tx(() => { + const a = allocationOf(store, groupId, resource, subjectType, subjectId); const before = a ? a.amount : 0; const f = figures(store, groupId, resource); + const floor = RESOURCES[resource].unit.endsWith('_month') ? Math.max(usedNow, monthUsed) : usedNow; + if (amount < floor) throw new QuotaError('allocation_below_usage', `현재 사용량(${floor})보다 작게 줄일 수 없습니다. 먼저 ${floor - amount} 만큼 정리하세요`, { resource, requested: amount, floor, cleanupNeeded: floor - amount }); + const delta = amount - before; + if (delta > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label} 미배정량(${f.unallocated})을 넘습니다`, { resource, requested: delta, available: f.unallocated, shortage: delta - f.unallocated }); + const now = nowIso(); + if (a) store.run('UPDATE quota_allocations SET amount=?, state=\'in_use\', updated_at=? WHERE id=?', amount, now, a.id); + else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), groupId, resource, subjectType, subjectId, amount, now, now); + return { allocation: allocationOf(store, groupId, resource, subjectType, subjectId), before, after: amount }; + }); +} +/** 배정 반환 시작(반환 중) / 완료(삭제). §14 사이트 수 반환은 모든 조건이 충족된 뒤 finish 를 부른다. */ +export function beginReturn(store, allocationId, returnDueAt = null) { return store.run('UPDATE quota_allocations SET state=\'returning\', return_due_at=?, updated_at=? WHERE id=? AND state=\'in_use\'', returnDueAt, nowIso(), allocationId).changes; } +export function finishReturn(store, allocationId) { return store.run('DELETE FROM quota_allocations WHERE id=?', allocationId).changes; } +export function cancelReturn(store, allocationId) { return store.run('UPDATE quota_allocations SET state=\'in_use\', return_due_at=NULL, updated_at=? WHERE id=? AND state=\'returning\'', nowIso(), allocationId).changes; } +/** 실제 사용량 기록(계측 결과). 월간 자원은 period_start(KST 월 시작) 를 함께 둔다. */ +export function recordUsage(store, { groupId, resource, subjectType, subjectId, used, periodStart = null, source = null, measuredAt = nowIso() }) { + store.run('INSERT INTO quota_usage(group_id, resource, subject_type, subject_id, used, period_start, measured_at, source) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(group_id, resource, subject_type, subject_id) DO UPDATE SET used=excluded.used, period_start=excluded.period_start, measured_at=excluded.measured_at, source=excluded.source', groupId, resource, subjectType, subjectId, Math.round(used), periodStart, measuredAt, source); +} +export function usageOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_usage WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; } +/** 서비스별 배정·사용 목록(화면: 사이트별 배정량·사용량) */ +export function listAllocations(store, groupId, resource = null) { + return store.all(`SELECT a.*, u.used, u.measured_at FROM quota_allocations a LEFT JOIN quota_usage u ON u.group_id=a.group_id AND u.resource=a.resource AND u.subject_type=a.subject_type AND u.subject_id=a.subject_id WHERE a.group_id=? ${resource ? 'AND a.resource=?' : ''} ORDER BY a.resource, a.created_at`, ...(resource ? [groupId, resource] : [groupId])); +} +export function listReservations(store, groupId, { active = true } = {}) { return store.all(`SELECT * FROM quota_reservations WHERE group_id=? ${active ? 'AND state IN (\'reserved\',\'returning\')' : ''} ORDER BY created_at DESC`, groupId); } +/** + * 경고 구간(§13.4). 넘은 가장 높은 구간을 돌려준다(0 = 정상, 100 = 제한). + * @param {number} used @param {number} limit 0 이면 제한 없음 → 0 + */ +export function warnLevel(used, limit, levels = DEFAULT_WARN_LEVELS) { + if (!limit || limit <= 0) return 0; const pct = (used / limit) * 100; if (pct >= 100) return 100; + let lvl = 0; for (const l of levels) if (pct >= l) lvl = l; return lvl; +} +export const percent = (used, limit) => (limit > 0 ? Math.min(999, Math.round((used / limit) * 1000) / 10) : 0); diff --git a/lib/redact.mjs b/lib/redact.mjs new file mode 100644 index 0000000..0ad2b8f --- /dev/null +++ b/lib/redact.mjs @@ -0,0 +1,45 @@ +/** + * lib/redact.mjs — 비밀값 가리기(§37). + * + * 감사 기록·알림·작업 결과·오류 메시지·로그에 들어가는 모든 객체는 이 함수를 거친다. + * - 키 이름이 비밀로 보이면(password, secret, token, key, credential, cookie, authorization, recovery, otp, privkey …) 값을 '[redacted]' 로 바꾼다. + * - 문자열 값이 비밀 형태(sk-…, re_…, au_…, JWT, 40자 이상 base64url/hex, PEM 블록)면 가린다. + * - 깊이 6 까지 재귀. 배열·객체·문자열 모두. + */ +const KEY_RE = /pass(word|wd)?|secret|token|api[-_]?key|private[-_]?key|privkey|credential|cookie|authorization|recovery|otp|totp|session|vault|dkim|\bkey\b/i; +const VALUE_RES = [ + /^(sk|rk|re|au|aw|ag|ghp|gho|xox[a-z])[-_][A-Za-z0-9_-]{16,}$/, // 서비스 키 접두어 형식 + /^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$/, // JWT + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM 개인키 + /^[A-Za-z0-9_-]{40,}$/, // 긴 무작위 토큰 + /^[a-f0-9]{48,}$/i, // 긴 hex 비밀 +]; +const KEEP_KEYS = /^(id|_id|site_id|group_id|user_id|job_id|idempotency_?key|incident_?key|public_?key_?fingerprint|key_?name|keyName|kind|resource)$/i; + +/** 값 하나가 비밀 형태인지 */ +export function looksSecret(v) { if (typeof v !== 'string' || v.length < 12) return false; return VALUE_RES.some((re) => re.test(v)); } + +/** + * 객체/배열/문자열에서 비밀값을 가린다. 원본은 바꾸지 않는다. + * @param {*} v + * @param {number} depth 내부 재귀 깊이 + */ +export function redact(v, depth = 0) { + if (depth > 6) return '[depth]'; + if (v == null) return v; + if (typeof v === 'string') return looksSecret(v) ? '[redacted]' : v; + if (Array.isArray(v)) return v.map((x) => redact(x, depth + 1)); + if (typeof v === 'object') { + const out = {}; + for (const [k, val] of Object.entries(v)) { + if (!KEEP_KEYS.test(k) && KEY_RE.test(k)) { out[k] = val == null || val === '' ? val : '[redacted]'; continue; } + out[k] = redact(val, depth + 1); + } + return out; + } + return v; +} +/** 오류 객체를 기록용 안전 문자열로 */ +export function safeError(e) { const m = e && e.message ? String(e.message) : String(e || ''); return redact(m.replace(/(Bearer\s+)[A-Za-z0-9._-]+/g, '$1[redacted]')); } +/** 비밀값의 앞뒤만 보이는 힌트(예: au_12…9f) — 화면에 "등록됨" 을 보여 주되 값은 못 알아보게 */ +export function hint(secret) { const s = String(secret || ''); if (s.length < 10) return s ? '••••' : ''; return `${s.slice(0, 4)}…${s.slice(-2)}`; } diff --git a/lib/session.mjs b/lib/session.mjs new file mode 100644 index 0000000..be24e97 --- /dev/null +++ b/lib/session.mjs @@ -0,0 +1,64 @@ +/** + * lib/session.mjs — DB 세션(기기 목록·개별/전체 종료 §3.2)과 CSRF. + * + * 쿠키에는 무작위 토큰만 두고 서버는 sha256 해시로 찾는다(DB 가 유출돼도 세션을 위조할 수 없다). + * kind: 'customer'(고객 포털) | 'operator'(운영자 콘솔) | 'setup'. 종류가 다른 세션으로는 상대편 화면을 열 수 없다(§25). + * CSRF: 세션마다 무작위 csrf 값을 두고 변경 요청은 `X-CSRF` 헤더로 보낸다(상수 시간 비교). + * 재인증(§25 고위험 작업): reauth_at 을 두고 requireRecentAuth(maxAgeMs) 로 검사한다. + */ +import { randomToken, sha256, safeEqual, newId } from './crypto.mjs'; +import { nowIso, parseJson, toJson } from './db.mjs'; + +export const MIGRATIONS = [{ id: 'lib_sessions_0001', sql: ` +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL, kind TEXT NOT NULL, + csrf TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, reauth_at TEXT, + ip TEXT, ua TEXT, label TEXT, revoked_at TEXT, data_json TEXT +); +CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id, kind);` }]; + +const COOKIE = { customer: 'aw_session', operator: 'aw_ops', setup: 'aw_setup' }; +export const cookieName = (kind) => COOKIE[kind] || 'aw_session'; + +/** 세션 만들기. 반환 { id, token(원문, 쿠키용), csrf, expiresAt } */ +export function createSession(store, { userId, kind = 'customer', ip = '', ua = '', days = 30, data = null, reauth = true }) { + const token = randomToken(32); const id = newId('s_'); const now = nowIso(); + const expiresAt = new Date(Date.now() + days * 86400000).toISOString(); const csrf = randomToken(24); + store.run('INSERT INTO sessions(id, token_hash, user_id, kind, csrf, created_at, expires_at, last_seen_at, reauth_at, ip, ua, label, data_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)', + id, sha256(token), userId, kind, csrf, now, expiresAt, now, reauth ? now : null, ip, String(ua || '').slice(0, 200), deviceLabel(ua), toJson(data)); + return { id, token, csrf, expiresAt }; +} +/** 토큰(쿠키 값)으로 세션을 찾는다. 만료·종료된 세션은 null. 찾으면 last_seen 을 갱신(1분에 한 번만). */ +export function resolveSession(store, token, kind) { + if (!token) return null; + const s = store.get('SELECT * FROM sessions WHERE token_hash=? AND kind=?', sha256(token), kind); + if (!s || s.revoked_at || Date.parse(s.expires_at) < Date.now()) return null; + if (Date.now() - Date.parse(s.last_seen_at) > 60000) store.run('UPDATE sessions SET last_seen_at=? WHERE id=?', nowIso(), s.id); + return { ...s, data: parseJson(s.data_json, {}) }; +} +/** 세션 데이터(현재 그룹 등) 갱신 */ +export function updateSessionData(store, id, patch) { const s = store.get('SELECT data_json FROM sessions WHERE id=?', id); const d = { ...parseJson(s?.data_json, {}), ...patch }; store.run('UPDATE sessions SET data_json=? WHERE id=?', toJson(d), id); return d; } +/** 재인증 기록(고위험 작업 전 비밀번호 재확인) */ +export function markReauth(store, id) { store.run('UPDATE sessions SET reauth_at=? WHERE id=?', nowIso(), id); } +/** 최근 재인증 여부 */ +export function recentlyAuthed(session, maxAgeMs = 10 * 60 * 1000) { return !!(session && session.reauth_at && Date.now() - Date.parse(session.reauth_at) < maxAgeMs); } +/** 개별 세션 종료 */ +export function revokeSession(store, id, userId) { return store.run('UPDATE sessions SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL', nowIso(), id, userId).changes; } +/** 다른 모든 세션 종료(현재 세션 제외 가능). kind 를 주면 그 종류만 */ +export function revokeOtherSessions(store, userId, keepId = null, kind = null) { + return store.run(`UPDATE sessions SET revoked_at=? WHERE user_id=? AND revoked_at IS NULL ${keepId ? 'AND id<>?' : ''} ${kind ? 'AND kind=?' : ''}`, ...[nowIso(), userId, ...(keepId ? [keepId] : []), ...(kind ? [kind] : [])]).changes; +} +/** 사용자의 활성 세션 목록(기기 화면용, 토큰 없음) */ +export function listSessions(store, userId, kind) { + return store.all('SELECT id, created_at, last_seen_at, expires_at, ip, label FROM sessions WHERE user_id=? AND kind=? AND revoked_at IS NULL AND expires_at>? ORDER BY last_seen_at DESC', userId, kind, nowIso()); +} +/** CSRF 검사 */ +export function csrfOk(session, token) { return !!(session && token && safeEqual(session.csrf, token)); } +/** UA 에서 기기 표시 이름 (대략) */ +function deviceLabel(ua) { + const s = String(ua || ''); const os = /Windows/.test(s) ? 'Windows' : /Android/.test(s) ? 'Android' : /iPhone|iPad/.test(s) ? 'iOS' : /Mac OS/.test(s) ? 'macOS' : /Linux/.test(s) ? 'Linux' : '기기'; + const br = /Edg\//.test(s) ? 'Edge' : /Chrome\//.test(s) ? 'Chrome' : /Safari\//.test(s) ? 'Safari' : /Firefox\//.test(s) ? 'Firefox' : '브라우저'; + return `${os} · ${br}`; +} +/** 만료·종료된 세션 정리(주기 작업) */ +export function purgeSessions(store, olderThanDays = 7) { const cut = new Date(Date.now() - olderThanDays * 86400000).toISOString(); return store.run('DELETE FROM sessions WHERE expires_at staleAfterMs; } diff --git a/lib/test.mjs b/lib/test.mjs new file mode 100644 index 0000000..a32a2cc --- /dev/null +++ b/lib/test.mjs @@ -0,0 +1,26 @@ +/** + * lib/test.mjs — 작은 테스트 러너(의존성 없음). 형제 제품(AriMail)과 같은 방식: 파일을 node 로 직접 실행하면 순서대로 돈다. + * import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs'; + * await test('이름', async () => { ... }); 실패하면 즉시 요약을 내고 종료 코드 1. + * done(); 마지막에 요약 출력. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let passed = 0; let failed = 0; const dirs = []; +export async function test(name, fn) { + const t0 = Date.now(); + try { await fn(); passed++; console.log(` ✔ ${name} (${Date.now() - t0}ms)`); } + catch (e) { failed++; console.error(` ✘ ${name}\n ${e && e.stack ? e.stack.split('\n').slice(0, 6).join('\n ') : e}`); process.exitCode = 1; } +} +export function eq(a, b, msg = '') { const sa = JSON.stringify(a); const sb = JSON.stringify(b); if (sa !== sb) throw new Error(`${msg ? msg + ': ' : ''}기대 ${sb} 실제 ${sa}`); } +export function ok(v, msg = '') { if (!v) throw new Error(`${msg || '참이어야 합니다'} (실제 ${JSON.stringify(v)})`); } +export async function rejects(fn, codeOrRe, msg = '') { + try { await fn(); } catch (e) { if (codeOrRe == null) return e; const s = `${e.code || ''} ${e.message || ''}`; if (codeOrRe instanceof RegExp ? codeOrRe.test(s) : (e.code === codeOrRe || String(e.message).includes(codeOrRe))) return e; throw new Error(`${msg || '다른 오류'}: 기대 ${codeOrRe} 실제 ${s}`); } + throw new Error(`${msg || '예외가 나야 합니다'}`); +} +/** 테스트용 임시 디렉터리(종료 시 삭제) */ +export function tmpDir(prefix = 'aw-test-') { const d = mkdtempSync(join(tmpdir(), prefix)); dirs.push(d); return d; } +export function done() { for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }); } catch (_) {} } console.log(`${failed ? '✘' : '✔'} 통과 ${passed} · 실패 ${failed}`); if (failed) process.exit(1); } +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); diff --git a/lib/vault.mjs b/lib/vault.mjs new file mode 100644 index 0000000..5e7987f --- /dev/null +++ b/lib/vault.mjs @@ -0,0 +1,41 @@ +/** + * lib/vault.mjs — 비밀값 보관소(AES-256-GCM). + * + * 서비스 키·API 키·Agent 비밀·SSO 공유 비밀처럼 "저장 후 다시 볼 수 없고 재발급만 되는" 값을 둔다(§37). + * - 암호화 키: 데이터 디렉터리의 `vault.key`(32바이트, 0600). 없으면 처음 열 때 만든다. 환경 변수 ARIWEB_VAULT_KEY(hex) 가 있으면 그것을 쓴다(재해복구 때 같은 키로 복원). + * - 저장 파일: `vault.json` — { items: { name: { iv, tag, data, hint, updatedAt, kind } } }. 원자적 쓰기(tmp → rename). + * - API: set(name, secret, {kind}) / get(name) / has(name) / remove(name) / list() (이름·힌트·시각만, 값 없음) + */ +import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; +import { hint } from './redact.mjs'; + +export function openVault(dir) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const keyFile = join(dir, 'vault.key'); const file = join(dir, 'vault.json'); + let key; + if (process.env.ARIWEB_VAULT_KEY) key = Buffer.from(process.env.ARIWEB_VAULT_KEY, 'hex'); + else if (existsSync(keyFile)) key = Buffer.from(readFileSync(keyFile, 'utf8').trim(), 'hex'); + else { key = randomBytes(32); writeFileSync(keyFile, key.toString('hex'), { mode: 0o600 }); } + if (key.length !== 32) throw new Error('vault 키 길이가 올바르지 않습니다(32바이트 hex 필요)'); + const load = () => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch (_) { return { items: {} }; } }; + const save = (d) => { const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(d, null, 1), { mode: 0o600 }); renameSync(tmp, file); try { chmodSync(file, 0o600); } catch (_) {} }; + return { + dir, file, + set(name, secret, { kind = 'secret' } = {}) { + const iv = randomBytes(12); const c = createCipheriv('aes-256-gcm', key, iv); + const data = Buffer.concat([c.update(String(secret), 'utf8'), c.final()]); + const d = load(); d.items[name] = { kind, iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64'), hint: hint(secret), updatedAt: new Date().toISOString() }; save(d); + return { name, hint: d.items[name].hint }; + }, + get(name) { + const it = load().items[name]; if (!it) return null; + const dc = createDecipheriv('aes-256-gcm', key, Buffer.from(it.iv, 'base64')); dc.setAuthTag(Buffer.from(it.tag, 'base64')); + return Buffer.concat([dc.update(Buffer.from(it.data, 'base64')), dc.final()]).toString('utf8'); + }, + has: (name) => !!load().items[name], + remove(name) { const d = load(); const had = !!d.items[name]; delete d.items[name]; save(d); return had; }, + list() { return Object.entries(load().items).map(([name, it]) => ({ name, kind: it.kind, hint: it.hint, updatedAt: it.updatedAt })); }, + }; +} diff --git a/modules/arimail/README.md b/modules/arimail/README.md new file mode 100644 index 0000000..58b096e --- /dev/null +++ b/modules/arimail/README.md @@ -0,0 +1,68 @@ +# AriMail — mail.arihakdang.com / mail.heart-m.com + +**AriMail**(1.0.0 까지의 이름은 mail-gateway, 저장소 https://source.ibit.kr/OZ-Group/AriMail)은 웹 메일함과 IMAP·POP3·SMTP 서버를 갖춘 메일 게이트웨이입니다. +수신·발신은 **Resend** 또는 **내장 Haraka**(직접 수신 MX / 직접 발송) 또는 외부 SMTP 중계 중에서 설정으로 고릅니다. www.arihakdang.com 관리자 메일함(`server/mailbox.mjs`)을 참고해 +별도 사이트로 분리했고, 다음을 더합니다. + +- **설정 파일과 초기 설정 페이지**(1.0.0~): Resend 키·중계 서버·Haraka·사이트 정보 같은 운영 설정은 `data/config.json` 에 두고, 배포 뒤 전용 포트(8791, 토큰)의 초기 설정 페이지나 `/admin → 메일 서비스 설정` 에서 채웁니다(재시작 없음). 환경 파일에는 배포에 필요한 값만 남습니다. +- **발송 모듈화**(1.0.0~): `server/providers/`(resend · smtp 중계 · 내장 Haraka)와 `sendMail()` 한 지점. 새 서비스는 모듈 하나를 더해 끼웁니다. +- **자동 설정·DNS 모듈**(1.0.5~): 설정 화면의 "자동 설정" 이 Resend 도메인 등록·검증·웹훅(비밀키 자동 저장), DNS 레코드(Google Cloud DNS / AWS Route 53 자동 적용, 그 밖에는 안내), 내장 Haraka 준비(DKIM·PTR·설치)를 진단·실행하고 상황별 안내 끝에 "메일 사용 준비 완료" 를 알립니다. +- **내장 Haraka**(1.0.0~): `haraka/`(run.mjs 관리 프로세스 + arimail 플러그인)와 `deploy/haraka-apply.sh`. 수신(25 → 게이트웨이 저장)과 발신(로컬 제출 포트 → MX 직접 배달, DKIM 서명)을 설정으로 켭니다. + +- **이중 수신**: Resend 웹훅(`email.received`) + 1시간마다 수신 목록 동기화(웹 메일함을 열어 두면 1분마다 빠른 확인). 같은 메일은 한 번만 저장. +- **원본 보관**: Resend 가 서명 URL 로 주는 원본 `.eml` 을 그대로 저장(없으면 본문·헤더·첨부로 재구성). 첨부 바이트도 함께 보관. +- **웹 메일함**: 3단(폴더·목록·읽기 창) 레이아웃, 중요 표시·읽지 않음·첨부 필터, 일괄 삭제/이동/읽음, 답장·전체답장·전달, 원본 다운로드, 첨부. +- **HTML 편집기**: 글꼴·크기·색·강조·정렬·목록·링크·인용·구분선·본문 이미지(cid 인라인 첨부로 발송)·HTML 소스 편집, 임시 저장 자동화. +- **관리자 페이지** `/admin`(0.8.4~): 별도 로그인(같은 관리자 계정)을 거치는 고정 주소. 게이트웨이 사용자 계정(로그인 ID·이름·비밀번호·역할·상태·POP/IMAP 허용)을 추가·수정·삭제하고 서버 상태를 봅니다. 메일함 주소는 `로그인ID@도메인` 으로 자동 지정. 웹·POP·IMAP·SMTP 동일 인증. +- **IMAP**: `mail.arihakdang.com:993`(TLS)/`143`(STARTTLS). INBOX/Sent/Drafts/Trash 가 웹 메일함과 같은 저장소. 읽음·플래그·이동·삭제 공유, IDLE·MOVE·UIDPLUS·APPEND. +- **POP3**: `mail.arihakdang.com:995`(TLS)/`110`(STLS, 관리자 설정에 따라 일반 접속 로그인도 허용 — Gmail 의 POP 가져오기용). 계정은 www 관리자 계정 그대로. 한 번 내려준 메일은 다시 주지 않음. +- **SMTP 제출**(0.8.1~): `mail.arihakdang.com:465`(TLS)/`587`(STARTTLS). 웹 메일과 같은 아이디·비밀번호로 인증하고, 실제 발송은 서버 내부의 Resend API 키로 합니다(키는 사용자에게 노출되지 않음). 보낸 메일은 보낸 메일함에 바로 저장. +- **기한 없는 보관**(0.8.0~): 메일은 휴지통에서 영구 삭제할 때만 원본·첨부와 함께 지우고, 지운 메일은 재수신하지 않음. +- **설정 화면**: IMAP·SMTP·POP 메일 프로그램 설정 안내, 화면 기본값, 계정별 POP/IMAP 접속 허용 스위치. 로그인 화면의 (?) 접속 안내. 화면 상단에 버전 표시. +- **메일 전달·자동 응답**(0.8.16~): 설정 화면에서 받은 메일을 지정 주소(최대 5개)로 전부 전달하고, 지정한 메시지로 자동 회신(기한 없음 또는 날짜·시간 단위 기간)합니다. + 되풀이 방지(전달 표시 헤더·`Auto-Submitted`), 같은 상대 24시간 1회, 자동 발송 메일 제외, 처리 기록은 메일 상세에 표시. + +- **독립 설치**(0.8.18~): `WWW_DB_FILE` 을 비우면 www 사이트 없이 게이트웨이 계정만으로 동작합니다(첫 관리자 `admin`/`adminpasswd` 자동 생성(0.8.24~), `server/user-cli.mjs` 로 계정 추가·비밀번호 재설정, `SITE_TITLE`/`SITE_BRAND` 로 화면 이름 변경). +- **비밀번호 정책**(0.8.24~): 관리자 페이지에서 최소 글자 수(6~64, 기본 6)와 대·소문자 혼용·숫자·특수문자 요구를 켜고 끕니다. 예전의 10자 고정 규칙을 대체. +- **비밀번호 변경 보호·도메인 기반 서비스 이름·자동 새로고침**(0.8.25~): 자기 비밀번호 변경은 현재 비밀번호 확인 + 두 번 입력 + 보기 단추. 왼쪽 위 이름은 메일 도메인에서(`heart-m.com` → "HEART-M 메일"). 배포 뒤 열어 둔 화면은 `X-App-Version` 을 보고 스스로 새로 고침. + 배포 파일은 `deploy/sites/<이름>.env` 사이트 설정 + `.in` 템플릿이며, 현재 mail.arihakdang.com(www 연동)과 mail.heart-m.com(독립) 두 사이트가 있습니다. + +의존성 없음(Node 22.13+ 의 `node:sqlite`, `node:tls`). 문구는 `lang/ko.json` 한 곳에 둡니다. + +```bash +npm test # 인증·웹훅·동기화·보관·발송·POP3·IMAP·SMTP·설정/발송 모듈/Haraka 수신/초기 설정 회귀 테스트 +DB_FILE=./data/mail-gateway.sqlite MAILBOX=admin@example.test node server/index.mjs # 운영 설정은 ./data/config.json 과 초기 설정 페이지(8791) +``` + +설치·운영은 [docs/SETUP.md](docs/SETUP.md), 배포 파일은 `deploy/`, 변경 이력은 [CHANGELOG.md](CHANGELOG.md) 를 보세요. + +## 구조 + +| 파일 | 역할 | +|---|---| +| `server/index.mjs` | HTTP(127.0.0.1:8790) + POP3 + 주기 작업 | +| `server/api.mjs` | 로그인, 메일함 API, 설정 API, Resend 웹훅 | +| `server/store.mjs` | 수신 저장(웹훅/동기화), 원본, 시각 정규화, 세션 정리, 초안·발송 | +| `server/automation.mjs` | 수신 메일 자동 처리(0.8.16~): 메일 전달·자동 응답, 되풀이 방지 규칙, 처리 기록(auto_actions) | +| `server/smtp.mjs` | SMTP 제출 서버(EHLO/STARTTLS/AUTH PLAIN·LOGIN/MAIL/RCPT/DATA), 로그인 인증 후 내부 키로 Resend 발송·보낸 메일함 저장 | +| `server/pop3.mjs` | POP3 서버(USER/PASS, AUTH PLAIN, STLS, UIDL, TOP), 전달 기록 | +| `server/imap.mjs` | IMAP4rev1 서버(SELECT/FETCH/STORE/SEARCH/MOVE/COPY/EXPUNGE/APPEND/IDLE, STARTTLS) | +| `server/imap-mime.mjs` | 원본 MIME 해석: ENVELOPE, BODYSTRUCTURE, 섹션 추출, APPEND 요약 | +| `server/auth.mjs` | www 관리자 인증(PBKDF2, 별칭 admin) + 게이트웨이 계정(mail_users) 관리(회원 가입·승인 대기 포함), 세션, 계정별 설정(prefs: 외부 이미지·POP/IMAP·전달·자동 응답) | +| `server/files.mjs` | 0.9.3: 대용량 첨부파일 — 디스크 스트리밍 업로드, 발송 시 토큰 링크(/dl/<토큰>)와 본문 블록, 랜딩 페이지·횟수 차감 다운로드, 기한·횟수 만료 삭제·정리, 시스템 상한(LIMIT_LARGE_FILE_*)·관리자 설정(50% 기본) | +| `server/limits.mjs` | 0.9.0: 시스템 제한(환경 변수 LIMIT_*)·관리자 제한·계정별 메일함 크기, 사용량(MIME 기준)·발송/수신 제한 검사, POP/IMAP 서비스 스위치, 회원 가입 설정, 메일 현황 집계(일·주·월) | +| `server/user-cli.mjs` | 계정 명령줄 도구(0.8.18~): 독립 설치의 첫 관리자 생성, 비밀번호 재설정, 목록 | +| `server/config.mjs` | 1.0.0: 운영 설정 파일(config.json) — 스키마, 환경 변수 가져오기·우선순위, 저장 즉시 적용, 비밀값 가림, 초기 설정 토큰, DKIM 키·DNS 레코드 | +| `server/domains.mjs` | 1.0.7: 다중 도메인 — 도메인 등록부(data/domains.json)와 테넌트(도메인별 env: DB·설정·파일), Host·로그인 ID·주소로 도메인 고르기, 추가·중지·삭제, "메일 서비스 없음" 안내 화면 | +| `server/usage.mjs` | 1.0.9: 메일 사용량 정보 제공 API(`/api/usage`) — 도메인·계정별 디스크 사용량·메일 수·발송량(일/주/월·일별 추이), 비밀 키(Bearer)·허용 IP·시도 제한으로 보호된 읽기 전용([docs/USAGE-API.md](docs/USAGE-API.md)) | +| `server/webmaker.mjs`, `server/webmaker-snapshot.mjs` | 1.0.8: webmaker 인증 모드 — 웹메이커(WebHosting_AI_agent) 데이터 디렉터리를 읽기 전용 스냅샷으로 읽어(직접 또는 sudo 도우미) 같은 scrypt 로 비밀번호 검사, 도메인 ↔ 사이트 대응(사이트 사용자·소유자·웹메이커 관리자 → `아이디@도메인`), 관리자 페이지의 읽기 전용 행 | +| `server/setup.mjs` | 1.0.0: 초기 설정 페이지 API(/api/setup/*)와 전용 포트(8791, 토큰) 서버, Haraka 상태 읽기. 1.0.7: 전체 설정(도메인 목록·추가·중지, ?domain= 으로 도메인별 설정) | +| `server/dns/` | 1.0.5: DNS 모듈 — `index.mjs`(모듈 선택·필요한 레코드 계산·SPF 병합·적용·반영 확인), `google.mjs`(Cloud DNS, 서비스 계정 JWT), `route53.mjs`(Route 53, SigV4), `common.mjs`(공개 조회·PTR) | +| `server/provision.mjs` | 1.0.5: 자동 설정 계획 — 단계별 진단(buildPlan)과 실행(runPlan): Resend 도메인·검증·웹훅, DNS 적용, Haraka DKIM·설치·PTR, SMTP 중계 확인 | +| `server/providers/` | 1.0.0: 발송·수신 서비스 모듈 — `index.mjs`(sendMail 선택·상태), `resend.mjs`(Resend API, 옛 server/resend.mjs; 1.0.11: 발신 도메인 검증 상태 진단 `mail_domain_unverified`/`mail_domain_missing` — 레코드별 없음/값 다름, 자동 재검증), `smtp.mjs` + `smtp-client.mjs`(SMTP 중계·내장 Haraka 제출) | +| `haraka/` | 1.0.0: 내장 Haraka — `package.json`(Haraka + dkim 플러그인), `run.mjs`(설정 파일을 보고 Haraka 설정 생성·실행·상태 기록), `plugins/arimail.js`(게이트웨이 연결: relay 판정·RCPT 확인·원본 전달) | +| `web/setup.*` | 1.0.0: 메일 서비스 설정 화면(전용 포트와 /setup 공용). 1.0.7: 전용 포트에서는 전체 설정(도메인 카드 + 고른 도메인의 설정) | +| `server/mime.mjs` | RFC 5322 메시지 생성, POP 전송 정규화 | +| `server/sanitize.mjs` | 수신 HTML 정제(www 의 sanitize_mail.py 와 같은 허용 목록) | +| `web/` | 웹 메일 화면(로그인·회원 가입·접속 안내, 3단 메일함, HTML 편집기(8색 팔레트), 설정(비밀번호 변경·사용량·전달·자동 응답))과 관리자 페이지(/admin: 계정 관리·회원 가입/제한/서비스 설정, 서버 상태·메일 현황) | +| `deploy/` | 사이트 설정(`sites/*.env`, `new-site.sh` 로 생성), `nginx-apply.sh`(vhost 만 다시 적용, 0.9.5), `haraka-apply.sh`(내장 Haraka npm 설치·서비스 등록, 1.0.0), `domain-nginx.sh`(추가 도메인 호스트의 nginx vhost·인증서, 1.0.7), `webmaker-access.sh`(웹메이커 계정 읽기 도우미·sudoers, 1.0.8), `site-mail-path.sh`(사이트의 `/mail` 을 웹 메일로 — `nginx/mail-path.conf.in`, 1.0.8)과 템플릿(systemd·nginx·certbot 훅·환경 파일 `.in`), setup/deploy 스크립트(끝에 초기 설정 페이지 주소 출력), 인증서 자동 발급 타이머(`tls-when-ready.sh`). 설치 옵션 `LOGIN_MODE`(standalone 기본 / www / webmaker)와 시스템 제한 `LIMIT_*` | diff --git a/modules/arimail/deploy/deploy.sh b/modules/arimail/deploy/deploy.sh new file mode 100755 index 0000000..3801c90 --- /dev/null +++ b/modules/arimail/deploy/deploy.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# 배포 — 서버에서 실행 계정으로 실행: sudo -u env SOURCE_BUNDLE=<번들> /repo/deploy/deploy.sh +# 1. 소스 갱신(번들 또는 원격) 2. 테스트 3. 릴리스 복사 4. DB 백업·마이그레이션 5. 서비스 재시작(실패 시 이전 릴리스로 복귀) +# 사이트(서비스 이름·계정·경로·포트)는 /site.env(setup.sh 가 둠)에서 읽습니다. 없으면 arihakdang 설정으로 동작합니다(0.8.17 까지의 서버 호환). +set -euo pipefail +. "$(dirname "$0")/lib.sh" +APP="$(cd "$DEPLOY_DIR/../.." && pwd)" # /repo/deploy → +if [ -f "$APP/site.env" ]; then load_site "$APP/site.env"; else load_site "$DEPLOY_DIR/sites/arihakdang.env"; fi +APP="$APP_DIR"; REPO="$APP/repo"; RELEASES="$APP/releases" +[ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node)" +MAJOR="$($NODE_BIN --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $($NODE_BIN --version)" >&2; exit 1; } +export PATH="$(dirname "$NODE_BIN"):$PATH" +STAMP="$(date +%Y%m%d-%H%M%S)"; TARGET="$RELEASES/$STAMP" +exec 9>"$APP/deploy.lock"; flock -n 9 || { echo '다른 배포가 진행 중입니다' >&2; exit 1; } +echo "▶ 소스 갱신 ($SITE: $MAIL_HOST)"; cd "$REPO" +# SOURCE_BUNDLE= 를 주면 원격 자격 증명 없이 번들에서 main 을 받습니다(작업 PC 에서 scp 로 전달). +# 번들의 main 이 서버가 아는 origin/main 과 이어지지 않아도(릴리스 역사를 다시 쓴 경우) 받도록 강제(+) 갱신합니다. 어차피 아래에서 origin/main 으로 reset 합니다. +if [ -n "${SOURCE_BUNDLE:-}" ]; then git bundle verify "$SOURCE_BUNDLE" >/dev/null 2>&1; git fetch --quiet --force "$SOURCE_BUNDLE" +main:refs/remotes/origin/main; else git fetch --quiet origin; fi +git reset --hard --quiet origin/main; echo " $(git log --oneline -1)" +echo "▶ 테스트"; for t in gateway imap smtp config provision domains webmaker usage; do "$NODE_BIN" "test/$t.test.mjs"; done +echo "▶ 릴리스 배치"; mkdir -p "$TARGET" +for path in server web lang migrations package.json haraka; do cp -r "$REPO/$path" "$TARGET/$path"; done +git rev-parse HEAD > "$TARGET/REVISION" +DB="$APP/data/mail-gateway.sqlite"; mkdir -p "$APP/data/backups" +if [ -f "$DB" ]; then python3 - "$DB" "$APP/data/backups/pre-$STAMP.sqlite" <<'PY' +import sqlite3,sys +s=sqlite3.connect('file:'+sys.argv[1]+'?mode=ro',uri=True);b=sqlite3.connect(sys.argv[2]);s.backup(b);b.close();s.close() +PY +fi +DB_FILE="$DB" "$NODE_BIN" "$TARGET/server/db-migrate.mjs" +# 첫 배포에는 current 가 없습니다. -e 는 존재하는 경로만 돌려주므로 자기 자신을 가리키는 링크를 만들지 않습니다. +PREVIOUS="$(readlink -e "$APP/current" 2>/dev/null || true)" +rollback() { trap - ERR; if [ -n "$PREVIOUS" ] && [ -d "$PREVIOUS" ] && [ "$PREVIOUS" != "$TARGET" ]; then ln -sfn "$PREVIOUS" "$APP/current.rollback"; mv -Tf "$APP/current.rollback" "$APP/current"; sudo systemctl restart "$APP_NAME"; fi; echo "배포 실패 — 이전 릴리스로 복귀" >&2; } +trap rollback ERR +ln -sfn "$TARGET" "$APP/current.tmp"; mv -Tf "$APP/current.tmp" "$APP/current"; echo " current → $TARGET" +# sync: 릴리스 파일이 디스크에 완전히 쓰인 뒤 재시작합니다(1.0.5 — 0.9.7 배포 때 첫 시작이 빈 package.json 을 읽은 기록). +sync +echo "▶ 서비스 재시작"; sudo systemctl restart "$APP_NAME"; sleep 2 +systemctl is-active --quiet "$APP_NAME" || { rollback; exit 1; } +curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null && echo " health OK" +trap - ERR +ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf +# 내장 Haraka(1.0.0): 관리 서비스가 등록되어 있으면 새 릴리스의 run.mjs 로 다시 띄웁니다(sudoers 에 없으면 건너뜀 — haraka-apply.sh 가 다음 실행에서 맞춤). +# 의존성 설치는 root 가 하는 haraka-apply.sh 의 몫이지만, 이미 설치된 디렉터리가 있고 package.json 이 바뀌었으면 여기서 실행 계정으로 갱신을 시도합니다(실패해도 배포는 성공). +if [ -f "$APP/haraka/package.json" ] && ! cmp -s "$REPO/haraka/package.json" "$APP/haraka/package.json"; then + NPM_BIN="$(dirname "$NODE_BIN")/npm"; [ -x "$NPM_BIN" ] || NPM_BIN="$(command -v npm || true)" + if [ -x "$NPM_BIN" ]; then cp "$REPO/haraka/package.json" "$APP/haraka/package.json"; (cd "$APP/haraka" && HOME="$APP/haraka" "$NPM_BIN" install --omit=dev --no-audit --no-fund --no-package-lock --loglevel=error) || echo " ⚠️ Haraka 의존성 갱신 실패(인터넷?) — sudo bash $REPO/deploy/haraka-apply.sh 로 다시 시도" >&2; fi +fi +if systemctl is-enabled --quiet "$APP_NAME-haraka" 2>/dev/null; then sudo -n systemctl restart "$APP_NAME-haraka" 2>/dev/null && echo " Haraka 관리 서비스 재시작" || echo " (Haraka 관리 서비스는 root 가 재시작해야 합니다: sudo systemctl restart $APP_NAME-haraka)"; fi +echo "✔ 배포 완료: $STAMP" +# 초기 설정 페이지(1.0.0): 서버가 시작하며 데이터 디렉터리에 접속 주소(토큰 포함)를 남깁니다. 설치 직후 운영 설정(Resend 키 등)을 여기서 채웁니다. +sleep 1; if [ -f "$APP/data/setup-url.txt" ]; then echo "▶ 초기 설정 페이지(토큰 포함 — 공유하지 마세요):"; sed 's/^/ /' "$APP/data/setup-url.txt"; echo " 또는 https://$MAIL_HOST/admin → 메일 서비스 설정"; fi diff --git a/modules/arimail/deploy/domain-nginx.sh b/modules/arimail/deploy/domain-nginx.sh new file mode 100755 index 0000000..6a57943 --- /dev/null +++ b/modules/arimail/deploy/domain-nginx.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# 추가 도메인의 웹 메일 호스트에 nginx vhost 와 Let's Encrypt 인증서를 준비합니다(1.0.7, 요구사항 1) — root 로 실행: +# sudo bash /repo/deploy/domain-nginx.sh mail.example.com [site.env 경로] +# +# 왜 필요한가 +# 도메인 자체는 전체 설정 화면(기본 도메인의 8791 포트)에서 추가하면 게이트웨이가 바로 받기 시작하지만, 브라우저가 https://mail.example.com/ 으로 +# 오려면 nginx 가 그 이름(server_name)과 인증서를 알아야 합니다. 이 스크립트는 기존 사이트의 nginx 템플릿(deploy/nginx/*.in)을 그 호스트 이름으로 채워 +# 같은 게이트웨이 포트(PORT)로 넘기는 vhost 를 만들고 certbot 으로 인증서를 받습니다. 게이트웨이 설정·환경 파일·데이터는 건드리지 않습니다. +# +# 하는 일 +# 1) 인증서가 없으면 80 전용(ACME 챌린지) vhost 로 시작하고, DNS(호스트 → 이 서버 IP)가 맞으면 certbot certonly --webroot 로 발급합니다. +# DNS 가 아직 안 바뀌었으면 안내만 하고 끝납니다(DNS 가 바뀐 뒤 다시 실행). +# 2) 인증서가 있으면 443 vhost(mail.conf.in)로 바꾸고 nginx 를 다시 읽습니다. 갱신 뒤 nginx 재적재용 certbot 훅도 둡니다. +# 참고: 메일 프로그램 포트(IMAP/POP/SMTP)의 인증서는 기본 도메인 것 하나이므로, 추가 도메인의 사용자도 메일 프로그램에는 기본 도메인의 호스트 이름으로 접속합니다 +# (웹 메일의 "메일 프로그램 설정" 화면이 그 이름을 안내합니다). +set -euo pipefail +[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; } +. "$(dirname "$0")/lib.sh" +TARGET_HOST="$(printf '%s' "${1:?웹 메일 호스트 이름(예: mail.example.com)}" | tr 'A-Z' 'a-z')" +printf '%s' "$TARGET_HOST" | grep -Eq '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' || { echo "호스트 이름 형식이 아닙니다: $TARGET_HOST" >&2; exit 1; } +SITE_FILE="${2:-}" +if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi +load_site "$SITE_FILE" +[ "$TARGET_HOST" != "$MAIL_HOST" ] || { echo "$TARGET_HOST 는 기본 도메인의 호스트입니다(setup.sh / nginx-apply.sh 가 관리)." >&2; exit 1; } +# 템플릿의 @MAIL_HOST@ 자리를 이 호스트로 채웁니다(나머지 변수 — 포트·로그 이름·ACME 경로 — 는 사이트 설정 그대로). +MAIL_HOST="$TARGET_HOST"; export MAIL_HOST +TARGET="/etc/nginx/sites-available/$TARGET_HOST.conf" +apply() { render "$1" "$TARGET"; chmod 644 "$TARGET"; ln -sfn "$TARGET" "/etc/nginx/sites-enabled/$TARGET_HOST.conf"; nginx -t; systemctl reload nginx; } +echo "▶ 추가 도메인 호스트: $TARGET_HOST (사이트 $SITE, 게이트웨이 포트 $PORT)" +if [ ! -d "/etc/letsencrypt/live/$TARGET_HOST" ]; then + apply "$DEPLOY_DIR/nginx/mail.http-only.conf.in" + echo " 80 포트(ACME) 설정 적용. 인증서를 발급합니다…" + RESOLVED="$(getent ahostsv4 "$TARGET_HOST" 2>/dev/null | awk '{print $1}' | sort -u || true)" + MINE="$(ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1 | sort -u)" + MATCH=""; for a in $RESOLVED; do for b in $MINE; do [ "$a" = "$b" ] && MATCH="$a"; done; done + if [ -z "$MATCH" ]; then + echo " $TARGET_HOST 가 아직 이 서버를 가리키지 않습니다 (DNS: ${RESOLVED:-없음} / 서버: $MINE)." + echo " DNS 의 A 레코드를 이 서버로 바꾼 뒤 같은 명령을 다시 실행하세요. 그때까지 https 는 열리지 않습니다." + exit 0 + fi + mkdir -p "$ACME_WEBROOT" + ACCOUNT_FLAG=""; ls /etc/letsencrypt/accounts/*/directory/*/regr.json >/dev/null 2>&1 || ACCOUNT_FLAG="--register-unsafely-without-email" + certbot certonly --webroot -w "$ACME_WEBROOT" -d "$TARGET_HOST" --non-interactive --agree-tos --keep-until-expiring $ACCOUNT_FLAG +fi +apply "$DEPLOY_DIR/nginx/mail.conf.in" +# 갱신 훅: 이 호스트의 인증서가 갱신되면 nginx 만 다시 읽습니다(메일 포트는 기본 도메인 인증서를 쓰므로 게이트웨이 재시작 불필요). +mkdir -p /etc/letsencrypt/renewal-hooks/deploy +cat > "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME-$TARGET_HOST.sh" <:8791/?token=… 또는 https://@MAIL_HOST@/admin → 메일 서비스 설정)에서 채우면 재시작 없이 적용됩니다. +# 예전 서버에서 이 파일에 남아 있는 RESEND_API_KEY 등은 1.0.0 첫 시작 때 설정 파일로 가져가며, 그 뒤로는 설정 파일 값이 우선합니다. +# +# 게이트웨이가 다루는 기본 메일함. 계정의 메일함은 항상 "로그인 ID@(기본 메일함의 도메인)" 입니다. +MAILBOX=@MAILBOX@ +PUBLIC_HOST=@MAIL_HOST@ +# 운영 설정 파일 위치(비우면 DB 파일 옆의 config.json). 초기 설정 페이지 전용 포트(0 이면 끔)와 접속 허용 주소. +CONFIG_FILE= +SETUP_PORT=8791 +SETUP_HOST=0.0.0.0 +# 내장 Haraka 설치 디렉터리(deploy/haraka-apply.sh 가 npm 으로 설치, 서비스 @APP_NAME@-haraka). 비우면 @APP_DIR@/haraka. +HARAKA_DIR=@APP_DIR@/haraka +# 로그인 방식(0.9.0 설치 옵션): standalone = www 사이트 없이 게이트웨이 계정만 사용(기본 설치 형태, 첫 관리자 admin/adminpasswd 자동 생성) +# www = www 사이트 DB(WWW_DB_FILE)로 관리자를 인증. standalone 이면 WWW_DB_FILE 은 무시됩니다. +LOGIN_MODE=@LOGIN_MODE@ +# www 사이트 DB(관리자 계정 인증용, 읽기 전용). LOGIN_MODE=www 일 때만 씁니다. +WWW_DB_FILE=@WWW_DB_FILE@ +# 웹메이커 연동(1.0.8, LOGIN_MODE=webmaker): 웹메이커 데이터 디렉터리(DATA_ROOT), sudo 로 읽을 계정(선택, deploy/webmaker-access.sh), 사이트 id(선택). +WEBMAKER_DIR=@WEBMAKER_DIR@ +WEBMAKER_USER=@WEBMAKER_USER@ +WEBMAKER_SITE=@WEBMAKER_SITE@ +# 시스템 제한(0.9.0, 메일 시스템 전체의 상위 설정 — 관리자 페이지에서 바꿀 수 없고 관리자 설정은 이 값 이내로만 가능). 모두 0 이면 제한 없음. +# 크기는 바이트 또는 K/M/G 접미사(예: 500M, 2G)이며 MIME 변환 뒤의 크기(첨부는 base64 로 약 1.37배) 기준입니다. +# (systemd 환경 파일은 값 뒤의 주석을 지원하지 않으므로 주석은 따로 둡니다.) +# 허용 가능한 메일 계정 수 +LIMIT_ACCOUNTS=@LIMIT_ACCOUNTS@ +# 메일함 전체 최대 용량 +LIMIT_TOTAL_BYTES=@LIMIT_TOTAL_BYTES@ +# 개별 메일함 최대 용량 +LIMIT_MAILBOX_BYTES=@LIMIT_MAILBOX_BYTES@ +# 첨부 파일 한 개 최대 크기 +LIMIT_ATTACHMENT_BYTES=@LIMIT_ATTACHMENT_BYTES@ +# 한 메일의 받는 사람 수(참조·숨은 참조 포함, 내장 상한 50) +LIMIT_RECIPIENTS=@LIMIT_RECIPIENTS@ +# 월간(1일~말일) 최대 수발신 통수 +LIMIT_MONTHLY_MESSAGES=@LIMIT_MONTHLY_MESSAGES@ +# 대용량 첨부파일 시스템 상한(0.9.3): 관리자 설정의 상한이며 무제한으로 둘 수 없습니다(비우거나 0 이면 기본값 1G / 30 / 20 적용). +# 파일 한 개 최대 크기(기본 1G) +LIMIT_LARGE_FILE_BYTES=@LIMIT_LARGE_FILE_BYTES@ +# 보관 최대 기간(일, 기본 30) +LIMIT_LARGE_FILE_DAYS=@LIMIT_LARGE_FILE_DAYS@ +# 다운로드 최대 횟수(기본 20) +LIMIT_LARGE_FILE_DOWNLOADS=@LIMIT_LARGE_FILE_DOWNLOADS@ +# 대용량 파일을 둘 디렉터리(비우면 DB 파일 옆의 files/). 서비스가 쓸 수 있는 경로여야 합니다(systemd ReadWritePaths). +FILES_DIR= +# 게이트웨이 저장소(SQLite). 설정 파일·초기 설정 토큰·Haraka 상태 파일도 같은 디렉터리에 둡니다. +DB_FILE=@APP_DIR@/data/mail-gateway.sqlite +# POP3·IMAP·SMTP TLS: 인증서 사본 경로(certbot 갱신 훅이 여기에 복사). 없으면 995/993/465 를 열지 않고 평문 포트는 STARTTLS 없이 로그인 거부. +POP_TLS_CERT=@ETC_DIR@/tls/fullchain.pem +POP_TLS_KEY=@ETC_DIR@/tls/privkey.pem +POP_PORT=110 +POPS_PORT=995 +# IMAP: 993(TLS) / 143(STARTTLS). 인증서는 POP 와 같은 파일을 씁니다. +IMAP_PORT=143 +IMAPS_PORT=993 +# SMTP 제출(보내는 메일 서버): 587(STARTTLS) / 465(TLS). 로그인 비밀번호로 인증하고 위 RESEND_API_KEY 로 발송합니다. +SMTP_PORT=587 +SMTPS_PORT=465 +# 110/143 포트의 TLS 없는 로그인: 0.9.7 부터 관리자 페이지(POP / IMAP 서비스 → 일반 접속 로그인 허용, 기본 켬)에서 정합니다. +# 1 로 두면 관리자 설정과 무관하게 항상 허용(진단용). 0 이나 빈 값은 관리자 설정을 따릅니다(예전처럼 "항상 거부" 가 아님). +POP_ALLOW_PLAINTEXT=0 diff --git a/modules/arimail/deploy/haraka-apply.sh b/modules/arimail/deploy/haraka-apply.sh new file mode 100644 index 0000000..a7dc44d --- /dev/null +++ b/modules/arimail/deploy/haraka-apply.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# 내장 Haraka 준비(1.0.0) — root 로 실행: sudo bash /repo/deploy/haraka-apply.sh [site.env 경로] +# +# 하는 일(환경 파일·데이터는 건드리지 않습니다) +# 1) /haraka 디렉터리(실행 계정 소유)에 haraka/package.json 을 두고 npm 으로 Haraka 와 DKIM 플러그인을 설치합니다(인터넷 필요, 몇 분). +# 2) systemd 서비스 -haraka 를 등록·시작합니다. 이 서비스(haraka/run.mjs)는 설정 파일(config.json)의 수·발신 방식이 haraka 일 때만 +# Haraka 를 띄우므로, 지금 Resend 만 쓰고 있어도 켜 두어도 아무 포트를 열지 않습니다(설정 화면에서 바꾸는 순간 동작). +# setup.sh 가 새 사이트에 자동으로 부르고, 기존 사이트는 한 번 직접 실행합니다. 다시 실행해도 안전합니다(설치된 버전이 같으면 npm 이 금방 끝남). +set -euo pipefail +[ "$(id -u)" = 0 ] || { echo 'sudo 로 실행하세요' >&2; exit 1; } +. "$(dirname "$0")/lib.sh" +SITE_FILE="${1:-}" +# 사이트 파일: 인자 → /site.env(0.8.18 이후 setup.sh 가 둠) → deploy/sites/.env(0.8.17 이전에 설치한 서버는 site.env 사본이 없음, deploy.sh 와 같은 폴백). +if [ -n "$SITE_FILE" ] && [ ! -f "$SITE_FILE" ]; then echo "사이트 설정 파일이 없어 저장소의 기본 파일을 씁니다: $SITE_FILE" >&2; SITE_FILE=""; fi +if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi +load_site "$SITE_FILE" +echo "▶ Haraka 준비: $SITE ($APP_DIR/haraka, 서비스 $APP_NAME-haraka)" +[ -x "$NODE_BIN" ] || { echo "Node 실행 파일이 없습니다: $NODE_BIN" >&2; exit 1; } +NPM_BIN="$(dirname "$NODE_BIN")/npm"; [ -x "$NPM_BIN" ] || NPM_BIN="$(command -v npm || true)" +REPO_HARAKA="$APP_DIR/repo/haraka"; [ -f "$REPO_HARAKA/package.json" ] || REPO_HARAKA="$DEPLOY_DIR/../haraka" +mkdir -p "$APP_DIR/haraka"; chown "$APP_USER:$APP_USER" "$APP_DIR/haraka" +install -o "$APP_USER" -g "$APP_USER" -m 644 "$REPO_HARAKA/package.json" "$APP_DIR/haraka/package.json" +if [ -x "$NPM_BIN" ]; then + echo "▶ npm 설치 ($NPM_BIN) — 인터넷이 필요합니다" + # 실행 계정으로 설치해 node_modules 소유자를 맞춥니다. 실패해도(오프라인 등) 서비스는 등록하고 안내만 남깁니다. + if sudo -u "$APP_USER" env PATH="$(dirname "$NODE_BIN"):$PATH" HOME="$APP_DIR/haraka" "$NPM_BIN" install --omit=dev --no-audit --no-fund --no-package-lock --prefix "$APP_DIR/haraka" --loglevel=error; then + echo " 설치됨: Haraka $("$NODE_BIN" -e "console.log(require('$APP_DIR/haraka/node_modules/Haraka/package.json').version)")" + else echo " ⚠️ npm 설치 실패 — 인터넷 연결을 확인하고 다시 실행하세요: sudo bash $0 $SITE_FILE" >&2; fi +else echo " ⚠️ npm 을 찾지 못했습니다($NODE_BIN 옆에 없음). Node 배포판의 npm 을 두고 다시 실행하세요." >&2; fi +REPO_DEPLOY="$APP_DIR/repo/deploy"; [ -f "$REPO_DEPLOY/systemd/mail-haraka.service.in" ] || REPO_DEPLOY="$DEPLOY_DIR" +render "$REPO_DEPLOY/systemd/mail-haraka.service.in" "/etc/systemd/system/$APP_NAME-haraka.service"; chmod 644 "/etc/systemd/system/$APP_NAME-haraka.service" +# 실행 계정이 deploy.sh 에서 이 서비스를 재시작할 수 있게 sudoers 를 최신 규칙으로 다시 씁니다(setup.sh 와 같은 한 줄). +sudoers_line > "/etc/sudoers.d/$APP_NAME"; chmod 440 "/etc/sudoers.d/$APP_NAME"; visudo -c -q +systemctl daemon-reload +# 첫 배포 전(current 없음)에는 시작하지 않고 등록만 합니다(deploy.sh 가 끝나면 다음 부팅·수동 시작 때 뜸). current 가 있으면 바로 (재)시작. +systemctl enable -q "$APP_NAME-haraka" +if [ -e "$APP_DIR/current/haraka/run.mjs" ]; then systemctl restart "$APP_NAME-haraka"; sleep 1; systemctl is-active --quiet "$APP_NAME-haraka" && echo " 서비스 실행 중: $APP_NAME-haraka" || echo " ⚠️ 서비스가 뜨지 않았습니다: journalctl -u $APP_NAME-haraka -n 50" >&2 +else echo " (아직 배포된 릴리스가 없어 서비스는 등록만 했습니다. deploy.sh 뒤 자동으로 시작합니다)"; fi +cat < : 사이트 설정(deploy/sites/<이름>.env 또는 $APP_DIR/site.env)을 읽고 필수 값을 확인합니다. +# render <템플릿> <출력> : 템플릿의 @변수@ 자리를 사이트 설정 값으로 바꿔 씁니다(systemd·nginx·certbot 훅·환경 파일). +# +# 예전(0.8.17 까지)에는 모든 파일에 mail.arihakdang.com / arihakdang 계정 / /srv/arihakdang-mail 이 박혀 있어 다른 도메인에 설치할 수 없었습니다. +# 이제 사이트마다 변수 파일 하나로 서비스 이름·계정·경로·호스트·메일함을 정하고, 같은 템플릿으로 설정 파일을 만듭니다. + +DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# 템플릿이 치환하는 변수 목록. 새 변수를 쓰려면 여기와 sites/*.env 에 함께 더합니다. +# 0.9.0: LOGIN_MODE(설치 옵션: standalone | www)와 시스템 제한 LIMIT_*(0 = 제한 없음, 크기는 500M/2G 같은 접미사 가능)을 더했습니다. +SITE_VARS=(SITE APP_NAME APP_USER APP_DIR ETC_DIR NODE_BIN PORT MAIL_HOST MAILBOX MAIL_FROM_NAME SITE_TITLE SITE_BRAND WWW_DB_FILE WEBMAKER_DIR WEBMAKER_USER WEBMAKER_SITE EXTRA_RW ACME_WEBROOT CF_CERT CF_KEY REPO_URL LOGIN_MODE LIMIT_ACCOUNTS LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES LIMIT_LARGE_FILE_BYTES LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS) + +load_site() { + local file="$1" + [ -f "$file" ] || { echo "사이트 설정 파일이 없습니다: $file" >&2; return 1; } + # shellcheck disable=SC1090 + set -a; . "$file"; set +a + for v in SITE APP_NAME APP_USER APP_DIR ETC_DIR NODE_BIN PORT MAIL_HOST MAILBOX; do + [ -n "${!v:-}" ] || { echo "사이트 설정에 $v 가 없습니다: $file" >&2; return 1; } + done + : "${MAIL_FROM_NAME:=$SITE}" "${SITE_TITLE:=}" "${SITE_BRAND:=}" "${WWW_DB_FILE:=}" "${WEBMAKER_DIR:=}" "${WEBMAKER_USER:=}" "${WEBMAKER_SITE:=}" "${EXTRA_RW:=}" "${ACME_WEBROOT:=/var/www/letsencrypt}" "${CF_CERT:=}" "${CF_KEY:=}" "${REPO_URL:=https://source.ibit.kr/OZ-Group/AriMail.git}" + : "${LIMIT_ACCOUNTS:=0}" "${LIMIT_TOTAL_BYTES:=0}" "${LIMIT_MAILBOX_BYTES:=0}" "${LIMIT_ATTACHMENT_BYTES:=0}" "${LIMIT_RECIPIENTS:=0}" "${LIMIT_MONTHLY_MESSAGES:=0}" + # 대용량 첨부파일 시스템 상한(0.9.3): 무제한이 없으므로 기본값(1G / 30일 / 20회)을 둡니다. + : "${LIMIT_LARGE_FILE_BYTES:=1G}" "${LIMIT_LARGE_FILE_DAYS:=30}" "${LIMIT_LARGE_FILE_DOWNLOADS:=20}" + # 로그인 방식(0.9.0 설치 옵션). 비워 두면 WWW_DB_FILE 유무로 정합니다(0.8.x 사이트 파일 호환). standalone 이 기본 설치 형태입니다. + # standalone : www 사이트 없이 게이트웨이 계정만 씀(WWW_DB_FILE 은 비움) www : www 사이트 DB 로 관리자를 인증(WWW_DB_FILE 필수) + : "${LOGIN_MODE:=}" + if [ -z "$LOGIN_MODE" ]; then if [ -n "$WWW_DB_FILE" ]; then LOGIN_MODE=www; else LOGIN_MODE=standalone; fi; fi + # webmaker(1.0.8): 웹메이커 데이터 디렉터리(WEBMAKER_DIR)로 계정을 연동. WEBMAKER_USER(sudo 로 읽을 계정)·WEBMAKER_SITE(사이트 id)는 선택. + case "$LOGIN_MODE" in + standalone) WWW_DB_FILE=""; WEBMAKER_DIR="" ;; + www) WEBMAKER_DIR=""; [ -n "$WWW_DB_FILE" ] || { echo "LOGIN_MODE=www 이면 WWW_DB_FILE(www 사이트 DB 경로)이 필요합니다: $file" >&2; return 1; } ;; + webmaker) WWW_DB_FILE=""; [ -n "$WEBMAKER_DIR" ] || { echo "LOGIN_MODE=webmaker 이면 WEBMAKER_DIR(웹메이커 데이터 디렉터리)이 필요합니다: $file" >&2; return 1; } ;; + *) echo "LOGIN_MODE 는 standalone, www, webmaker 중 하나여야 합니다: $LOGIN_MODE ($file)" >&2; return 1 ;; + esac + export MAIL_FROM_NAME SITE_TITLE SITE_BRAND WWW_DB_FILE WEBMAKER_DIR WEBMAKER_USER WEBMAKER_SITE EXTRA_RW ACME_WEBROOT CF_CERT CF_KEY REPO_URL LOGIN_MODE LIMIT_ACCOUNTS LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES LIMIT_LARGE_FILE_BYTES LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS +} + +# sed 치환에서 특별한 뜻을 갖는 글자(구분자 | 와 &, \)를 피합니다. +_sed_escape() { local s="$1"; s="${s//\\/\\\\}"; s="${s//|/\\|}"; s="${s//&/\\&}"; printf '%s' "$s"; } + +render() { + local template="$1" out="$2" expr=() + for v in "${SITE_VARS[@]}"; do expr+=(-e "s|@${v}@|$(_sed_escape "${!v:-}")|g"); done + sed "${expr[@]}" "$template" > "$out" + # 치환되지 않은 자리가 남으면 템플릿과 변수 목록이 어긋난 것이므로 바로 알립니다. + if grep -q '@[A-Z_]\{3,\}@' "$out"; then echo "템플릿에 알 수 없는 변수가 있습니다: $template → $(grep -o '@[A-Z_]\{3,\}@' "$out" | sort -u | tr '\n' ' ')" >&2; return 1; fi +} + +# 실행 계정이 서비스를 재시작하고 nginx 를 다시 읽을 수 있게 하는 sudoers 규칙 한 줄. +# 1.0.0: 내장 Haraka 관리 서비스(-haraka)의 재시작도 허용합니다(deploy.sh 가 새 릴리스로 다시 띄움). +sudoers_line() { printf '%s ALL=(root) NOPASSWD: /usr/bin/systemctl restart %s, /usr/bin/systemctl restart %s-haraka, /usr/bin/systemctl reload nginx\n' "$APP_USER" "$APP_NAME" "$APP_NAME"; } diff --git a/modules/arimail/deploy/new-site.sh b/modules/arimail/deploy/new-site.sh new file mode 100755 index 0000000..3ca245f --- /dev/null +++ b/modules/arimail/deploy/new-site.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# 새 사이트 설정 파일 만들기(0.9.0 설치 옵션) — 작업 PC 에서: bash deploy/new-site.sh <사이트이름> --host mail.example.com --mailbox admin@example.com [옵션] +# +# deploy/sites/<사이트이름>.env 를 만듭니다. setup.sh 는 이 파일로 디렉터리·환경 파일·systemd·nginx 를 준비합니다. +# 로그인 방식은 --login standalone(기본: www 사이트 없이 게이트웨이 계정만 사용) 또는 --login www --www-db (www 사이트 관리자로 인증) 중 고릅니다. +# 시스템 제한(--limit-*)은 설치 시 gateway.env 에 들어가는 상위 설정이며, 0(기본)은 제한 없음입니다. 크기는 500M, 2G 처럼 적습니다. +# +# 옵션: --user <실행 계정>(기본 사이트이름) --app-name <서비스 이름>(기본 <사이트이름>-mail) --app-dir <경로>(기본 /srv/<서비스 이름>) --etc-dir <경로>(기본 /etc/<서비스 이름>) +# --node (기본 /opt/<서비스 이름>-node/bin/node) --port <포트>(기본 8790) --from-name <발신 표시 이름> --title <탭 제목> +# --login standalone|www --www-db <경로> --extra-rw <경로> --repo <저장소 URL> --force(있는 파일 덮어쓰기) +# --limit-accounts N --limit-total 2G --limit-mailbox 500M --limit-attachment 20M --limit-recipients N --limit-monthly N +# --large-bytes 1G --large-days 30 --large-downloads 20 (대용량 첨부파일 시스템 상한, 0.9.3 — 무제한 불가, 기본 1G/30/20) +set -euo pipefail +DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SITE="${1:-}"; [ -n "$SITE" ] && [ "${SITE#-}" = "$SITE" ] || { sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 1; } +shift +[[ "$SITE" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "사이트 이름은 영문 소문자·숫자·하이픈이어야 합니다: $SITE" >&2; exit 1; } +MAIL_HOST=""; MAILBOX=""; APP_USER="$SITE"; APP_NAME="$SITE-mail"; APP_DIR=""; ETC_DIR=""; NODE_BIN=""; PORT=8790; MAIL_FROM_NAME=""; SITE_TITLE="" +LOGIN_MODE=standalone; WWW_DB_FILE=""; EXTRA_RW=""; REPO_URL="https://source.ibit.kr/OZ-Group/AriMail.git"; FORCE=0 +LIMIT_ACCOUNTS=0; LIMIT_TOTAL_BYTES=0; LIMIT_MAILBOX_BYTES=0; LIMIT_ATTACHMENT_BYTES=0; LIMIT_RECIPIENTS=0; LIMIT_MONTHLY_MESSAGES=0 +LIMIT_LARGE_FILE_BYTES=1G; LIMIT_LARGE_FILE_DAYS=30; LIMIT_LARGE_FILE_DOWNLOADS=20 +while [ $# -gt 0 ]; do + case "$1" in + --host) MAIL_HOST="$2"; shift 2 ;; --mailbox) MAILBOX="$2"; shift 2 ;; --user) APP_USER="$2"; shift 2 ;; --app-name) APP_NAME="$2"; shift 2 ;; + --app-dir) APP_DIR="$2"; shift 2 ;; --etc-dir) ETC_DIR="$2"; shift 2 ;; --node) NODE_BIN="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;; + --from-name) MAIL_FROM_NAME="$2"; shift 2 ;; --title) SITE_TITLE="$2"; shift 2 ;; --login) LOGIN_MODE="$2"; shift 2 ;; --www-db) WWW_DB_FILE="$2"; shift 2 ;; + --extra-rw) EXTRA_RW="$2"; shift 2 ;; --repo) REPO_URL="$2"; shift 2 ;; --force) FORCE=1; shift ;; + --limit-accounts) LIMIT_ACCOUNTS="$2"; shift 2 ;; --limit-total) LIMIT_TOTAL_BYTES="$2"; shift 2 ;; --limit-mailbox) LIMIT_MAILBOX_BYTES="$2"; shift 2 ;; + --limit-attachment) LIMIT_ATTACHMENT_BYTES="$2"; shift 2 ;; --limit-recipients) LIMIT_RECIPIENTS="$2"; shift 2 ;; --limit-monthly) LIMIT_MONTHLY_MESSAGES="$2"; shift 2 ;; + --large-bytes) LIMIT_LARGE_FILE_BYTES="$2"; shift 2 ;; --large-days) LIMIT_LARGE_FILE_DAYS="$2"; shift 2 ;; --large-downloads) LIMIT_LARGE_FILE_DOWNLOADS="$2"; shift 2 ;; + *) echo "알 수 없는 옵션: $1" >&2; exit 1 ;; + esac +done +[ -n "$MAIL_HOST" ] && [ -n "$MAILBOX" ] || { echo "--host 와 --mailbox 는 꼭 필요합니다" >&2; exit 1; } +[[ "$MAILBOX" == *@*.* ]] || { echo "--mailbox 는 이메일 주소여야 합니다: $MAILBOX" >&2; exit 1; } +case "$LOGIN_MODE" in + standalone) WWW_DB_FILE="" ;; + www) [ -n "$WWW_DB_FILE" ] || { echo "--login www 에는 --www-db 가 필요합니다" >&2; exit 1; }; [ -n "$EXTRA_RW" ] || EXTRA_RW="$(dirname "$WWW_DB_FILE")" ;; + *) echo "--login 은 standalone 또는 www 입니다" >&2; exit 1 ;; +esac +for v in LIMIT_ACCOUNTS LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES; do [[ "${!v}" =~ ^[0-9]+$ ]] || { echo "$v 는 0 이상의 정수여야 합니다: ${!v}" >&2; exit 1; }; done +for v in LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS; do [[ "${!v}" =~ ^[1-9][0-9]*$ ]] || { echo "$v 는 1 이상의 정수여야 합니다(무제한 불가): ${!v}" >&2; exit 1; }; done +[[ "$LIMIT_LARGE_FILE_BYTES" =~ ^[1-9][0-9]*(\.[0-9]+)?[KkMmGgTt]?[Bb]?$ ]] || { echo "LIMIT_LARGE_FILE_BYTES 는 0 이 아닌 크기(예: 1G)여야 합니다: $LIMIT_LARGE_FILE_BYTES" >&2; exit 1; } +for v in LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES; do [[ "${!v}" =~ ^[0-9]+(\.[0-9]+)?[KkMmGgTt]?[Bb]?$ ]] || { echo "$v 는 크기(예: 500M, 2G, 0)여야 합니다: ${!v}" >&2; exit 1; }; done +: "${APP_DIR:=/srv/$APP_NAME}" "${ETC_DIR:=/etc/$APP_NAME}" "${NODE_BIN:=/opt/$APP_NAME-node/bin/node}" "${MAIL_FROM_NAME:=$SITE}" +OUT="$DEPLOY_DIR/sites/$SITE.env" +[ "$FORCE" = 1 ] || [ ! -e "$OUT" ] || { echo "이미 있습니다: $OUT (덮어쓰려면 --force)" >&2; exit 1; } +cat > "$OUT" < bash deploy/setup.sh +SITE=$SITE +APP_NAME=$APP_NAME +APP_USER=$APP_USER +APP_DIR=$APP_DIR +ETC_DIR=$ETC_DIR +NODE_BIN=$NODE_BIN +PORT=$PORT +MAIL_HOST=$MAIL_HOST +MAILBOX=$MAILBOX +MAIL_FROM_NAME="$MAIL_FROM_NAME" +SITE_TITLE="$SITE_TITLE" +SITE_BRAND="" +# 로그인 방식(0.9.0 설치 옵션): standalone = www 사이트 없이 게이트웨이 계정만 사용(기본) / www = www 사이트 DB 로 관리자 인증(WWW_DB_FILE 필수) +LOGIN_MODE=$LOGIN_MODE +WWW_DB_FILE=$WWW_DB_FILE +EXTRA_RW=$EXTRA_RW +ACME_WEBROOT=/var/www/letsencrypt +CF_CERT= +CF_KEY= +REPO_URL=$REPO_URL +# 시스템 제한(0.9.0): 메일 시스템 전체의 상위 설정. 0 = 제한 없음. 크기는 500M/2G 식(MIME 변환 뒤 크기 기준). +LIMIT_ACCOUNTS=$LIMIT_ACCOUNTS +LIMIT_TOTAL_BYTES=$LIMIT_TOTAL_BYTES +LIMIT_MAILBOX_BYTES=$LIMIT_MAILBOX_BYTES +LIMIT_ATTACHMENT_BYTES=$LIMIT_ATTACHMENT_BYTES +LIMIT_RECIPIENTS=$LIMIT_RECIPIENTS +LIMIT_MONTHLY_MESSAGES=$LIMIT_MONTHLY_MESSAGES +# 대용량 첨부파일 시스템 상한(0.9.3): 무제한 불가. +LIMIT_LARGE_FILE_BYTES=$LIMIT_LARGE_FILE_BYTES +LIMIT_LARGE_FILE_DAYS=$LIMIT_LARGE_FILE_DAYS +LIMIT_LARGE_FILE_DOWNLOADS=$LIMIT_LARGE_FILE_DOWNLOADS +ENV +echo "✔ 만들었습니다: $OUT" +echo "다음: 서버에서 sudo env SITE=$SITE SOURCE_BUNDLE=/tmp/arimail.bundle bash <저장소>/deploy/setup.sh" diff --git a/modules/arimail/deploy/nginx-apply.sh b/modules/arimail/deploy/nginx-apply.sh new file mode 100755 index 0000000..104cc77 --- /dev/null +++ b/modules/arimail/deploy/nginx-apply.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# nginx 설정만 다시 적용(0.9.5~) — root 로: sudo bash deploy/nginx-apply.sh /site.env +# +# setup.sh 전체를 다시 돌리지 않고(환경 파일·인증서 사본은 건드리지 않음) 저장소의 nginx 템플릿을 사이트 설정으로 채워 +# 기존 vhost 파일을 바꿔 쓰고 nginx -t 로 검사한 뒤 reload 합니다. 템플릿은 setup.sh 와 같은 규칙으로 고릅니다 +# (Let's Encrypt 인증서 → mail.conf.in, Cloudflare 오리진 인증서 → mail.cloudflare.conf.in, 둘 다 없으면 80 전용). +# vhost 파일 경로: sites-enabled 에서 이 호스트(server_name)를 쓰는 파일이 있으면 그 실제 파일(예전 이름 mail.arihakdang.conf 도 포함), 없으면 sites-available/.conf. +set -euo pipefail +[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; } +. "$(dirname "$0")/lib.sh" +load_site "${1:?site.env 경로}" +REPO_DEPLOY="$(cd "$(dirname "$0")" && pwd)" +if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then TEMPLATE="$REPO_DEPLOY/nginx/mail.conf.in" +elif [ -n "$CF_CERT" ] && [ -f "$CF_CERT" ]; then TEMPLATE="$REPO_DEPLOY/nginx/mail.cloudflare.conf.in" +else TEMPLATE="$REPO_DEPLOY/nginx/mail.http-only.conf.in"; fi +TARGET="" +for f in /etc/nginx/sites-enabled/*; do [ -f "$f" ] || continue; if grep -qE "^\s*server_name\s+$MAIL_HOST;" "$f"; then TARGET="$(readlink -f "$f")"; break; fi; done +[ -n "$TARGET" ] || { TARGET="/etc/nginx/sites-available/$MAIL_HOST.conf"; ln -sfn "$TARGET" "/etc/nginx/sites-enabled/$MAIL_HOST.conf"; } +BACKUP="$TARGET.bak-$(date +%Y%m%d-%H%M%S)"; [ -f "$TARGET" ] && cp "$TARGET" "$BACKUP" +render "$TEMPLATE" "$TARGET"; chmod 644 "$TARGET" +if nginx -t; then systemctl reload nginx; echo "✔ nginx 적용: $TARGET ($(basename "$TEMPLATE")), 이전 설정 $BACKUP" +else echo "nginx 검사 실패 — 이전 설정으로 되돌립니다" >&2; [ -f "$BACKUP" ] && cp "$BACKUP" "$TARGET"; exit 1; fi diff --git a/modules/arimail/deploy/nginx/mail-path.conf.in b/modules/arimail/deploy/nginx/mail-path.conf.in new file mode 100644 index 0000000..cfaf017 --- /dev/null +++ b/modules/arimail/deploy/nginx/mail-path.conf.in @@ -0,0 +1,27 @@ +# AriMail 경로 접두사(1.0.8, 요구사항 1): 이 사이트의 @MAIL_PATH@/ 아래를 게이트웨이(127.0.0.1:@PORT@)로 넘깁니다. deploy/site-mail-path.sh 가 만들어 +# /etc/nginx/snippets/ 에 두고, 사이트의 server(443) 블록에 include 합니다. 서브 도메인 사이트(site.example.com)처럼 메일용 호스트 이름을 따로 두기 어려울 때 +# https://site.example.com@MAIL_PATH@/ 가 그 도메인의 웹 메일이 됩니다(전체 설정 화면의 호스트 항목은 "site.example.com@MAIL_PATH@"). +# +# - proxy_pass 끝의 "/" 가 @MAIL_PATH@/ 를 떼고 넘기므로 게이트웨이는 늘 / 기준 경로를 봅니다. 대신 X-Forwarded-Prefix 로 접두사를 알려 주면 화면()·링크가 그 아래로 만들어집니다. +# - ^~ : 사이트 설정의 정규식 location(예: 숨김 파일 차단)이 이 경로를 가로채지 못하게 합니다. +# - 대용량 첨부파일(@MAIL_PATH@/api/files)은 본문 크기 제한 없이 버퍼링 없이 흘려보냅니다(mail.conf.in 과 같은 이유). +location = @MAIL_PATH@ { return 301 @MAIL_PATH@/; } +location ^~ @MAIL_PATH@/ { + proxy_pass http://127.0.0.1:@PORT@/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Prefix @MAIL_PATH@; + proxy_set_header CF-Connecting-IP ""; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + client_max_body_size 0; + proxy_request_buffering off; + proxy_buffering off; + proxy_no_cache 1; + proxy_cache_bypass 1; + # add_header 를 두지 않는 이유: location 안의 add_header 는 사이트 server 블록의 헤더(HSTS 등) 상속을 끊습니다. 캐시 금지·보안 헤더는 게이트웨이가 응답마다 붙입니다. +} diff --git a/modules/arimail/deploy/nginx/mail.cloudflare.conf.in b/modules/arimail/deploy/nginx/mail.cloudflare.conf.in new file mode 100644 index 0000000..851a74d --- /dev/null +++ b/modules/arimail/deploy/nginx/mail.cloudflare.conf.in @@ -0,0 +1,76 @@ +# @MAIL_HOST@ 가 Cloudflare 프록시(주황 구름) 뒤에 있을 때의 443 설정. +# Cloudflare 오리진 인증서(@CF_CERT@)를 써서 웹 화면과 웹훅을 바로 제공합니다. +# POP3·IMAP·SMTP 는 프록시를 지나지 못하므로, 메일 프로그램을 쓰려면 DNS 를 회색 구름으로 바꾸고 +# Let's Encrypt 발급 후 setup.sh 로 mail.conf 로 교체합니다. +server { + listen 80; listen [::]:80; + server_name @MAIL_HOST@; + location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; } + location / { return 301 https://@MAIL_HOST@$request_uri; } +} +server { + listen 443 ssl http2; listen [::]:443 ssl http2; + server_name @MAIL_HOST@; + ssl_certificate @CF_CERT@; + ssl_certificate_key @CF_KEY@; + ssl_protocols TLSv1.2 TLSv1.3; + server_tokens off; charset utf-8; client_max_body_size 32m; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + add_header X-Frame-Options DENY always; + add_header X-Robots-Tag "noindex, nofollow" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; + add_header Cache-Control "no-store, no-transform" always; + # 대용량 첨부파일 업로드(0.9.5): 본문 크기 제한을 두지 않고(크기는 Node 가 관리자·시스템 설정으로 거부) 버퍼링 없이 바로 흘려보냅니다. + # 예전에는 server 블록의 client_max_body_size 32m 에 걸려 32MB 가 넘는 파일이 nginx 의 413(HTML)으로 끊겼고 화면에는 일반 오류만 보였습니다. + # 버퍼링을 끄는 이유: 켜 두면 nginx 가 1GB 를 임시 파일에 다 받은 뒤 넘기므로 브라우저의 진행률은 100% 인데 서버 저장은 한참 뒤에 끝나고 디스크를 두 번 씁니다. + location = /api/files { + client_max_body_size 0; + proxy_request_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_pass http://127.0.0.1:@PORT@; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Prefix ""; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + # 대용량 첨부파일 다운로드 링크: 큰 파일을 오래 흘려보내므로 응답 버퍼링을 끄고 시간 제한을 넉넉히 둡니다. + location /dl/ { + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_pass http://127.0.0.1:@PORT@; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Prefix ""; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + location / { + proxy_pass http://127.0.0.1:@PORT@; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Prefix ""; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; + proxy_read_timeout 180s; + proxy_no_cache 1; proxy_cache_bypass 1; + } + access_log /var/log/nginx/@APP_NAME@.access.log; + error_log /var/log/nginx/@APP_NAME@.error.log; +} diff --git a/modules/arimail/deploy/nginx/mail.conf.in b/modules/arimail/deploy/nginx/mail.conf.in new file mode 100644 index 0000000..ef215b3 --- /dev/null +++ b/modules/arimail/deploy/nginx/mail.conf.in @@ -0,0 +1,90 @@ +# @MAIL_HOST@ — 웹 메일함과 Resend 웹훅. POP3·IMAP·SMTP 포트는 Node 프로세스가 직접 엽니다. +# +# DNS: @MAIL_HOST@ 는 이 서버를 직접 가리키는 A 레코드여야 합니다(Cloudflare 를 쓰면 프록시 끔, 회색 구름). +# 메일 프로그램 포트는 프록시를 지나지 못하고 공개 CA 인증서를 요구하므로 Let's Encrypt 인증서를 원본 서버에 둡니다. +# +# 인증서: certbot certonly --webroot -w @ACME_WEBROOT@ -d @MAIL_HOST@ (아래 경로가 만들어집니다) +server { + listen 80; + listen [::]:80; + server_name @MAIL_HOST@; + location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; } + location / { return 301 https://@MAIL_HOST@$request_uri; } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name @MAIL_HOST@; + + ssl_certificate /etc/letsencrypt/live/@MAIL_HOST@/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/@MAIL_HOST@/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + server_tokens off; + charset utf-8; + client_max_body_size 32m; + + # 로그인 전용 화면: 색인·캐시·프레임 금지. 응답 헤더는 Node 도 같이 붙이지만 nginx 레벨에서도 보장합니다. + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + add_header X-Frame-Options DENY always; + add_header X-Robots-Tag "noindex, nofollow" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; + add_header Cache-Control "no-store, no-transform" always; + + # 대용량 첨부파일 업로드(0.9.5): 본문 크기 제한을 두지 않고(크기는 Node 가 관리자·시스템 설정으로 거부) 버퍼링 없이 바로 흘려보냅니다. + # 예전에는 server 블록의 client_max_body_size 32m 에 걸려 32MB 가 넘는 파일이 nginx 의 413(HTML)으로 끊겼고 화면에는 일반 오류만 보였습니다. + # 버퍼링을 끄는 이유: 켜 두면 nginx 가 1GB 를 임시 파일에 다 받은 뒤 넘기므로 브라우저의 진행률은 100% 인데 서버 저장은 한참 뒤에 끝나고 디스크를 두 번 씁니다. + location = /api/files { + client_max_body_size 0; + proxy_request_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_pass http://127.0.0.1:@PORT@; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Prefix ""; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header CF-Connecting-IP ""; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + # 대용량 첨부파일 다운로드 링크: 큰 파일을 오래 흘려보내므로 응답 버퍼링을 끄고 시간 제한을 넉넉히 둡니다. + location /dl/ { + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_pass http://127.0.0.1:@PORT@; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Prefix ""; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header CF-Connecting-IP ""; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + location / { + proxy_pass http://127.0.0.1:@PORT@; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Prefix ""; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header CF-Connecting-IP ""; + proxy_read_timeout 180s; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + + access_log /var/log/nginx/@APP_NAME@.access.log; + error_log /var/log/nginx/@APP_NAME@.error.log; +} diff --git a/modules/arimail/deploy/nginx/mail.http-only.conf.in b/modules/arimail/deploy/nginx/mail.http-only.conf.in new file mode 100644 index 0000000..1998b87 --- /dev/null +++ b/modules/arimail/deploy/nginx/mail.http-only.conf.in @@ -0,0 +1,8 @@ +# 인증서 발급 전 임시 설정: ACME 챌린지만 받고 나머지는 503. 발급 후 setup.sh(또는 tls-when-ready 타이머)가 443 설정으로 교체합니다. +server { + listen 80; + listen [::]:80; + server_name @MAIL_HOST@; + location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; } + location / { return 503; } +} diff --git a/modules/arimail/deploy/setup.sh b/modules/arimail/deploy/setup.sh new file mode 100755 index 0000000..3cbd642 --- /dev/null +++ b/modules/arimail/deploy/setup.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# 최초 1회 서버 준비(이후 인증서 발급 뒤에 다시 실행) — root 로 실행: +# sudo env SITE=heart-m SOURCE_BUNDLE=/tmp/arimail.bundle bash deploy/setup.sh +# SITE 는 deploy/sites/.env 를 고릅니다(기본 arihakdang). 그 파일의 실행 계정(APP_USER)과 Node(NODE_BIN)는 미리 있어야 합니다. +# 새 사이트 파일은 deploy/new-site.sh 로 만듭니다(0.9.0). LOGIN_MODE(standalone | www)와 시스템 제한(LIMIT_*)은 그 파일에서 정하며, +# 환경 파일(gateway.env)은 처음 한 번만 만들므로 이후 바꾸려면 $ETC_DIR/gateway.env 를 직접 고치고 서비스를 재시작합니다. +# 하는 일: 디렉터리·사이트 설정 사본·환경 파일 → 저장소(번들 또는 원격) → systemd·sudoers → nginx(인증서 유무에 따라 80 전용/443) → certbot 훅 → 내장 Haraka(haraka-apply.sh, 1.0.0). +# 1.0.0: 운영 설정(Resend 키 등)은 환경 파일이 아니라 데이터 디렉터리의 config.json 에 있으며 배포 뒤 초기 설정 페이지에서 채웁니다(값이 없어도 배포는 성공). +set -euo pipefail +[ "$(id -u)" = 0 ] || { echo 'sudo 로 실행하세요' >&2; exit 1; } +. "$(dirname "$0")/lib.sh" +SITE="${SITE:-arihakdang}" +load_site "$DEPLOY_DIR/sites/$SITE.env" +echo "▶ 사이트: $SITE ($MAIL_HOST, 계정 $APP_USER, $APP_DIR, 로그인 $LOGIN_MODE)" + +id "$APP_USER" >/dev/null 2>&1 || { echo "실행 계정 $APP_USER 이 없습니다. 계정을 먼저 만드세요(www 연동 사이트라면 www 배포 문서를 따르세요)." >&2; exit 1; } +[ -x "$NODE_BIN" ] || { echo "Node 실행 파일이 없습니다: $NODE_BIN — nodejs.org 의 Linux 바이너리(22.13+)를 풀어 두세요(예: tar -xJf node-v22.x-linux-x64.tar.xz -C $(dirname "$(dirname "$NODE_BIN")") --strip-components=1)." >&2; exit 1; } +MAJOR="$("$NODE_BIN" --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $("$NODE_BIN" --version)" >&2; exit 1; } + +mkdir -p "$APP_DIR"/{repo,releases,data} "$ETC_DIR" "$ACME_WEBROOT" +chown -R "$APP_USER:$APP_USER" "$APP_DIR" +# 사이트 설정 사본: deploy.sh 와 tls-when-ready.sh 가 저장소 밖의 이 파일로 자기 사이트를 압니다. +install -o "$APP_USER" -g "$APP_USER" -m 644 "$DEPLOY_DIR/sites/$SITE.env" "$APP_DIR/site.env" +if [ ! -f "$ETC_DIR/gateway.env" ]; then + render "$DEPLOY_DIR/gateway.env.in" "$ETC_DIR/gateway.env" + chown "$APP_USER:$APP_USER" "$ETC_DIR/gateway.env"; chmod 600 "$ETC_DIR/gateway.env" + echo "→ $ETC_DIR/gateway.env 를 만들었습니다(배포에 필요한 값만). Resend 키 등 운영 설정은 배포 뒤 초기 설정 페이지에서 채웁니다." +fi +if [ ! -d "$APP_DIR/repo/.git" ]; then + if [ -n "${SOURCE_BUNDLE:-}" ]; then + echo '→ 번들에서 저장소를 만듭니다 (서버에 원격 자격 증명을 남기지 않음).' + sudo -u "$APP_USER" git clone -q -b main "$SOURCE_BUNDLE" "$APP_DIR/repo" + sudo -u "$APP_USER" git -C "$APP_DIR/repo" remote set-url origin "$REPO_URL" + else + echo "→ 저장소를 $APP_USER 계정으로 복제합니다 ($REPO_URL 자격 증명 필요)." + sudo -u "$APP_USER" git clone "$REPO_URL" "$APP_DIR/repo" + fi +fi +REPO_DEPLOY="$APP_DIR/repo/deploy" +render "$REPO_DEPLOY/systemd/mail-gateway.service.in" "/etc/systemd/system/$APP_NAME.service"; chmod 644 "/etc/systemd/system/$APP_NAME.service" +# 배포 스크립트(실행 계정)가 서비스 재시작과 nginx 재적재만 할 수 있게 합니다. +sudoers_line > "/etc/sudoers.d/$APP_NAME"; chmod 440 "/etc/sudoers.d/$APP_NAME"; visudo -c -q +# nginx: 인증서가 있으면 443, Cloudflare 오리진 인증서만 있으면 프록시용 443, 둘 다 없으면 80(ACME 챌린지)만 엽니다. +if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then + render "$REPO_DEPLOY/nginx/mail.conf.in" "/etc/nginx/sites-available/$MAIL_HOST.conf" +elif [ -n "$CF_CERT" ] && [ -f "$CF_CERT" ]; then + render "$REPO_DEPLOY/nginx/mail.cloudflare.conf.in" "/etc/nginx/sites-available/$MAIL_HOST.conf" +else + render "$REPO_DEPLOY/nginx/mail.http-only.conf.in" "/etc/nginx/sites-available/$MAIL_HOST.conf" +fi +chmod 644 "/etc/nginx/sites-available/$MAIL_HOST.conf" +ln -sfn "/etc/nginx/sites-available/$MAIL_HOST.conf" "/etc/nginx/sites-enabled/$MAIL_HOST.conf" +nginx -t && systemctl reload nginx +systemctl daemon-reload +systemctl enable -q "$APP_NAME" +# 메일 포트 TLS: certbot 의 개인키는 root 만 읽으므로, 실행 계정이 읽을 사본을 만들고 갱신 때마다 다시 복사합니다. +mkdir -p /etc/letsencrypt/renewal-hooks/deploy +render "$REPO_DEPLOY/letsencrypt-deploy-hook.sh.in" "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME.sh"; chmod 755 "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME.sh" +if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then + RENEWED_LINEAGE="/etc/letsencrypt/live/$MAIL_HOST" SKIP_RESTART=1 sh "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME.sh" + sed -i -E "s#^POP_TLS_CERT=.*#POP_TLS_CERT=$ETC_DIR/tls/fullchain.pem#; s#^POP_TLS_KEY=.*#POP_TLS_KEY=$ETC_DIR/tls/privkey.pem#" "$ETC_DIR/gateway.env" + # 서비스가 이미 돌고 있으면 새 인증서로 다시 띄웁니다(처음 설치라면 아직 current 가 없어 deploy.sh 가 시작합니다). + if systemctl is-active --quiet "$APP_NAME"; then systemctl restart "$APP_NAME"; fi +else + # 인증서가 생길 때까지 15분마다 DNS 를 확인해 자동 발급하는 타이머를 켭니다(발급되면 스스로 꺼짐). + render "$REPO_DEPLOY/systemd/mail-tls.service.in" "/etc/systemd/system/$APP_NAME-tls.service" + render "$REPO_DEPLOY/systemd/mail-tls.timer.in" "/etc/systemd/system/$APP_NAME-tls.timer" + systemctl daemon-reload; systemctl enable -q --now "$APP_NAME-tls.timer" + cat <&2; exit 1; } +. "$(dirname "$0")/lib.sh" +TARGET=""; MAIL_PATH=/mail; SITE_FILE="" +while [ $# -gt 0 ]; do + case "$1" in + --path) MAIL_PATH="$2"; shift 2 ;; + --site) SITE_FILE="$2"; shift 2 ;; + -h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) [ -z "$TARGET" ] || { echo "알 수 없는 인수: $1" >&2; exit 1; }; TARGET="$1"; shift ;; + esac +done +[ -n "$TARGET" ] || { echo "nginx 설정 파일이나 사이트 호스트 이름을 주세요(--help)" >&2; exit 1; } +printf '%s' "$MAIL_PATH" | grep -Eq '^/[A-Za-z0-9._~-]+(/[A-Za-z0-9._~-]+)*$' || { echo "경로 형식이 아닙니다: $MAIL_PATH (예: /mail)" >&2; exit 1; } +if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi +load_site "$SITE_FILE" +# 설정 파일 찾기: 파일 경로면 그대로, 아니면 sites-enabled 에서 server_name 줄에 그 호스트가 있는 파일(하나여야 함). +if [ -f "$TARGET" ]; then CONF="$TARGET"; else + HOST_LC="$(printf '%s' "$TARGET" | tr 'A-Z' 'a-z')" + mapfile -t FOUND < <(grep -lE "^[[:space:]]*server_name[[:space:]]+([^;]*[[:space:]])?${HOST_LC//./\\.}([[:space:];]|$)" /etc/nginx/sites-enabled/*.conf /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf 2>/dev/null | sort -u || true) + [ "${#FOUND[@]}" -gt 0 ] || { echo "server_name 에 $HOST_LC 가 있는 nginx 설정을 찾지 못했습니다(사이트를 먼저 발행하세요)" >&2; exit 1; } + [ "${#FOUND[@]}" -eq 1 ] || { echo "설정 파일이 여럿입니다 — 파일 경로를 직접 주세요: ${FOUND[*]}" >&2; exit 1; } + CONF="${FOUND[0]}" +fi +SNIPPET="/etc/nginx/snippets/$APP_NAME-mail-path.conf"; INCLUDE=" include $SNIPPET;" +mkdir -p /etc/nginx/snippets +sed -e "s|@PORT@|$PORT|g" -e "s|@MAIL_PATH@|$MAIL_PATH|g" "$DEPLOY_DIR/nginx/mail-path.conf.in" > "$SNIPPET"; chmod 644 "$SNIPPET" +echo "▶ $CONF 의 443 블록에 $MAIL_PATH → 127.0.0.1:$PORT (조각 $SNIPPET)" +BAK="$CONF.bak-$(date +%Y%m%d-%H%M%S)"; cp -p "$CONF" "$BAK" +# server 블록 단위로 읽어 443 을 듣고 include 가 없는 블록의 닫는 괄호 앞에 include 를 넣습니다. 블록은 "server {" 로 열리고 첫 칸의 "}" 로 닫힙니다(웹메이커·AriMail 템플릿 모두 이 꼴). +awk -v inc="$INCLUDE" -v snippet="$SNIPPET" ' + /^[[:space:]]*server[[:space:]]*\{[[:space:]]*$/ { inblock = 1; buf = ""; ssl = 0; has = 0 } + inblock { + buf = buf $0 "\n" + if ($0 ~ /^[[:space:]]*listen[^;]*[^0-9]443([^0-9]|$)/) ssl = 1 + if (index($0, snippet)) has = 1 + if ($0 ~ /^\}[[:space:]]*$/) { if (ssl && !has) { sub(/\}[[:space:]]*\n$/, inc "\n}\n", buf); added++ } printf "%s", buf; inblock = 0 } + next + } + { print } + END { if (inblock) printf "%s", buf; if (!added) print "(이미 적용되어 있거나 443 블록이 없음)" > "/dev/stderr" } +' "$BAK" > "$CONF" +# 닫는 괄호를 못 찾아 블록이 잘리는 일이 없도록 줄 수를 비교합니다(include 줄만 늘어야 함). 사본은 성공해도 남겨 둡니다. +[ "$(wc -l < "$CONF")" -ge "$(wc -l < "$BAK")" ] || { cp -p "$BAK" "$CONF"; echo "✖ 설정 파일 구조를 해석하지 못해 되돌렸습니다(블록 닫는 괄호가 첫 칸에 있어야 함): $CONF" >&2; exit 1; } +if nginx -t; then systemctl reload nginx; echo "✔ https://<사이트 호스트>$MAIL_PATH/ 준비 완료(사본 $BAK). 전체 설정 화면의 도메인 호스트 항목에 \"<사이트 호스트>$MAIL_PATH\" 를 넣으세요." +else cp -p "$BAK" "$CONF"; echo "✖ nginx 설정 검사 실패 — 원래 파일로 되돌렸습니다($BAK 는 남김)" >&2; exit 1; fi diff --git a/modules/arimail/deploy/systemd/mail-gateway.service.in b/modules/arimail/deploy/systemd/mail-gateway.service.in new file mode 100644 index 0000000..6510838 --- /dev/null +++ b/modules/arimail/deploy/systemd/mail-gateway.service.in @@ -0,0 +1,39 @@ +[Unit] +Description=@APP_NAME@ — AriMail gateway (@MAIL_HOST@: webmail, IMAP/POP3/SMTP, Resend or Haraka) +Documentation=https://source.ibit.kr/OZ-Group/AriMail +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# 사이트 설정(deploy/sites/@SITE@.env)의 실행 계정. www 사이트와 연동하면 www DB 를 같은 권한으로 읽기 위해 www 와 같은 계정을 씁니다. +User=@APP_USER@ +Group=@APP_USER@ +WorkingDirectory=@APP_DIR@/current +EnvironmentFile=@ETC_DIR@/gateway.env +Environment=NODE_ENV=production +Environment=HOST=127.0.0.1 +Environment=PORT=@PORT@ +ExecStart=@NODE_BIN@ server/index.mjs +Restart=always +RestartSec=3 + +# 110/143/465/587/993/995 포트를 일반 계정으로 열기 위한 권한 +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +# 게이트웨이 DB·설정 파일(config.json, 1.0.0)·초기 설정 토큰과(www 연동 시) WAL 모드 www DB 를 읽기 위한 공유 메모리(-shm) 경로 +ReadWritePaths=@APP_DIR@/data @EXTRA_RW@ +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +StandardOutput=journal +StandardError=journal +SyslogIdentifier=@APP_NAME@ + +[Install] +WantedBy=multi-user.target diff --git a/modules/arimail/deploy/systemd/mail-haraka.service.in b/modules/arimail/deploy/systemd/mail-haraka.service.in new file mode 100644 index 0000000..8a42012 --- /dev/null +++ b/modules/arimail/deploy/systemd/mail-haraka.service.in @@ -0,0 +1,42 @@ +[Unit] +Description=@APP_NAME@ — AriMail 내장 Haraka 관리(수신 25 / 로컬 제출 포트, 설정 파일에 따라 켜고 끔) +Documentation=https://source.ibit.kr/OZ-Group/AriMail +After=network-online.target @APP_NAME@.service +Wants=network-online.target + +[Service] +Type=simple +User=@APP_USER@ +Group=@APP_USER@ +WorkingDirectory=@APP_DIR@/current +# 게이트웨이와 같은 환경 파일: DB_FILE(설정 파일 위치)·PORT(게이트웨이 HTTP)·MAILBOX(도메인)·PUBLIC_HOST·POP_TLS_* 를 읽습니다. 값이 없어도 뜹니다. +EnvironmentFile=@ETC_DIR@/gateway.env +Environment=NODE_ENV=production +Environment=PORT=@PORT@ +Environment=HARAKA_DIR=@APP_DIR@/haraka +# haraka/run.mjs 는 config.json 을 5초마다 보고 Haraka(자식 프로세스)를 띄우거나 끕니다. 쓰지 않을 때는 아무 포트도 열지 않고 대기만 합니다. +ExecStart=@NODE_BIN@ @APP_DIR@/current/haraka/run.mjs +Restart=always +RestartSec=3 +KillMode=mixed +TimeoutStopSec=15 + +# 25 포트를 일반 계정으로 열기 위한 권한(자식 프로세스 Haraka 도 상속) +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +# haraka/: 설정·플러그인·큐·node_modules, data/: 상태 파일(haraka-status.json) +ReadWritePaths=@APP_DIR@/haraka @APP_DIR@/data +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +StandardOutput=journal +StandardError=journal +SyslogIdentifier=@APP_NAME@-haraka + +[Install] +WantedBy=multi-user.target diff --git a/modules/arimail/deploy/systemd/mail-tls.service.in b/modules/arimail/deploy/systemd/mail-tls.service.in new file mode 100644 index 0000000..b0acf98 --- /dev/null +++ b/modules/arimail/deploy/systemd/mail-tls.service.in @@ -0,0 +1,8 @@ +[Unit] +Description=@APP_NAME@ — DNS 가 이 서버를 가리키면 Let's Encrypt 인증서를 받고 443·메일 포트 TLS 를 켭니다 +After=network-online.target nginx.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/env bash @APP_DIR@/repo/deploy/tls-when-ready.sh @APP_DIR@/site.env diff --git a/modules/arimail/deploy/systemd/mail-tls.timer.in b/modules/arimail/deploy/systemd/mail-tls.timer.in new file mode 100644 index 0000000..6afb15e --- /dev/null +++ b/modules/arimail/deploy/systemd/mail-tls.timer.in @@ -0,0 +1,10 @@ +[Unit] +Description=@APP_NAME@ — 인증서가 생길 때까지 15분마다 DNS 를 확인해 TLS 를 켭니다(발급되면 스스로 멈춤) + +[Timer] +OnBootSec=2min +OnUnitActiveSec=15min +Unit=@APP_NAME@-tls.service + +[Install] +WantedBy=timers.target diff --git a/modules/arimail/deploy/tls-when-ready.sh b/modules/arimail/deploy/tls-when-ready.sh new file mode 100755 index 0000000..05ed898 --- /dev/null +++ b/modules/arimail/deploy/tls-when-ready.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# 인증서 자동 발급(0.8.18~) — root 로 실행(보통 -tls.timer 가 15분마다 부름): bash deploy/tls-when-ready.sh +# +# 새 서버에 설치할 때 DNS(MAIL_HOST → 이 서버)가 아직 바뀌지 않았으면 Let's Encrypt HTTP-01 검증이 실패하므로 setup.sh 는 +# 80 포트만 여는 임시 nginx 설정으로 끝납니다. 이 스크립트는 MAIL_HOST 의 A 레코드가 이 서버의 IP 와 같아지는 순간을 기다렸다가 +# certbot 으로 인증서를 받고 setup.sh 를 다시 실행해 443 설정·인증서 사본·메일 포트 TLS 를 켠 뒤 타이머를 멈춥니다. +# 이미 인증서가 있으면 아무 일도 하지 않고 타이머만 끕니다(갱신은 certbot 의 자체 타이머가 합니다). +set -euo pipefail +[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; } +. "$(dirname "$0")/lib.sh" +load_site "${1:?site.env 경로}" +TIMER="$APP_NAME-tls.timer" +stop_timer() { systemctl disable --now "$TIMER" >/dev/null 2>&1 || true; } + +if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then echo "이미 인증서가 있습니다: $MAIL_HOST"; stop_timer; exit 0; fi +# DNS 확인: 권위 DNS 가 아니라 이 서버의 해석기 기준입니다(certbot 검증 서버가 보는 값과 보통 같습니다). +RESOLVED="$(getent ahostsv4 "$MAIL_HOST" 2>/dev/null | awk '{print $1}' | sort -u || true)" +MINE="$(ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1 | sort -u)" +MATCH=""; for a in $RESOLVED; do for b in $MINE; do [ "$a" = "$b" ] && MATCH="$a"; done; done +if [ -z "$MATCH" ]; then echo "$MAIL_HOST 가 아직 이 서버를 가리키지 않습니다 (DNS: ${RESOLVED:-없음} / 서버: $MINE) — 다음에 다시 확인"; exit 0; fi +echo "DNS 확인됨($MAIL_HOST → $MATCH). 인증서를 발급합니다" +mkdir -p "$ACME_WEBROOT" +# 등록된 certbot 계정이 없으면 이메일 없이 등록합니다(만료 안내 메일을 받으려면 나중에 certbot register/update_account 로 바꾸세요). +ACCOUNT_FLAG=""; ls /etc/letsencrypt/accounts/*/directory/*/regr.json >/dev/null 2>&1 || ACCOUNT_FLAG="--register-unsafely-without-email" +certbot certonly --webroot -w "$ACME_WEBROOT" -d "$MAIL_HOST" --non-interactive --agree-tos --keep-until-expiring $ACCOUNT_FLAG +echo "발급 완료. setup.sh 를 다시 실행해 443 설정과 메일 포트 TLS 를 켭니다" +SITE="$SITE" bash "$APP_DIR/repo/deploy/setup.sh" +stop_timer +echo "✔ TLS 준비 완료: https://$MAIL_HOST/" diff --git a/modules/arimail/deploy/webmaker-access.sh b/modules/arimail/deploy/webmaker-access.sh new file mode 100755 index 0000000..60e2509 --- /dev/null +++ b/modules/arimail/deploy/webmaker-access.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# 웹메이커(webmaker) 계정 연동을 위한 읽기 도우미 설치(1.0.8, 요구사항 1) — root 로 실행: +# sudo bash /repo/deploy/webmaker-access.sh [<웹메이커 DATA_ROOT>] [--site ] [--node ] [--sudo] +# +# 왜 필요한가 +# webmaker 인증 모드는 웹메이커의 사용자 파일(DATA_ROOT/users.json, sites/, modules/, domains.json)을 읽기 전용으로 참조합니다. 그런데 웹메이커는 그 파일을 +# 자기 서비스 계정(예: master)만 읽을 수 있게(0600/0700) 쓰므로 AriMail 실행 계정(예: arihakdang)이 직접 읽을 수 없습니다. 웹메이커가 파일을 다시 쓸 때마다 +# 권한이 0600 으로 돌아가므로 ACL 로도 풀리지 않습니다. 그래서 "그 계정으로 스냅샷 하나를 읽어 JSON 으로 돌려주는" 도우미를 둡니다. +# +# 하는 일 +# 1) server/webmaker-snapshot.mjs 의 사본을 root 소유로 /usr/local/lib/arimail/ 에 둡니다(실행 계정이 바꿀 수 없어야 다른 계정으로 도는 코드가 안전). +# 실행용 node 도 root 소유여야 하므로 /usr/bin/node(22 이상)를 먼저 찾고, 없으면 --node 로 지정한 root 소유 파일을 씁니다. +# 2) systemd 소켓 도우미(기본): /run/arimail-webmaker/<슬러그>.sock 에 접속하면 systemd 가 웹메이커 소유 계정으로 도우미를 띄워 JSON 을 소켓에 씁니다(Accept=yes). +# AriMail 서비스 유닛은 NoNewPrivileges=yes·CapabilityBoundingSet 로 잠겨 있어 sudo 가 동작하지 않으므로 이 방식이 필요합니다. 소켓은 AriMail 실행 계정의 그룹만 접속(0660). +# 3) --sudo: systemd 가 없는 서버용 — 감싸는 스크립트 /usr/local/lib/arimail/webmaker-snapshot 와 sudoers 규칙 한 줄(그 디렉터리 인수 하나만 허용)을 설치합니다. +# 4) 실행 계정으로 한 번 읽어 보고(계정 수 출력), 전체 설정 화면에서 적을 값(디렉터리·읽을 계정)을 안내합니다. +# 게이트웨이 설정·환경 파일·데이터와 웹메이커의 파일은 건드리지 않습니다. 다시 실행해도 안전합니다(같은 내용으로 덮어씀). +set -euo pipefail +[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; } +. "$(dirname "$0")/lib.sh" +DATA_ROOT=""; SITE_FILE=""; NODE_OVERRIDE=""; MODE=socket +while [ $# -gt 0 ]; do + case "$1" in + --site) SITE_FILE="$2"; shift 2 ;; + --node) NODE_OVERRIDE="$2"; shift 2 ;; + --sudo) MODE=sudo; shift ;; + -h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) [ -z "$DATA_ROOT" ] || { echo "알 수 없는 인수: $1" >&2; exit 1; }; DATA_ROOT="$1"; shift ;; + esac +done +if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi +load_site "$SITE_FILE" +# 데이터 디렉터리: 인수 → webmaker 서비스의 환경 변수(DATA_ROOT) → /data/webmaker +if [ -z "$DATA_ROOT" ]; then + DATA_ROOT="$(systemctl show -p Environment webmaker 2>/dev/null | tr ' ' '\n' | sed -n 's/^DATA_ROOT=//p' | head -1 || true)" + [ -n "$DATA_ROOT" ] || DATA_ROOT=/data/webmaker +fi +DATA_ROOT="${DATA_ROOT%/}" +# sudoers·유닛 파일에 그대로 적히므로 경로에 허용하는 글자를 제한합니다(공백·와일드카드·쉼표가 들어가면 규칙이 깨지거나 넓어짐). +printf '%s' "$DATA_ROOT" | grep -Eq '^/[A-Za-z0-9._/-]+$' || { echo "데이터 디렉터리는 영문·숫자·._- 로 된 절대 경로여야 합니다: $DATA_ROOT" >&2; exit 1; } +[ -f "$DATA_ROOT/users.json" ] || { echo "웹메이커 사용자 파일이 없습니다: $DATA_ROOT/users.json (DATA_ROOT 를 확인하세요)" >&2; exit 1; } +OWNER="$(stat -c %U "$DATA_ROOT/users.json")" +id "$APP_USER" >/dev/null 2>&1 || { echo "AriMail 실행 계정이 없습니다: $APP_USER (사이트 설정 $SITE_FILE)" >&2; exit 1; } +APP_GROUP="$(id -gn "$APP_USER")" +# 실행용 node: root 소유 + 22 이상. /usr/bin/node → --node 지정 → 사이트의 NODE_BIN(루트 소유일 때만). +pick_node() { + local cand; for cand in "$NODE_OVERRIDE" /usr/bin/node "$NODE_BIN"; do + [ -n "$cand" ] && [ -x "$cand" ] || continue + [ "$(stat -c %U "$cand")" = root ] || { echo " (건너뜀) root 소유가 아닌 node: $cand" >&2; continue; } + local major; major="$("$cand" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)" + [ "$major" -ge 22 ] 2>/dev/null || { echo " (건너뜀) node 22 미만: $cand" >&2; continue; } + printf '%s' "$cand"; return 0 + done; return 1 +} +NODE_EXEC="$(pick_node)" || { echo "root 소유의 node 22 이상을 찾지 못했습니다. apt 로 설치하거나 --node <경로> 로 지정하세요(실행 계정이 바꿀 수 있는 파일은 쓰지 않습니다)." >&2; exit 1; } +LIB=/usr/local/lib/arimail +# 슬러그·소켓 경로: server/webmaker.mjs 의 webmakerSocketPath 와 같은 규칙(/data/webmaker → data-webmaker). +SLUG="$(printf '%s' "$DATA_ROOT" | sed 's#^/*##; s#[^A-Za-z0-9]#-#g')"; SOCK_DIR=/run/arimail-webmaker; SOCK="$SOCK_DIR/$SLUG.sock" +echo "▶ 웹메이커 읽기 도우미 설치($MODE): 데이터 $DATA_ROOT (소유 $OWNER) · AriMail 계정 $APP_USER($APP_GROUP) · node $NODE_EXEC" +install -d -o root -g root -m 755 "$LIB" +install -o root -g root -m 644 "$DEPLOY_DIR/../server/webmaker-snapshot.mjs" "$LIB/webmaker-snapshot.mjs" +[ "$MODE" = socket ] && ! command -v systemctl >/dev/null 2>&1 && { echo " systemd 가 없어 sudo 방식으로 설치합니다"; MODE=sudo; } +if [ "$MODE" = socket ]; then + UNIT="arimail-webmaker-$SLUG" + install -d -o root -g root -m 755 "$SOCK_DIR" + printf 'd %s 0755 root root -\n' "$SOCK_DIR" > /etc/tmpfiles.d/arimail-webmaker.conf + cat > "/etc/systemd/system/$UNIT.socket" < "/etc/systemd/system/$UNIT@.service" </dev/null 2>&1 || systemctl restart "$UNIT.socket" + systemctl restart "$UNIT.socket" + echo " 소켓: $SOCK ($UNIT.socket, 서비스 $UNIT@.service → $OWNER)" + # 실행 계정으로 실제 접속(계정 수만 출력) + READ_CMD="sudo -u $APP_USER $NODE_EXEC -e" + JS='const s=require("net").connect(process.argv[1]);let b="";s.setEncoding("utf8");s.on("data",c=>b+=c);s.on("end",()=>{const d=JSON.parse(b);console.log(`회원 ${Object.keys(d.users).length}명 · 사이트 ${Object.keys(d.sites).length}개 · 도메인 연결 ${Object.keys(d.domainSites).length}개`);});s.on("error",e=>{console.error(e.message);process.exit(1);});' + if OUT="$($READ_CMD "$JS" "$SOCK" 2>&1)"; then echo "✔ 확인: $APP_USER 계정이 소켓으로 스냅샷을 읽었습니다 — $OUT" + else echo "✖ 실행 계정으로 소켓 읽기에 실패했습니다: $OUT (journalctl -u '$UNIT@*' 확인)" >&2; exit 1; fi +else + cat > "$LIB/webmaker-snapshot" < "$TMP_RULE" + visudo -cf "$TMP_RULE" >/dev/null || { rm -f "$TMP_RULE"; echo "✖ sudoers 규칙 검사 실패(계정 이름·경로 확인)" >&2; exit 1; } + install -o root -g root -m 440 "$TMP_RULE" "$RULE"; rm -f "$TMP_RULE" + echo " sudoers: $RULE (주의: AriMail 서비스 유닛에 NoNewPrivileges=yes 가 있으면 sudo 가 동작하지 않습니다 — systemd 서버에서는 소켓 방식을 쓰세요)" + if OUT="$(sudo -u "$APP_USER" sudo -n -u "$OWNER" "$LIB/webmaker-snapshot" "$DATA_ROOT" 2>&1)"; then + N="$(printf '%s' "$OUT" | "$NODE_EXEC" -e 'let s="";process.stdin.on("data",(c)=>s+=c).on("end",()=>{const d=JSON.parse(s);console.log(`회원 ${Object.keys(d.users).length}명 · 사이트 ${Object.keys(d.sites).length}개 · 도메인 연결 ${Object.keys(d.domainSites).length}개`);});')" + echo "✔ 확인: $APP_USER 계정이 $OWNER 로 스냅샷을 읽었습니다 — $N" + else echo "✖ 실행 계정으로 읽기에 실패했습니다: $(printf '%s' "$OUT" | tail -1)" >&2; exit 1; fi +fi +cat < 1.0.0 부터 프로젝트 이름은 **AriMail**, 저장소는 https://source.ibit.kr/OZ-Group/AriMail 입니다(내용은 같고 이름만 바뀜). +> 운영 설정(Resend 키 등)은 환경 파일이 아니라 **설정 파일 + 초기 설정 페이지**(10절)에서 다루고, 수·발신은 Resend 또는 **내장 Haraka**(12절)로 할 수 있습니다. + +www.arihakdang.com 과 **같은 서버(187.53.133.173), 같은 실행 계정(`arihakdang`), 같은 Node(`/opt/arihakdang-node`)** 에서 돌아갑니다. +관리자 계정은 www 의 `users` 테이블을 읽기 전용으로 읽어 인증하므로 별도 계정·비밀번호가 없습니다. +사용자 이름 `admin` / `admin@arihakdang.com` 은 메일함 소유 관리자의 별칭입니다. 소유자는 `MAILBOX_OWNER_LOGIN`(www 로그인 ID 또는 이메일) → +이메일이 메일함 주소인 관리자 → 유일한 활성 관리자 순으로 정합니다. 관리자가 둘 이상이면 `MAILBOX_OWNER_LOGIN` 을 꼭 지정하세요. + +``` +Resend 수신(admin@arihakdang.com) + ├─ 웹훅 email.received ──▶ POST https://mail.arihakdang.com/api/webhooks/resend (즉시) + └─ GET /emails/receiving ──▶ 1시간마다 동기화 (보충) + │ 원본(.eml)·첨부·정제 본문을 SQLite 에 기한 없이 보관 + ├─ 웹 메일함 https://mail.arihakdang.com/ (보기·답장·발송) + ├─ IMAP mail.arihakdang.com:993 (TLS) / 143 (STARTTLS) + ├─ POP3 mail.arihakdang.com:995 (TLS) / 110 (STLS 또는 일반 접속) + └─ SMTP 제출 mail.arihakdang.com:465 (TLS) / 587 (STARTTLS) + 로그인 비밀번호로 인증 → 서버 내부 RESEND_API_KEY 로 Resend API 발송 +``` + +## 1. DNS 와 인증서 + +1. Cloudflare DNS 에 `mail` A 레코드 → `187.53.133.173`, **프록시 끔(DNS only, 회색 구름)**. + POP3 포트(110/995)는 Cloudflare 프록시를 지나지 못하고, 메일 클라이언트는 공개 CA 인증서를 요구합니다. + 그래서 이 호스트만 원본 IP 가 공개됩니다. www 의 Cloudflare 전용 차단(444)은 이 vhost 에 적용하지 않습니다. +2. 서버에서 인증서 발급: + ```bash + sudo apt install -y certbot + sudo mkdir -p /var/www/letsencrypt + sudo certbot certonly --webroot -w /var/www/letsencrypt -d mail.arihakdang.com + ``` + 발급 후 `sudo bash deploy/setup.sh` 를 다시 실행하면 갱신 훅(`deploy/letsencrypt-deploy-hook.sh.in` 에서 만든 `/etc/letsencrypt/renewal-hooks/deploy/arihakdang-mail.sh`)이 인증서 사본을 + `/etc/arihakdang-mail/tls/` 에 서비스 계정 권한으로 만들고, nginx(443)와 POP3(995/STLS)가 같은 인증서를 씁니다. + certbot 이 갱신할 때마다 훅이 사본을 갱신하고 서비스를 재시작합니다. + (프로세스도 1시간마다 인증서 파일을 다시 읽으므로 재시작 없이도 새 인증서가 적용됩니다.) +3. 방화벽에 110, 995(POP3)와 143, 993(IMAP) TCP 를 엽니다. 80/443 은 www 와 공유합니다. + +## 2. 서버 준비와 배포 + +```bash +sudo bash deploy/setup.sh # 디렉터리, 환경 파일(배포에 필요한 값만), systemd, nginx, 내장 Haraka 준비 +sudo -u arihakdang /srv/arihakdang-mail/repo/deploy/deploy.sh # 끝에 "초기 설정 페이지" 주소(토큰 포함)가 출력됩니다 +journalctl -u arihakdang-mail -n 50 --no-pager +``` + +1.0.0 부터 **환경 파일에 Resend 키를 적지 않습니다.** 배포가 끝나면 출력된 초기 설정 페이지 주소(`http(s)://<서버>:8791/?token=…`)를 열어 +수·발신 방식과 키를 채우세요(10절). 값이 비어 있어도 배포는 실패하지 않고, 저장하면 재시작 없이 적용됩니다. + +배포 스크립트는 `git reset --hard origin/main` → 테스트 → `releases/<시각>` 복사 → DB 백업·마이그레이션 → +`current` 링크 교체 → 재시작 순서이며, 재시작에 실패하면 이전 릴리스로 되돌립니다. +서버에는 `source.ibit.kr` 자격 증명을 두지 않습니다. 작업 PC 에서 번들을 만들어 올리고 번들로 배포합니다. + +```bash +git bundle create /tmp/mail-gateway.bundle main && scp /tmp/mail-gateway.bundle hostinger:/tmp/ +ssh hostinger 'sudo install -o arihakdang -g arihakdang -m 644 /tmp/mail-gateway.bundle /srv/arihakdang-mail/source.bundle && sudo -u arihakdang env SOURCE_BUNDLE=/srv/arihakdang-mail/source.bundle /srv/arihakdang-mail/repo/deploy/deploy.sh' +``` + +배포 스크립트의 재시작에는 sudoers 규칙이 필요합니다(`setup.sh` 가 `/etc/sudoers.d/arihakdang-mail` 로 만듭니다). + +`deploy/` 의 systemd 유닛·nginx·certbot 훅·환경 파일은 0.8.18 부터 `.in` 템플릿이며, `setup.sh` 가 사이트 설정(`deploy/sites/<이름>.env`, 기본 `arihakdang`)으로 +채워 설치합니다. 다른 도메인에 설치하는 방법은 8절, 설치 옵션(로그인 방식 `LOGIN_MODE`, 시스템 제한 `LIMIT_*`)은 9절을 보세요. + +## 3. Resend 웹훅 + +1. Resend 대시보드 → **Webhooks → Add Endpoint** + - URL: `https://mail.arihakdang.com/api/webhooks/resend` + - Events: `email.received` +2. 만들어진 엔드포인트의 **Signing Secret(`whsec_…`)** 을 초기 설정 페이지(또는 `/admin` → 메일 서비스 설정)의 Resend 카드 "웹훅 서명 비밀키" 에 넣고 저장합니다(재시작 불필요, 1.0.0). + (0.9.x 까지는 `gateway.env` 의 `RESEND_WEBHOOK_SECRET` 이었습니다. 남아 있는 값은 1.0.0 첫 시작 때 설정 파일로 가져갑니다.) +3. 웹훅은 본문을 담지 않으므로 게이트웨이가 `GET /emails/receiving/{id}` 로 전문·헤더·첨부·원본(raw) 을 받아 저장합니다. + 서명이 없거나 틀리면 401, 저장에 실패하면 500 을 돌려주어 Resend 가 재시도합니다. +4. 웹훅과 무관하게 **1시간마다** 수신·발송 목록을 확인해 빠진 메일을 보충합니다(관리자 페이지 → 서버 상태의 "지금 동기화"로 즉시 실행). + 한 번에 받은 메일 500건·보낸 메일 200건(최신순)까지 거슬러 올라갑니다. + 웹 메일함 목록을 열어 두면 1분마다 **빠른 확인**을 합니다. 최신 메일부터 보다가 이미 저장한 메일을 만나면 멈추므로 Resend 호출이 한두 번에 그칩니다. + +## 4. 보관·중복 방지 + +- 받은 메일과 보낸 메일은 **기한 없이** 보관합니다(0.8.0 에서 30일 제한과 `RETENTION_DAYS` 설정을 없앴습니다). + 메일은 휴지통에서 **영구 삭제**할 때만 원본·첨부와 함께 지워지고, Resend 목록에 남아 있어도 다시 가져오지 않습니다(tombstone). +- 저장 공간은 SQLite 파일(`/srv/arihakdang-mail/data/mail-gateway.sqlite`)이 계속 커지므로 디스크 사용량을 가끔 확인하세요. + 왼쪽 패널 아래에 메일함별 사용량이 보입니다. +- POP3 로 **RETR(내려받기)** 하거나 클라이언트가 **DELE** 한 메일은 `pop_deliveries` 에 기록되어 같은 계정에 다시 내려주지 않습니다. + 서버 저장본은 유지되어 웹에서 계속 볼 수 있고, 필요하면 메일 상세의 "POP 전달 기록 초기화"로 다시 내려받게 할 수 있습니다. +- 계정마다 따로 기록하므로 관리자가 두 명이면 각자 한 번씩 받습니다. + +## 4a. 웹 메일함 사용법 + +- **왼쪽 패널**: 메일 쓰기 버튼, 받은 메일함(읽지 않음 수)·중요 표시·보낸 메일함·임시 보관함·휴지통, 보기 필터(읽지 않음·첨부), 설정(관리자에게는 관리자 페이지 링크), 저장 사용량(적용 중인 메일함 최대 크기가 있으면 막대와 비율). + 0.9.0 부터 **누구나 자기 메일함만** 봅니다. 예전의 "관리자가 다른 계정의 메일함을 열어 보는" 기능(상단 메일함 선택)은 없앴고, 관리자 권한은 관리자 페이지의 계정·설정 관리에만 쓰입니다. +- **로그인 화면 → 회원 가입**(0.9.0~): 관리자가 켜 두었으면 접속 단추 아래에 "회원 가입" 이 보입니다. 로그인 ID(= 메일 주소의 앞부분)·이름·비밀번호(2회, 눈 단추)를 넣으면 계정이 생기며, + 관리자 설정에 따라 바로 쓰거나 "관리자 승인 후 사용"(승인 전 로그인하면 "가입 승인을 기다리는 계정입니다") 입니다. +- **목록**: 체크박스로 여러 통을 골라 삭제·복원·읽음/읽지 않음·이동을 한 번에 처리합니다. ☆ 를 누르면 중요 표시(IMAP `\Flagged` 와 공유)입니다. 상단 검색은 보낸 사람·제목·본문을 찾습니다. +- **읽기 창**: 답장·전체 답장·전달·삭제·이동·중요·읽지 않음, 원본(.eml) 내려받기, 상세 정보(헤더·POP 전달 기록), 외부 이미지 표시(기본 차단). +- **로그인 화면**: 오른쪽 위 (?) 를 누르면 접속 안내 팝업(이 도메인의 메일 서비스, 부여받은 계정 또는 도메인을 포함한 이메일 주소로 로그인, 웹 접속 계정과 같은 아이디·비밀번호)이 뜹니다. +- **설정 → 내 비밀번호 변경**(0.9.0~): 현재 비밀번호를 넣고 새 비밀번호를 두 번 같게 입력합니다(눈 단추로 글자 확인). 바꾸면 세션이 모두 끝나 새 비밀번호로 다시 로그인합니다. + www 관리자 계정은 www 사이트에서 관리하므로 이 카드가 안내문으로만 보입니다. +- **설정 → 메일함 사용량·제한**(0.9.0~): 사용량(MIME 기준)과 적용 중인 메일함 최대 크기, 첨부 한 개 최대 크기, 한 메일의 받는 사람 수를 보여 줍니다. +- **설정**: 화면 기본값(외부 이미지), **메일 프로그램 접속 허용(POP3 / IMAP)** 스위치, IMAP·SMTP·POP 설정 안내. 끈 서비스는 그 계정의 메일 프로그램 로그인을 거부합니다(웹 메일·SMTP 는 영향 없음). + 관리자가 서비스 자체를 껐으면 스위치가 비활성화되고 "관리자가 서비스를 껐습니다" 가 붙습니다. +- **설정 → 메일 전달**(0.8.16~): "받은 메일을 아래 주소로 전달" 을 체크하고 전달 주소(쉼표로 구분, 최대 5개)를 넣고 저장하면 이 메일함으로 받는 모든 메일을 그 주소로 보냅니다. + 체크를 풀고 저장하면 전달하지 않습니다. 전달 중이면 체크 상자 옆에 "현재 메일은 다음 주소로 전달되고 있습니다" 가 굵게 보입니다. + 원본은 받은 메일함에 그대로 남습니다. 전달 메일의 보내는 사람은 자기 메일함 주소(발송 서비스가 우리 도메인만 허용)이고 Reply-To 가 원래 발신자라 전달받은 쪽에서 답장하면 원래 보낸 사람에게 갑니다. + 첨부·본문 이미지도 함께 가며(첨부 합계 25MB 까지), 전달 메일에는 `X-Ari-Auto-Forwarded` 헤더가 붙어 두 메일함이 서로를 전달 대상으로 두어도 한 번만 전달됩니다. + 전달 주소에서 온 메일, 72시간이 지난 뒤 동기화가 뒤늦게 가져온 메일은 전달하지 않습니다. 전달 메일은 보낸 메일함에 쌓지 않고 메일 상세 정보의 "자동 처리" 줄에 기록만 남깁니다. +- **설정 → 자동 응답**(0.8.16~): 응답 제목(비우면 `Re: 원래 제목`)과 메시지를 쓰고, 기간을 **기한 제한 없음** 또는 **기간 지정**(시작·종료 날짜와 시간, 시작을 비우면 즉시)으로 정해 저장합니다. + 기간 선택과 시작·종료 상자는 항상 보이며(기한 제한 없음이면 시각 상자는 비활성), 켜고 끄는 것은 "자동 응답 사용" 체크 상자와 저장 버튼입니다. + 켜져 있으면 체크 상자 옆에 "메일이 수신되면 자동으로 아래 메일을 발신자에게 회신합니다." 가 굵게 보이고(지정 기간 밖이면 기간 안내가 덧붙음), 작동 중이면 왼쪽 패널 아래에도 "자동 응답 작동 중" 이 보입니다. + 같은 상대에게는 24시간에 한 번만 보내고, 자동 발송 메일(`Auto-Submitted`, `Precedence: bulk/list`, 메일링 리스트, no-reply·mailer-daemon 발신)과 72시간 지난 메일에는 응답하지 않습니다. + 자동 응답에는 `Auto-Submitted: auto-replied` 가 붙어 상대 서버의 자동 응답과 핑퐁이 생기지 않습니다. 보낸 자동 응답은 보낸 메일함에 남고 원래 메일에는 "답장함"(IMAP `\Answered`) 표시가 붙습니다. +- **메일 쓰기**: 참조 토글, 서식 도구(실행취소·글꼴·크기·굵게/기울임/밑줄/취소선·글자색·강조(기본 8색 팔레트 → 더 많은 색상은 브라우저 색상 선택창)·정렬·목록·들여쓰기·링크·인용·구분선·이미지·서식 지우기·HTML 편집), + 파일 첨부(파일당 상한은 관리자 설정, 합계 20MB), 본문 이미지 삽입·붙여넣기·끌어놓기(발송 시 cid 인라인 첨부로 변환), 4초 간격 자동 임시 저장. + 보내는 주소는 로그인 계정의 메일함 주소입니다. +- **참조 / 숨은 참조**(0.9.5): 받는 사람 줄 오른쪽의 "참조" 와 "숨은 참조" 를 각각 누르면 그 입력 줄이 열립니다. 숨은 참조도 초안에 저장되고 발송됩니다(받는 사람·참조에게는 보이지 않음). +- **대용량 첨부**(0.9.3~, 관리자가 켠 경우): "☁ 대용량 첨부" 로 올린 파일은 메일에 붙지 않고 서버에 보관되며 본문 끝에 **다운로드 링크** 블록이 붙어 나갑니다. + 올리는 동안 칩 자리에 진행 막대·퍼센트·보낸 양/전체가 보이고 ✕ 로 취소할 수 있습니다(0.9.5). nginx 가 요청을 막으면(413) 그 사실을 따로 알립니다. + 일반 첨부 상한을 넘는 파일을 첨부하거나 끌어 놓으면 자동으로 대용량으로 올라갑니다. 대용량 파일이 하나라도 있으면 **발송 조건(보관 일수·다운로드 횟수)** 줄이 보이며 둘 다 반드시 + 관리자 상한 이내로 정해야 합니다(제한 없는 발송 불가, 기본값은 상한). 받는 사람은 링크를 열면 랜딩 페이지(파일 이름·크기·남은 횟수·기한)를 보고 "다운로드" 단추를 눌러 받습니다 + (단추를 누를 때만 횟수가 줄어 메일 보안 장치가 링크를 미리 열어도 소진되지 않음). 기한이 지나거나 횟수를 다 쓰면 파일이 서버에서 지워지고 링크는 "만료" 페이지가 됩니다. + 보낸 메일함에서 메일을 열면 파일마다 남은 횟수·기한이 보이고 ✕ 로 링크를 끊을(파일 즉시 삭제) 수 있습니다. + +## 4b. 관리자 페이지 (`https://mail.arihakdang.com/admin`) + +관리자 페이지는 **`/admin` 주소로 고정**되어 있고(0.8.4~), 웹 메일과 **별도의 로그인**을 거칩니다. 로그인 정보는 같습니다 +(www.arihakdang.com 관리자 계정 = 별칭 admin, 또는 역할이 관리자인 게이트웨이 계정). 세션 쿠키가 다르므로(`mg_admin`) +웹 메일에 로그인돼 있어도 관리자 페이지는 다시 로그인해야 하고, 관리자 세션으로는 메일 API 를 쓸 수 없습니다. 웹 메일의 설정 메뉴에는 +관리자에게만 "관리자 페이지 ↗" 링크가 보입니다. 일반 계정으로 `/admin` 에 로그인하면 "관리자 역할 계정만" 안내와 함께 거부됩니다. + +**계정 관리** +- 계정 항목: 로그인 ID(영문 소문자·숫자·`.`·`_`·`-`, 2~64자), 이름, 비밀번호(아래 비밀번호 정책을 따름), 역할(관리자/사용자), 상태(사용/정지), 메일 프로그램 접속(POP3 / IMAP 허용). +- **비밀번호 재설정**(0.9.0): 다른 계정의 비밀번호는 계정을 열어 **한 번만 입력**하고 눈 단추로 확인한 뒤 저장합니다(0.8.25 의 두 번 입력·현재 비밀번호 확인은 설정 화면의 "내 비밀번호 변경" 으로 옮겼습니다). + **자기 계정**을 열면 비밀번호 칸 대신 "도구 → 설정에서 바꾸세요" 안내만 보이며, API 로 시도해도 `self_password_settings` 로 거부됩니다. +- **메일함 최대 크기**(0.9.0): 계정마다 MB 로 지정합니다(0 = 관리자 기본값·시스템 값). 시스템 제한(설치 시 `LIMIT_MAILBOX_BYTES`)이 있으면 그 이내만 저장됩니다. + 적용값은 "계정별 → 관리자 기본값 → 시스템" 중 가장 작은 0 아닌 값이며, 목록의 "저장 사용량" 열에 사용량 / 적용값이 보입니다. +- **승인 대기 계정**(0.9.0): 회원 가입으로 만들어진 계정은 상태가 "승인 대기" 로 보이고, 계정을 열면 **승인**(상태를 사용으로) / **거절**(계정 삭제) 단추가 있습니다. +- **비밀번호 정책**(0.8.24~): 계정 관리 화면 아래 "비밀번호 정책" 카드에서 최소 글자 수(6~64, 기본 6)와 영문 대·소문자 혼용 / 숫자 포함 / 특수문자 포함 요구를 각각 켜고 끕니다. + 예전에는 10자 이상이 코드에 고정돼 있었습니다. 정책은 새로 정하는 비밀번호(계정 추가·수정, 명령줄 도구)에만 적용되고 기존 비밀번호에는 소급하지 않습니다. + 규칙에 어긋나면 어느 항목이 어긋났는지("비밀번호가 정책에 맞지 않습니다: 8자 이상, 숫자 포함")를 알려 줍니다. +- **메일함 주소는 입력하지 않습니다.** 항상 `로그인 ID@arihakdang.com` 으로 자동 지정됩니다(예: `info` → `info@arihakdang.com`). 0.8.4 이전에 만든 계정은 등록된 주소를 유지합니다. +- 만든 계정은 웹 메일함·IMAP·POP·SMTP 에 **로그인 ID 또는 메일함 주소 + 비밀번호**로 접속하며 자기 메일함의 메일만 봅니다. +- 수신 메일은 받는 사람 주소가 등록된 메일함으로 들어옵니다(기본 메일함 admin@ + 활성 계정의 메일함). +- POP3 / IMAP 허용을 끄면 그 계정의 메일 프로그램 로그인을 거부합니다(`-ERR [SYS/PERM] POP access is disabled`, `NO [UNAVAILABLE] IMAP access is disabled`). 사용자도 웹 메일 설정에서 직접 켜고 끌 수 있습니다. +- 비밀번호를 바꾸거나 정지하면 해당 계정의 웹 세션이 즉시 종료됩니다. 계정을 삭제해도 메일함의 메일은 남습니다. +- www 관리자 계정(admin 행) 자체는 www 관리자 화면에서 관리하며 여기서는 POP/IMAP 허용만 바꿉니다. + +**회원 가입 / 메일 제한 / POP·IMAP 서비스**(0.9.0, 계정 관리 화면 아래 카드) +- 회원 가입: "로그인 화면에서 회원 가입 받기" 와 가입 계정의 사용 시점(즉시 사용 / 관리자 승인 후 사용). 끄면 관리자가 만든 계정만 메일을 쓸 수 있습니다(`POST /api/signup` 은 404). +- 메일 제한: 계정별 기본 메일함 크기, 첨부 한 개 최대 크기(MIME 기준), 한 메일의 받는 사람 수. 각 값은 시스템 제한 이내만 저장되며(`limit_exceeds_system`), 0 은 시스템 값을 따릅니다. + 그 아래 "시스템 설정" 에 설치 시 정한 `LIMIT_*` 값이 읽기 전용으로 보입니다(9절). +- POP3 / IMAP 서비스: 서비스 자체를 켜고 끕니다. 끄면 모든 계정의 POP(또는 IMAP) 로그인이 `-ERR [SYS/PERM] POP service is disabled by the administrator` / `NO [UNAVAILABLE] IMAP service is disabled by the administrator` 로 거부됩니다. +- 일반 접속 로그인 허용(0.9.7, 기본 켬): 110/143 포트에서 STARTTLS 없이 보낸 비밀번호로 로그인을 받을지. 끄면 CAPA 에서 `USER` 가 빠지고(IMAP 은 `LOGINDISABLED`) `-ERR [SYS/PERM] use STLS or port 995 before login` / `NO [PRIVACY] use STARTTLS or port 993 before login` 으로 거부합니다. 995/993·웹 메일·SMTP 에는 영향이 없고, 바꾼 값은 다음 접속부터 적용됩니다. + +**대용량 첨부파일**(0.9.3, 카드) +- "대용량 첨부파일 기능 사용" 스위치와 파일 한 개 최대 크기(MB)·보관 최대 기간(일)·다운로드 최대 횟수. 세 값은 **비울 수 없고(무제한 불가)** 시스템 상한(`LIMIT_LARGE_FILE_*`, 9절) 이내여야 하며 + 기본값은 시스템 상한의 50%(1GB/30일/20회 → 512MB/15일/10회)입니다. 기본은 꺼짐. 카드에 현재 서버에 보관 중인 파일 수·용량이 보입니다(서버 상태의 현황에도 표시). +- **nginx**(0.9.5): 업로드 경로 `/api/files` 는 `client_max_body_size 0` + `proxy_request_buffering off`, 다운로드 `/dl/` 는 버퍼링 없이 1시간 시간 제한으로 프록시합니다(템플릿 `deploy/nginx/*.conf.in`). + 이미 설치된 서버의 vhost 는 `sudo bash deploy/nginx-apply.sh /site.env`(site.env 가 없으면 `deploy/sites/<이름>.env`)로 다시 적용합니다 — 환경 파일·인증서는 건드리지 않고 vhost 파일만 바꿔 쓰고(이전 설정은 `.bak-<시각>`) `nginx -t` 뒤 reload 합니다. + 0.9.3 까지는 server 블록의 `client_max_body_size 32m` 때문에 32MB 가 넘는 대용량 파일이 nginx 에서 413 으로 끊겼습니다. +- 파일은 `/data/files/`(또는 `FILES_DIR`)에 있으며 만료·횟수 소진·링크 끊기·버려진 초안(하루 뒤)은 1시간마다 정리됩니다. 메일함 용량 제한(`LIMIT_MAILBOX_BYTES`)에는 들지 않습니다. + +**서버 상태**: **메일 현황**(0.9.0: 일일·주간·월간 발신/수신/수발신 통수 — Asia/Seoul 기준, 월간은 1일~말일; 메일 계정·총 메일함 사용량·이달 통수를 시스템 제한과 견준 "8개 사용 중 / 최대 10개 (80%)"; +사용자별 메일함 사용량과 적용 중인 최대 크기), 서비스 포트·TLS 인증서·POP/IMAP 스위치 상태, Resend 웹훅·동기화 기록·저장 통계·POP 전달 기록, "지금 동기화" 버튼. 사용량은 모두 MIME 기준(저장 원본 크기)입니다. + +## 5. IMAP 사용법 + +IMAP 은 서버의 메일함을 그대로 보여 주는 방식입니다. 읽음·삭제·이동이 웹 메일함, 다른 기기와 함께 공유되므로 +휴대폰과 PC 에서 같은 메일함을 쓰려면 POP 대신 IMAP 을 권장합니다. + +| 항목 | 값 | +|---|---| +| 받는 메일 서버 | `mail.arihakdang.com`, 포트 993 (SSL/TLS) 또는 143 (STARTTLS) | +| 사용자 이름 / 비밀번호 | `admin` 또는 `admin@arihakdang.com` (www 관리자 로그인 ID·이메일도 가능) / www 관리자 로그인 비밀번호 | +| 메일함 | `INBOX`(받은), `Sent`(보낸), `Drafts`(초안), `Trash`(휴지통). 새 폴더는 만들 수 없습니다. | +| 보내는 메일 서버 | `mail.arihakdang.com`, 포트 465 (SSL/TLS) 또는 587 (STARTTLS). 받는 서버와 같은 계정이므로 "받는 서버 설정 사용"을 켜도 됩니다. | + +설정 순서: 계정 추가 → 수동 설정(IMAP) → 받는 서버에 위 값 입력 → 보내는 서버에 같은 호스트·계정과 465/587 입력 → +보낸 메일 복사본은 `Sent`, 임시 저장은 `Drafts`, 삭제는 `Trash` 폴더를 쓰도록 지정(대부분 자동 인식). + +동작 규칙: +- 클라이언트에서 읽으면 웹 메일함도 읽음으로 바뀝니다(`\Seen` ↔ read_at). 깃발(`\Flagged`), 답장함(`\Answered`)은 서버에 저장됩니다. +- 클라이언트의 삭제는 `Trash` 로 이동입니다. `Trash` 에서 다시 삭제(EXPUNGE)하면 영구 삭제되며 같은 메일을 다시 가져오지 않습니다. +- 클라이언트가 `Sent`/`Drafts` 에 올린 메일(APPEND)은 원본 그대로 저장되어 웹 메일함에도 보입니다. +- IDLE 을 지원하므로 새 메일이 오면 10초 안에 클라이언트에 알립니다. +- 서버의 메일은 기한 없이 보관되므로 IMAP 에서도 계속 보입니다. 지우려면 클라이언트에서 삭제 후 `Trash` 를 비우세요. +- 지원 명령: IMAP4rev1 전체 기본 명령, LITERAL+, IDLE, UIDPLUS, MOVE, SPECIAL-USE, NAMESPACE, ID, UNSELECT, ENABLE. CREATE/RENAME/DELETE 는 거부합니다. + +확인: +```bash +openssl s_client -connect mail.arihakdang.com:993 -quiet # * OK [CAPABILITY ...] 가 보여야 함 +``` + +## 6. 메일 클라이언트 설정 + +| 항목 | 값 | +|---|---| +| 받는 메일(IMAP, 권장) | `mail.arihakdang.com`, 포트 993 (SSL/TLS) 또는 143 (STARTTLS, 또는 일반 접속*) | +| 받는 메일(POP3) | `mail.arihakdang.com`, 포트 995 (SSL/TLS) 또는 110 (STARTTLS, 또는 일반 접속*) | +| 보내는 메일(SMTP) | `mail.arihakdang.com`, 포트 465 (SSL/TLS) 또는 587 (STARTTLS), 인증: 일반 비밀번호 | +| 사용자 이름 | `admin` 또는 `admin@arihakdang.com` (www 관리자 로그인 ID·이메일도 가능). 게이트웨이 계정은 그 로그인 ID | +| 비밀번호 | 웹 메일 로그인 비밀번호 (받는 서버·보내는 서버 모두 같음) | +| 보내는 사람 | 계정의 메일함 주소(예: `admin@arihakdang.com`). 다른 주소는 서버가 거부합니다(관리자는 게이트웨이의 모든 메일함 주소 가능). | + +\* 일반 접속(암호화 없이 110/143 에 비밀번호를 보내는 방식)은 관리자 페이지 → 계정 관리 → "POP / IMAP 서비스" 카드의 **일반 접속 로그인 허용** 스위치(0.9.7, 기본 켬)를 따릅니다. +Gmail 의 "다른 계정에서 메일 확인(POP3)" 은 110 포트에서 STLS 를 쓰지 않고 바로 로그인하므로 이 스위치가 꺼져 있으면 Gmail 이 "사용자 이름과 비밀번호가 일치하지 않음" 으로 보여 줍니다. +가능하면 Gmail 에서 "보안 연결(SSL) 사용" 을 켜고 995 를 쓰세요. 환경 변수 `POP_ALLOW_PLAINTEXT=1` 은 관리자 설정과 무관하게 항상 허용(진단용)이고, 그 밖의 값은 관리자 설정을 따릅니다. + +POP3 로 받아도 서버 저장본은 지우지 않으며("서버에 메시지 복사본 남기기" 옵션과 무관) 웹에서 영구 삭제할 때까지 남습니다. +SMTP 로 보낸 메일은 곧바로 웹의 보낸 메일함에 저장됩니다. 메일 프로그램이 IMAP `Sent` 에 사본을 올려도 Message-ID 로 한 통으로 합칩니다. + +### SMTP 제출 서버 (0.8.1~) + +0.8.0 까지는 메일 프로그램이 Resend SMTP 에 직접 접속하며 **Resend API 키를 SMTP 비밀번호로** 썼습니다. +키가 사용자 PC 와 메일 프로그램 설정에 퍼지는 문제를 없애려고, 게이트웨이가 SMTP 제출 서버(`server/smtp.mjs`)를 직접 엽니다. + +- 포트 465(접속 즉시 TLS)·587(STARTTLS). 인증서는 POP·IMAP 과 같은 파일이고, 인증(AUTH PLAIN/LOGIN)은 TLS 위에서만 받습니다. +- 계정·비밀번호는 웹 메일·IMAP·POP 과 같습니다. 인증한 사용자는 자기 메일함 주소로만 보낼 수 있습니다(헤더 From 과 봉투 MAIL FROM 모두 검사). +- 받은 원본을 해석해 **서버의 `RESEND_API_KEY` 로 Resend API(`POST /emails`)** 를 호출합니다. 숨은 참조는 봉투(RCPT)에만 있는 주소로 판단합니다. + 같은 원본을 다시 보내면 멱등 키로 한 번만 발송합니다. +- 한도: 한 통 30MB(원본 기준), 받는 사람 50명. 인증 실패는 IP 당 15분 20회. +- 인증하지 않은 접속은 MAIL 단계에서 거부하므로 오픈 릴레이가 아닙니다. 외부 메일을 받는 MX 도 아닙니다(수신은 Resend). +- 환경 변수: `SMTP_PORT`(기본 587), `SMTPS_PORT`(기본 465), `SMTP_DISABLED=1`(끄기), `SMTP_ALLOW_PLAINTEXT=1`(진단용 평문 인증 허용). +- 기존에 메일 프로그램에 넣어 둔 Resend API 키는 더는 필요 없습니다. Resend 대시보드에서 그 키를 폐기하세요(서버 환경 파일의 키와 다른 키였다면). + +## 7. 문제 확인 + +```bash +journalctl -u arihakdang-mail -n 100 --no-pager # 동기화·웹훅·POP 로그 +curl -s http://127.0.0.1:8790/api/health +openssl s_client -connect mail.arihakdang.com:995 -quiet # +OK ... POP3 ready 가 보여야 함 +``` + +- 메일 화면 상단의 연결 상태가 `mail_auth_failed` 이면 `RESEND_API_KEY` 가 폐기·변경된 것입니다. +- 연결 상태가 `mail_domain_unverified`(1.0.11~)이면 Resend 가 발신 도메인을 검증하지 못한 것입니다. 관리자 › 서버 상태(또는 전체 설정 화면 › 자동 설정)에 **어느 DNS 레코드가 없거나 값이 다른지** + 문장으로 보입니다(예: `검증 실패 — 값이 다른 레코드: TXT resend._domainkey.<도메인> (지금 p=… / 필요 p=…)`). 값이 다르면 다른 Resend 계정·예전 등록의 값이니 표의 값으로 바꾸세요. + 바꾸면 1시간마다 도는 자동 재검증이 Resend 에 검증을 다시 요청합니다(`RESEND_AUTO_VERIFY=0` 으로 끔). `mail_domain_missing` 은 Resend 계정에 도메인이 없는 것 — 자동 설정 실행. + `mail_forbidden` 은 그 밖의 403(키 권한 등)에만 씁니다. +- 관리자 페이지 → 서버 상태의 웹훅 처리 내역이 비어 있으면 Resend 엔드포인트 URL·이벤트·Signing Secret 을 확인합니다. +- 995/993 이 열리지 않으면 인증서 경로(`POP_TLS_CERT/KEY`)와 `arihakdang` 계정의 읽기 권한을 확인합니다. +- POP 이 "되다말다" 하면 `journalctl` 의 `POP3` 줄을 봅니다(0.9.7~): `POP3 평문 로그인 거부`(110 에서 STLS 없이 로그인 → 일반 접속 로그인 허용 스위치 확인), + `POP3 접속 거부 … 다른 세션 … 사용 중`(같은 계정의 다른 메일 프로그램이 아직 연결돼 있음 → `-ERR [IN-USE]`, 그 세션이 끝나면 바로 됨), + `POP3 종료 … RETR n·DELE m … (quit|timeout|error:…)`(세션 요약 — `timeout` 은 10분 동안 명령이 없어 끊은 것, `error:` 는 상대가 연결을 끊은 것). +- 화면에 "처리하지 못했습니다 (코드)" 가 보이면 괄호 안 코드가 서버 오류 코드입니다(0.8.25~). 배포 직후라면 화면이 서버 버전(`X-App-Version`)을 보고 스스로 새로 고치므로 보통 한 번만 보입니다. +- 메일 전달·자동 응답이 나가지 않으면 메일 상세 정보의 "자동 처리" 줄을 봅니다. `전달하지 않음 — …` 은 규칙상 건너뛴 것(되풀이 방지·72시간 지난 메일·같은 상대 24시간 안 응답 등)이고, + `전달 실패 / 자동 응답 실패` 는 발송 서비스 오류입니다(`journalctl` 에 `메일 전달 실패` / `자동 응답 실패` 로 남음). 기록은 DB 의 `auto_actions` 표에 있습니다. + +## 8. 다른 도메인에 독립 설치 (예: mail.heart-m.com) + +0.8.18 부터 www 사이트 없이 **독립 실행(standalone)** 으로 다른 서버·도메인에 설치할 수 있습니다. 설치는 mail.heart-m.com(115.68.193.164, 실행 계정 `webmaster`)을 기준으로 적습니다. + +1. **사이트 설정 파일** `deploy/sites/heart-m.env` — 서비스 이름(`heart-mail`), 실행 계정, 경로(`/srv/heart-mail`, `/etc/heart-mail`), Node 경로, 포트, 호스트, 기본 메일함(`admin@heart-m.com`), + 탭 제목(`SITE_TITLE`, 비우면 서비스 이름과 같음), `LOGIN_MODE=standalone`(0.9.0 설치 옵션, 9절). 새 사이트 파일은 0.9.0 부터 `deploy/new-site.sh` 로 만듭니다: + ```bash + bash deploy/new-site.sh cafe --host mail.cafe.heart-m.com --mailbox admin@cafe.heart-m.com --user webmaster \ + --limit-accounts 20 --limit-mailbox 500M --limit-attachment 20M # 기본은 --login standalone, 제한 0 = 없음 + bash deploy/new-site.sh shop --host mail.shop.test --mailbox admin@shop.test --login www --www-db /srv/shop/data/shop.sqlite # www 연동 + ``` + 왼쪽 위 서비스 이름은 0.8.25 부터 메일 도메인에서 자동으로 정합니다(heart-m.com → "HEART-M 메일", cafe.heart-m.com → "CAFE 메일"). `SITE_BRAND` 는 더 쓰지 않습니다(남아 있어도 무시). +2. **서버 준비(root)**: 실행 계정이 있어야 하고(`id webmaster`), Node 22.13+ 를 `NODE_BIN` 경로에 둡니다(패키지 저장소 없이 nodejs.org 바이너리를 푸는 방식). + ```bash + curl -fsSLO https://nodejs.org/dist/v22.23.3/node-v22.23.3-linux-x64.tar.xz && sudo mkdir -p /opt/heart-mail-node \ + && sudo tar -xJf node-v22.23.3-linux-x64.tar.xz -C /opt/heart-mail-node --strip-components=1 + git bundle create /tmp/mail-gateway.bundle main && scp /tmp/mail-gateway.bundle heart:/tmp/ # 작업 PC 에서 + sudo install -o webmaster -g webmaster -m 644 /tmp/mail-gateway.bundle /srv/heart-mail/source.bundle # 서버에서(디렉터리는 setup.sh 가 만들지만 번들은 먼저 둬도 됨) + sudo env SITE=heart-m SOURCE_BUNDLE=/srv/heart-mail/source.bundle bash /path/to/repo/deploy/setup.sh + ``` + `setup.sh` 는 `/srv/heart-mail/{repo,releases,data}`, `/srv/heart-mail/site.env`, `/etc/heart-mail/gateway.env`, `heart-mail.service`, sudoers, nginx vhost(`mail.heart-m.com.conf`), certbot 훅을 만듭니다. +3. **환경 파일** `/etc/heart-mail/gateway.env` 에 `RESEND_API_KEY` 와 `RESEND_WEBHOOK_SECRET` 를 채웁니다(heart-m.com 이 Resend 에 수신·발신 도메인으로 등록되어 있어야 함). + Resend 웹훅 엔드포인트는 `https://mail.heart-m.com/api/webhooks/resend`(3절과 같음). +4. **배포**: `sudo -u webmaster env SOURCE_BUNDLE=/srv/heart-mail/source.bundle /srv/heart-mail/repo/deploy/deploy.sh` — 테스트·릴리스 복사·마이그레이션·재시작·health. + 이후 갱신도 같은 명령입니다(번들만 새로 올림). +5. **첫 관리자 계정** (0.8.24~): 독립 실행이고 계정이 하나도 없으면 서버가 시작할 때 **`admin` / `adminpasswd`** 관리자 계정(기본 메일함 admin@heart-m.com 의 주인)을 자동으로 만들고 + journal 에 안내를 남깁니다. `https://mail.heart-m.com/admin` 에 그 비밀번호로 로그인하면 "초기 비밀번호 사용 중" 경고가 보이며, 계정 관리에서 admin 계정을 열어 비밀번호를 바꾸면 사라집니다. + 초기 비밀번호는 비밀번호 정책과 무관하게 글자 수 하한(6)만 적용됩니다(예전에는 10자 규칙 때문에 짧은 초기 비밀번호를 만들 수 없었음). + 셸에서 비밀번호를 재설정하거나 계정을 추가하려면: + ```bash + sudo -u webmaster env $(sudo grep -E '^(DB_FILE|MAILBOX)=' /etc/heart-mail/gateway.env | xargs) \ + /opt/heart-mail-node/bin/node /srv/heart-mail/current/server/user-cli.mjs passwd admin # 또는 add [--admin] / list + # 비밀번호는 단말에서 숨겨 입력(또는 PASSWORD=... 환경 변수 / --password-stdin). 규칙은 관리자 페이지의 비밀번호 정책을 따릅니다. + ``` + 나머지 계정은 `https://mail.heart-m.com/admin` 에서 만듭니다. +6. **DNS 와 인증서**: `mail.heart-m.com` A 레코드를 115.68.193.164 로 바꿉니다(프록시 없이). DNS 가 바뀌기 전에는 nginx 가 80 포트(ACME 챌린지)만 열고 나머지는 503 이며, + `heart-mail-tls.timer` 가 15분마다 A 레코드를 확인해 이 서버를 가리키면 certbot(webroot)으로 발급 → `setup.sh` 재실행 → 443·995/993/465 TLS 를 켜고 타이머를 끕니다. + 바로 하려면 `sudo bash /srv/heart-mail/repo/deploy/tls-when-ready.sh /srv/heart-mail/site.env`. 갱신은 certbot 타이머와 훅이 맡습니다. +7. **확인**: `curl -s http://127.0.0.1:8790/api/health`, `journalctl -u heart-mail -n 50 --no-pager`, `systemctl list-timers heart-mail-tls.timer`. + 메일 프로그램 설정값은 6절과 같되 호스트가 `mail.heart-m.com`, 사용자 이름이 게이트웨이 계정의 로그인 ID 또는 `로그인ID@heart-m.com` 입니다. + +## 9. 설치 옵션: 로그인 방식과 시스템 제한 (0.9.0~) + +설치·배포 때 사이트 설정 파일(`deploy/sites/<이름>.env`, `new-site.sh` 가 만듦)에서 정합니다. `setup.sh` 가 **처음 한 번** `gateway.env` 를 만들 때 이 값을 넣으므로, +이미 설치된 서버에서 바꾸려면 `/gateway.env` 를 직접 고치고 `systemctl restart <서비스>` 합니다(mail.heart-m.com 의 환경 파일은 0.9.0 배포에서 건드리지 않았으며, +값이 없으면 모두 "제한 없음" 과 `standalone` 으로 동작합니다). + +**로그인 방식 `LOGIN_MODE`** — 독립 실행이 기본 설치 형태입니다. +- `standalone`(기본): www 사이트와 연동하지 않습니다. 첫 관리자 `admin / adminpasswd` 가 자동으로 생기고, 계정은 관리자 페이지(또는 회원 가입)로 만듭니다. `WWW_DB_FILE` 은 무시됩니다. +- `www`: www 사이트 DB(`WWW_DB_FILE`, 필수)로 관리자를 인증합니다(mail.arihakdang.com). 경로가 없으면 서비스가 시작하지 않습니다. +- 값이 없으면 0.8.x 처럼 `WWW_DB_FILE` 유무로 정합니다(기존 서버 호환). + +**시스템 제한 `LIMIT_*`** — 메일 시스템 전체의 상위 설정입니다. 관리자 페이지에서는 볼 수만 있고, 관리자가 정하는 값(계정별 메일함 크기, 첨부 크기, 받는 사람 수)은 이 값 이내로만 저장됩니다. **모두 0 이면 제한 없음.** + +| 변수 | 뜻 | 어긋나면 | +|---|---|---| +| `LIMIT_ACCOUNTS` | 허용 가능한 메일 계정 수(정지·승인 대기 포함) | 계정 추가·회원 가입이 `account_limit_reached` 로 거부 | +| `LIMIT_TOTAL_BYTES` | 모든 메일함을 합친 최대 용량 | 수신은 `storage_full` 로 건너뜀(공간이 생기면 다음 동기화가 가져옴), 발송은 거부 | +| `LIMIT_MAILBOX_BYTES` | 개별 메일함 최대 용량(관리자·계정별 값의 상한) | 그 메일함 수신 건너뜀(`quota_exceeded`), 웹·SMTP 발송 거부(`452 4.2.2`), IMAP APPEND `NO [OVERQUOTA]` | +| `LIMIT_ATTACHMENT_BYTES` | 첨부 파일 한 개 최대 크기(관리자 값의 상한, 내장 상한 25MB) | 웹 첨부·전달 복사 `attachment_too_large`, SMTP `552 5.3.4` | +| `LIMIT_RECIPIENTS` | 한 메일의 받는 사람 수(참조·숨은 참조 포함, 내장 상한 50) | 웹 발송 `too_many_recipients`, SMTP `452 4.5.3` | +| `LIMIT_MONTHLY_MESSAGES` | 월간(1일~말일, Asia/Seoul) 최대 수발신 통수(보낸 + 받은) | 발송 거부(`monthly_limit_exceeded`, SMTP `550 5.7.1`), 자동 전달·자동 응답 건너뜀(limit) | + +**대용량 첨부파일 상한(0.9.3)** — 다른 `LIMIT_*` 와 달리 **무제한이 없습니다**. 비우거나 0 이면 기본값이 적용됩니다. + +| 변수 | 뜻 | 기본값 | +|---|---|---| +| `LIMIT_LARGE_FILE_BYTES` | 대용량 파일 한 개 최대 크기(관리자 설정의 상한) | `1G` | +| `LIMIT_LARGE_FILE_DAYS` | 보관 최대 기간(일) | `30` | +| `LIMIT_LARGE_FILE_DOWNLOADS` | 다운로드 최대 횟수 | `20` | +| `FILES_DIR` | 파일 저장 디렉터리(비우면 DB 옆 `files/`) | | + +크기 값은 바이트 또는 `K`/`M`/`G` 접미사(`500M`, `2G`)이며 **MIME 변환 뒤의 크기** 기준입니다. 메일은 저장·전송될 때 첨부가 base64 로 바뀌어 약 1.37배가 되므로 +사용량(저장된 원본 `.eml` 크기)과 모든 제한을 그 크기로 셉니다. 첨부 파일을 올릴 때는 파일 크기 × 4/3 을 제한과 비교합니다. +메일 현황의 시간대는 `STATS_TIMEZONE`(기본 `Asia/Seoul`)으로 바꿀 수 있습니다. + +## 10. 설정 파일과 초기 설정 페이지 (1.0.0) + +설정은 두 층입니다. + +| 층 | 어디에 | 무엇 | 바꾸는 방법 | +|---|---|---|---| +| 배포 설정 | `/etc/<서비스>/gateway.env` (systemd EnvironmentFile) | DB 경로, 포트, 기본 메일함·공개 호스트, 로그인 방식, 시스템 제한 `LIMIT_*`, TLS 사본 경로, `SETUP_PORT`, `HARAKA_DIR` | root 가 파일을 고치고 서비스 재시작 | +| 운영 설정 | `/data/config.json` (실행 계정만 읽음) | 수·발신 방식, Resend API 키·웹훅 비밀키, SMTP 중계 서버, 내장 Haraka(포트·DKIM), 발신 표시 이름, 탭 제목, 통계 시간대, 동기화 주기 | **초기 설정 페이지** 또는 `/admin` → **메일 서비스 설정**. 저장 즉시 적용(재시작 없음) | + +- **왜 나눴나**: 운영 설정은 처음 배포할 때 알 수 없거나(키를 아직 만들지 않음) 배포 자체에는 필요하지 않습니다. 비어 있어도 배포는 성공해야 하고, 채울 때 root 작업과 재시작이 없어야 합니다. +- **기존 서버**: 설정 파일이 없으면 첫 시작 때 환경 파일에 있던 값(`RESEND_API_KEY` 등)을 그대로 읽어 설정 파일을 만듭니다. 환경 파일은 그대로 두어도 됩니다. + 그 뒤로는 설정 파일 값이 우선합니다(설정 파일에서 지운 값은 환경 파일 값으로 되살아나지 않음). 설정 화면의 각 항목 아래에 출처(설정 파일 / 환경 파일 값 / 기본값)가 보입니다. +- **초기 설정 페이지**: 게이트웨이가 전용 포트(`SETUP_PORT`, 기본 **8791**, 모든 주소)에서 엽니다. 접속에는 데이터 디렉터리의 `setup-token`(배포 계정만 읽음)이 필요하며, + `deploy.sh` 가 끝에 전체 주소(`…:8791/?token=…`)를 출력하고 `data/setup-url.txt` 에도 남깁니다. 틀린 토큰은 IP 당 15분 10회만 받습니다. + 메일 포트 인증서(`POP_TLS_CERT`)를 읽을 수 있으면 HTTPS 로, 없으면 HTTP 로 엽니다(새 서버는 인증서 전이라 HTTP — 토큰이 노출되므로 설정이 끝나면 토큰 파일을 바꾸거나 `SETUP_PORT=0` 으로 끌 수 있음). + 방화벽을 쓰면 8791 을 열어야 합니다(`sudo ufw allow 8791/tcp`). 운영 중에는 `https://<호스트>/setup`(관리자 세션)으로 같은 화면을 엽니다. +- **화면 구성**: 현재 상태(수·발신 방식, 발신 서비스 연결, Haraka, 설정 파일) → ① 수·발신 방식 → ② Resend → ③ SMTP 중계 서버 → ④ 내장 Haraka(DKIM 키 생성, DNS 레코드, 서비스 상태·로그) → ⑤ 사이트 정보 → ⑥ 마무리(점검표, 완료 표시, systemd 아래면 재시작). + 고른 방식에 필요한 카드만 보입니다. 비밀값은 가려서 보이며(`re_••••abcd`) 입력 칸을 비워 두면 그대로, "비우기" 를 누르면 지워집니다. +- **API**: `GET/PUT /api/setup/config`, `POST /api/setup/check {what:'outbound'|'haraka'}`, `POST /api/setup/dkim`, `POST /api/setup/restart`. 전용 포트는 `X-Setup-Token`(또는 `?token=`), 웹 서버는 관리자 세션 + `X-Mail-Scope: admin`. + +## 11. 발송 모듈 (1.0.0) + +발송은 `server/providers/index.mjs` 의 `sendMail(env, payload, key)` 한 곳을 거치며, 설정의 **발신 방식(outbound)** 이 모듈을 고릅니다. + +| 발신 방식 | 모듈 | 설명 | +|---|---|---| +| `resend` | `providers/resend.mjs` | Resend REST API(예전과 같음). 도메인이 Resend 에 검증되어 있어야 함 | +| `haraka` | `providers/smtp.mjs` → 127.0.0.1:`HARAKA_RELAY_PORT`(2525) | 내장 Haraka 의 로컬 제출 포트로 넘기면 Haraka 가 받는 쪽 MX 에 직접 배달(12절) | +| `smtp` | `providers/smtp.mjs` → `SMTP_RELAY_HOST:PORT` | 외부 SMTP 중계 서버(587 STARTTLS / 465 TLS / 인증 선택). 보낸 사람 도메인을 그 서버가 허용해야 함 | + +메시지 객체는 Resend 요청과 같은 모양(from, to, cc, bcc, reply_to, subject, text, html, headers, attachments[{filename, content(base64), content_type, content_id}])이라 +웹 발송(store.mjs)·SMTP 제출 서버(smtp.mjs)·자동 전달/응답(automation.mjs)은 어느 모듈을 쓰는지 모릅니다. SMTP 쪽은 `mime.mjs` 의 `buildEml` 로 원본을 만들고(Bcc 는 봉투에만), +의존성 없는 SMTP 클라이언트(`providers/smtp-client.mjs`)로 보냅니다. 오류 코드는 공통(`mail_not_configured` / `mail_auth_failed` / `mail_rejected` / `mail_rate_limited` / `mail_provider_unavailable`)이라 +화면 문구와 SMTP 제출 서버의 응답 변환이 그대로 동작합니다. 새 발송 서비스를 더하려면 `providers/<이름>.mjs` 에 같은 모양의 send 를 만들고 `index.mjs` 에 한 줄씩 더합니다. + +수신 방식(inbound)은 `resend`(웹훅 + 동기화) 또는 `haraka`(12절)입니다. Resend 동기화는 Resend 를 쓰는 방향만 훑습니다(수신이 Haraka 면 받은 메일 목록은 보지 않음). + +## 12. 내장 Haraka (1.0.0) + +[Haraka](https://haraka.github.io/)(Node.js 메일 서버)를 함께 띄워 **Resend 없이** 메일을 받거나(MX) 보낼 수 있습니다. 조합은 자유입니다(예: 수신 Haraka + 발신 Resend). + +- **설치**: 저장소의 `haraka/package.json`(Haraka 3.3.4 + `haraka-plugin-dkim`)을 `deploy/haraka-apply.sh` 가 `/haraka` 에 npm 으로 설치하고 systemd 서비스 `-haraka` 를 등록합니다. + 새 사이트는 `setup.sh` 가 자동으로 부르고, 기존 사이트는 한 번 실행합니다: `sudo bash /repo/deploy/haraka-apply.sh`(인터넷 필요, 환경 파일·데이터는 건드리지 않음). +- **동작**: 관리 프로세스 `haraka/run.mjs` 가 항상 떠 있으면서 설정 파일을 5초마다 보고, 수신 또는 발신 방식이 `haraka` 이면 Haraka 설정(`/haraka/config/*`, 플러그인 `plugins/arimail.js`)을 만들어 + Haraka 를 자식 프로세스로 띄우고, 아니면 끕니다. 상태는 `data/haraka-status.json` 에 써서 설정 화면·서버 상태에 보입니다. 로그: `journalctl -u -haraka -f`. +- **수신**: Haraka 가 `HARAKA_PORT`(25)로 받아 RCPT 단계에서 게이트웨이에 메일함이 있는지 확인하고(없는 사용자는 550), DATA 가 끝나면 원본을 `POST /api/inbound/haraka`(로컬, 토큰 인증)로 넘깁니다. + 게이트웨이는 Resend 경로와 같은 규칙으로 저장합니다(중복·영구 삭제·용량·사본·자동 전달/응답). 메일함이 가득 차면 452 로 보낸 쪽이 나중에 다시 보냅니다. + DNS 의 **MX** 를 이 서버(`HARAKA_HOSTNAME`, 기본 공개 호스트 이름)로 바꾸고 방화벽에서 25 를 열어야 합니다. +- **발신**: 게이트웨이가 127.0.0.1:`HARAKA_RELAY_PORT`(2525, 로컬 전용)로 넘기면 Haraka 가 받는 쪽 MX 에 직접 배달하고 실패는 재시도·반송합니다. + 설정 화면에서 **DKIM 키 만들기** 를 누르면 RSA 2048 키가 설정 파일에 저장되고 Haraka 가 서명합니다. 화면의 **SPF·DKIM(·DMARC) TXT 레코드**를 DNS 에 올리고, 서버 IP 의 역방향 DNS(PTR)가 + 메일 서버 이름을 가리키게 호스팅 업체에 요청하세요(없으면 스팸 분류가 잦습니다). STARTTLS 는 메일 포트 인증서(`POP_TLS_CERT`)를 그대로 씁니다. +- **환경 변수**: `HARAKA_DIR`(설치 위치), `HARAKA_LOGLEVEL`(기본 info). 그 밖의 값(포트·호스트 이름·DKIM)은 설정 파일에 있습니다. + +## 13. 자동 설정과 DNS 모듈 (1.0.5) + +설정 화면의 **자동 설정** 카드가 수·발신 방식과 도메인에 맞는 준비 상태를 진단하고(`GET /api/setup/plan`), 자동으로 할 수 있는 일은 **자동 설정 실행**(`POST /api/setup/plan/run`) 한 번으로 처리합니다(`server/provision.mjs`). +단계마다 상태(완료 / 자동 실행 가능 / 반영 대기 / 사용자 작업 / 오류 / 참고)와 안내 문구가 붙고, 사람이 해야 하는 단계는 입력 칸이나 단추를 함께 보여 줍니다. + +| 구성 | 자동으로 하는 일 | 사용자가 하는 일(안내) | +|---|---|---| +| Resend 수·발신 | 도메인 등록(지역 `RESEND_REGION`)과 발송/수신 기능 맞춤, 요구 DNS 레코드(DKIM TXT·`send` CNAME·수신 MX) 적용(DNS 모듈이 있을 때), 검증 요청과 상태 추적(`pending`/`verified`/`failed`/`temporary_failure`), 수신 웹훅 생성과 **서명 비밀키 자동 저장** | API 키 입력, DNS 모듈이 없으면 표의 레코드를 직접 넣고 "반영 확인", 웹훅 비밀키를 잃었으면 입력 또는 "웹훅 새로 만들기" | +| 내장 Haraka | DKIM 키 생성, MX/SPF/DKIM 레코드 적용(기존 SPF 가 있으면 우리 메커니즘만 끼워 넣음), 실행 상태·PTR 확인 | `deploy/haraka-apply.sh` 실행(설치), 방화벽 25 개방, 호스팅 업체에 PTR 요청 | +| 외부 SMTP 중계 | 접속·인증 확인 | 접속 정보 입력 | + +- **DNS 모듈**(`server/dns/`, 요구사항 1): `DNS_PROVIDER` 가 `google`(Google Cloud DNS, 서비스 계정 JSON) 또는 `route53`(AWS Route 53, 액세스 키)이면 존을 자동으로 찾아 레코드를 UPSERT 합니다(외부 의존성 없이 OAuth2 JWT / SigV4 서명 구현). + `none` 이면 자동 적용 없이 표의 레코드를 보여 주고 공개 리졸버(8.8.8.8 / 1.1.1.1)로 반영 여부만 확인합니다. DMARC 는 권장 레코드로 보여 주되 자동으로 넣지 않습니다(기존 정책을 덮어쓰지 않기 위해). + 권한: Google 은 프로젝트의 "DNS 관리자", AWS 는 `route53:ListHostedZonesByName/GetHostedZone/ListResourceRecordSets/ChangeResourceRecordSets`. +- **Resend 모듈**(`server/providers/resend.mjs`, 요구사항 3): 도메인 목록·등록·기능 변경·검증, 웹훅 목록·생성·삭제를 담당합니다. 자동 설정은 이 모듈만 통해 Resend 를 바꿉니다. +- **검증 실패 진단과 자동 재검증**(1.0.11): Resend 도메인이 `failed` 면 레코드마다 공개 DNS 조회로 "없음(전파 대기)" 과 "값이 다름(바꿔야 함)" 을 구분해 단계 문구와 표의 "지금 값" 으로 보여 줍니다. + Resend 자신이 verified 라고 하는 레코드는 우리 조회 값이 달라도 정상으로 봅니다(예: 예전 수신 MX 값). 레코드가 모두 보이면 정기 동기화 주기(기본 1시간)마다 검증을 자동으로 다시 요청하므로 + "지금 검증 요청" 을 누르지 않아도 됩니다. 발신 상태(`/api/mail/status`, 서버 상태 화면)도 `mail_domain_unverified`(원인 문장 `detail`, 레코드별 `records`) / `mail_domain_missing` 으로 구분합니다. +- **상황별 안내**: 인증 정보 없음·형식 오류·권한 없음·존 없음·연결 실패(DNS), 키 거부·권한·요청 제한(Resend), 미설치·서비스 꺼짐·실행 대기·PTR 불일치(Haraka) 등 각 단계의 `detail` 에 원인과 다음 할 일이 적힙니다. 모든 단계가 끝나면 "메일 사용 준비가 끝났습니다" 를 알립니다. + +## 14. 다중 도메인과 전체 설정 화면 (1.0.7) + +한 서버(한 프로세스)가 여러 도메인의 메일 서비스를 섬깁니다(`server/domains.mjs`). 도메인마다 계정·세션·메일·설정 파일·파일 저장소가 따로이고, 로그인과 계정 관리는 도메인 단위입니다. + +| | 기본 도메인 | 추가 도메인 | +|---|---|---| +| 정해지는 곳 | 환경 파일(`MAILBOX`·`PUBLIC_HOST`·`LOGIN_MODE`·`WWW_DB_FILE`·`DB_FILE`) | 전체 설정 화면의 **도메인** 카드(재시작 없음) | +| 데이터 | `data/mail-gateway.sqlite`, `data/config.json`, `data/files/` (예전 그대로) | `data/domains/<도메인>/{mail.sqlite, config.json, files/}` | +| 웹 메일 주소 | `PUBLIC_HOST` (+ 등록부의 추가 호스트) | 등록한 호스트 이름(기본 `mail.<도메인>`) — nginx vhost 와 인증서는 `sudo bash deploy/domain-nginx.sh <호스트>` | +| 로그인 방식 | 환경 파일 | `local`(자체 계정, 첫 관리자 `admin` 자동 생성) 또는 `www`(연결된 웹 서비스의 SQLite `users` 표로 관리자 인증) | +| 메일 프로그램 | `PUBLIC_HOST`:993/995/465/587 | 같은 서버 이름(기본 도메인 호스트)에 `아이디@도메인` 으로 로그인 — 인증서가 하나이므로 | + +- **등록부** `data/domains.json`: 1.0.7 로 올린 서버는 첫 시작에 기본 도메인만 담아 만들고(데이터 이동 없음) 예전과 똑같이 동작합니다. 추가 도메인의 설정 파일은 기본 도메인의 설정에서 API 키·DNS 인증 정보·SMTP 중계·Haraka 포트·시간대를 복사해 시작할 수 있습니다. +- **요청 → 도메인**: HTTP 는 Host(X-Forwarded-Host). 등록된 호스트 이름과 같거나 도메인의 하위 이름이면 그 도메인, IP·localhost 는 기본 도메인. 모르는 호스트에는 "메일 서비스가 존재하지 않습니다", 중지한 도메인에는 "메일 서비스가 활성화되지 않았습니다"(관리자에게 요청) 화면을 줍니다. 메일 프로그램은 로그인 ID 의 `@도메인`(없으면 TLS SNI 호스트, 그래도 없으면 기본 도메인). +- **전체 설정 화면**: 기본 도메인의 전용 포트(`https://:8791/?token=…`, 배포 출력에 주소). 맨 위 도메인 카드에서 추가·수정·중지/사용·삭제(등록 해제만, 디렉터리는 남김)하고, 행의 "선택" 으로 고른 도메인의 설정을 아래 카드에서 바꿉니다. 추가 도메인의 호스트 이름으로 이 포트에 오면 열리지 않습니다(기본 도메인 주소 안내). 관리자 페이지의 `/setup` 은 자기 도메인만. +- **서버에 하나뿐인 것**(기본 도메인 설정을 따름): 메일 포트·인증서, 내장 Haraka 의 수신/제출 포트·메일 서버 이름·게이트웨이 토큰. Haraka 는 수신 방식이 haraka 인 모든 도메인을 받고(받는 사람 주소로 나눔), 발신 haraka + DKIM 키가 있는 도메인마다 `config/dkim/<도메인>/` 키로 서명합니다. +- **www 연동 도메인**: 그 SQLite 파일을 서비스 계정이 읽을 수 있어야 하며 systemd 의 `ReadWritePaths`(사이트 설정 `EXTRA_RW`)에 그 디렉터리(WAL 의 -shm 파일 때문)가 있어야 합니다. +- 추가 도메인의 명령줄 계정 도구: `DB_FILE=/data/domains/<도메인>/mail.sqlite MAILBOX=admin@<도메인> node server/user-cli.mjs …` +- API: `GET/POST /api/setup/domains`, `PATCH/DELETE /api/setup/domains/<도메인>`(전용 포트 전용), 그 밖의 `/api/setup/*` 에 `?domain=<도메인>`. 오류: `domain_exists`, `host_taken`, `www_db_unreadable`, `primary_domain`, `domain_not_found`, `validation`(field). + +## 15. webmaker 인증 모드와 사이트의 /mail 경로 (1.0.8) + +웹메이커(WebHosting_AI_agent, `https://source.ibit.kr/OZ-Group/WebHosting_AI_agent`)의 사용자 계정으로 AriMail 에 로그인하게 하는 로그인 방식입니다(`server/webmaker.mjs`). 웹메이커의 **사용자 파일을 읽기 전용으로 참조**해 같은 방식(scrypt)으로 비밀번호를 검사하므로 계정을 따로 만들거나 메일 비밀번호를 따로 정하지 않습니다. 웹 메일·IMAP·POP·SMTP 모두 같은 비밀번호이며, 도메인마다 따로 켭니다. + +| 웹메이커 쪽 | AriMail 계정 (도메인 D) | 역할 | +|---|---|---| +| 사이트 회원 중 로그인 ID 가 있는 계정(사이트 관리자 페이지 › 계정 추가, `modules/<사이트>.json` members.loginId) | `loginId@D` | 일반 | +| 사이트 소유자(`sites/<사이트>.json` ownerId → `users.json`) | `소유자ID@D` | 일반(웹메이커 관리자이기도 하면 관리자) | +| 웹메이커 관리자(`users.json` role=admin, 보통 `admin`) | `admin@D` | 관리자 | +| (도메인에 대응하는 사이트가 없을 때) 웹메이커의 모든 회원 | `아이디@D` | 관리자는 관리자, 나머지 일반 | + +- 도메인 D 에 대응하는 사이트: 등록부의 `siteId` → 웹메이커 `domains.json` 의 서브 도메인 등록(name=D, siteId) → 사이트의 `domains` 목록 → 사이트의 메일 도메인(`webmail.domain`) 순으로 찾습니다. 같은 로그인 ID 가 겹치면 사이트 회원 → 소유자 → 플랫폼 관리자 순으로 먼저 찾은 쪽이 그 주소를 씁니다. 승인이 취소된 회원(approved=false)은 로그인할 수 없습니다. +- 예: 웹메이커에서 `site.ariweb.net` 사이트를 만들고 AriMail 에 도메인 `site.ariweb.net`(로그인 방식 webmaker)을 추가하면 `admin@site.ariweb.net`(웹메이커 admin 비밀번호)으로 바로 수·발신하고, 사이트 관리자 페이지에서 사용자 `user` 를 추가하면 `user@site.ariweb.net` 도 같은 비밀번호로 씁니다. +- 웹메이커에서 비밀번호를 바꾸거나 계정을 지우면 바로 반영됩니다(스냅샷 3초 캐시, 로그인 실패 시 한 번 다시 읽음). 웹메이커 계정의 이름·비밀번호·역할은 AriMail 에서 바꿀 수 없고(관리자 페이지에 읽기 전용 행), POP/IMAP 허용과 메일함 크기만 둡니다. 게이트웨이 계정(관리자 페이지에서 만든 것)은 함께 쓸 수 있되 웹메이커 계정과 같은 로그인 ID 는 만들 수 없습니다. webmaker 모드 도메인에는 초기 관리자 admin/adminpasswd 를 만들지 않습니다. + +### 켜는 방법 + +1. **파일 읽기 권한**: 웹메이커 데이터(`DATA_ROOT`, 예 `/data/webmaker`)는 보통 웹메이커 서비스 계정(예: master)만 읽을 수 있고(0600/0700) 웹메이커가 파일을 다시 쓸 때마다 권한이 돌아옵니다. 같은 서버라면 root 로 한 번: + ```bash + sudo bash /srv/<사이트>/repo/deploy/webmaker-access.sh /data/webmaker + ``` + root 소유 사본 `/usr/local/lib/arimail/webmaker-snapshot.mjs`(node 22+)와 **systemd 소켓 도우미**(`arimail-webmaker-<슬러그>.socket` + `@.service`: `/run/arimail-webmaker/data-webmaker.sock` 에 접속하면 웹메이커 소유 계정으로 스냅샷 JSON 을 써 줌, 소켓은 AriMail 계정 그룹만 0660)를 설치하고 실행 계정으로 읽어 봅니다. AriMail 서비스 유닛은 `NoNewPrivileges=yes` 라 sudo 가 동작하지 않으므로 소켓 방식이 기본이고, systemd 가 없는 서버는 `--sudo`(sudoers 규칙 한 줄, 그 디렉터리 인수만 허용)로 설치합니다. AriMail 실행 계정이 직접 읽을 수 있는 디렉터리면 이 단계는 필요 없습니다. +2. **도메인 설정**: 전체 설정 화면(§14)의 도메인 카드에서 추가·수정 → 로그인 방식 **웹메이커(webmaker) 계정과 연동**, 웹메이커 데이터 디렉터리 `/data/webmaker`, 파일을 읽을 계정 `master`(1단계를 했을 때 — 소켓 도우미가 있으면 소켓, 없으면 sudo 도우미를 씀), 사이트 ID 는 보통 비움. 등록부에는 `auth: { mode:'webmaker', webmakerDir, webmakerUser, siteId }` 로 저장됩니다. 기본 도메인은 환경 파일(`/etc//gateway.env`)에 `LOGIN_MODE=webmaker`, `WEBMAKER_DIR=/data/webmaker`, 선택 `WEBMAKER_USER=master`, `WEBMAKER_SITE=<32자리>` 를 적고 재시작합니다. +3. 확인: 관리자 페이지(웹메이커 admin 으로 로그인)의 계정 목록에 "웹메이커 계정" 행이 보입니다. 파일을 못 읽으면 도메인 목록과 계정 목록에 "읽기 실패" 와 사유가 보이고, 마지막으로 읽은 스냅샷이 있으면 그것으로 계속 동작합니다. + +### 사이트 주소의 /mail 로 웹 메일 열기 + +서브 도메인 사이트(`site.ariweb.net`)는 메일용 호스트 이름(`mail.site.ariweb.net`)을 따로 두기 어렵습니다(와일드카드 인증서가 한 단계만 덮음). 그런 도메인은 사이트 주소의 `/mail` 아래를 웹 메일로 씁니다. + +1. 도메인의 호스트 항목에 `site.ariweb.net/mail` 처럼 **호스트/경로** 를 적습니다(같은 호스트 이름을 경로만 달리해 두 번 적을 수는 없음). +2. 사이트의 nginx 설정에 조각을 넣습니다(root): `sudo bash deploy/site-mail-path.sh site.ariweb.net` (또는 설정 파일 경로). `deploy/nginx/mail-path.conf.in` 을 `/etc/nginx/snippets/-mail-path.conf` 로 채우고 443 server 블록에 `include` 를 더합니다 — `location ^~ /mail/ { proxy_pass http://127.0.0.1:<포트>/; proxy_set_header X-Forwarded-Prefix /mail; … }`. 게이트웨이는 Host 로 도메인을, `X-Forwarded-Prefix`(없으면 등록부의 경로)로 화면의 기준 경로(``)를 압니다. 웹메이커는 사이트를 다시 발행할 때 vhost 파일(`wh-<사이트ID>.conf`)을 다시 쓰므로 그때 이 명령을 다시 실행하거나 웹메이커의 vhost 템플릿에 같은 include 를 넣어 둡니다. 사이트 폴더의 `mail/index.html`(웹메이커의 안내 파일)은 프록시가 먼저 받으므로 영향이 없습니다. +3. `https://site.ariweb.net/mail/` 이 웹 메일, `/mail/admin` 이 관리자 페이지, `/mail/setup` 이 설정 화면입니다. 세션 쿠키는 `Path=/mail` 로 한정됩니다. +4. **보안 참고**: 이 방식은 웹 메일이 사이트와 같은 출처(origin)를 씁니다. 사이트에 스크립트를 넣을 수 있는 사람(사이트 소유자)은 그 사이트를 방문한 로그인 사용자의 메일 API 를 같은 출처에서 부를 수 있으므로, 사이트 소유자를 믿을 수 없는 도메인은 별도 호스트 이름(`mail.<도메인>`, `domain-nginx.sh`)을 쓰세요. 메일 프로그램은 §14 와 같이 기본 도메인의 서버 이름에 `아이디@site.ariweb.net` 으로 접속합니다. + +## 16. 메일 사용량 정보 제공 API (1.0.9) + +웹메이커 같은 외부 관리 시스템이 도메인·계정별 디스크 사용량·메일 수·발송량을 읽는 읽기 전용 API 입니다. 전체 설정 화면(기본 도메인)의 **사용량 API** 카드에서 키를 만들면 켜지고(한 번만 표시), 접속 허용 IP 를 적어 웹메이커 서버만 부를 수 있게 합니다. 호출은 `GET /api/usage`(전체) · `GET /api/usage/<도메인>`(상세)에 `Authorization: Bearer <키>`. 자세한 응답 필드·보안 장치·웹메이커 쪽 예제는 [docs/USAGE-API.md](USAGE-API.md). + diff --git a/modules/arimail/docs/USAGE-API.md b/modules/arimail/docs/USAGE-API.md new file mode 100644 index 0000000..7edca1c --- /dev/null +++ b/modules/arimail/docs/USAGE-API.md @@ -0,0 +1,91 @@ +# 메일 사용량 정보 제공 API (1.0.9) + +웹메이커(WebHosting_AI_agent) 같은 외부 관리 시스템이 AriMail 의 **디스크 사용량·메일 수·발송량**을 도메인·계정 단위로 읽어 통합 관리할 수 있게 하는 읽기 전용 API 입니다. 구현은 `server/usage.mjs`. + +## 1. 켜기 — 키 만들기 + +1. 전체 설정 화면(기본 도메인의 전용 포트 `https://:8791/?token=…`, 또는 관리자 페이지 › 메일 서비스 설정 `/setup`)의 **사용량 API** 카드에서 **키 만들기**를 누릅니다. +2. 키(`au_…`, 32바이트 난수)는 **그때 한 번만** 보입니다. 웹메이커의 설정에 복사해 두세요. 설정 화면에는 이후 앞뒤 일부만 보입니다. +3. (권장) 같은 카드의 **접속 허용 IP** 에 웹메이커 서버의 IP 또는 CIDR 을 적고 저장합니다(쉼표 구분, 예 `203.0.113.10, 10.0.0.0/8`). 비우면 키만 맞으면 어디서나 허용합니다. +4. 키를 바꾸려면 **키 다시 만들기**(기존 키는 즉시 무효), 끄려면 **키 삭제**. 키는 기본 도메인의 설정 파일(`data/config.json` 의 `USAGE_API_KEY`)에만 저장되고 서버 하나에 하나입니다(모든 도메인을 한 키로 봄). + +API 로도 할 수 있습니다(전용 포트의 토큰 또는 관리자 세션): `POST /api/setup/usage-key {"action":"generate"}` → `{ ok, key, endpoint }`, `{"action":"revoke"}` → `{ ok, enabled:false }`. 허용 IP 는 `PUT /api/setup/config {"values":{"USAGE_API_ALLOW":"…"}}`. + +## 2. 부르기 + +``` +GET https://<기본 도메인 호스트>/api/usage 모든 도메인 요약 +GET https://<기본 도메인 호스트>/api/usage/<도메인> 도메인 하나 상세(계정 목록·일별 추이 포함) +헤더: Authorization: Bearer <키> (또는 X-Usage-Key: <키>) +쿼리: days=30 일별 추이·계정별 기간 합계의 기간(1~366, 기본 30) + accounts=1 계정 목록 포함(요약은 기본 0, 상세는 기본 1) + series=1 일별 추이 포함(요약은 기본 0, 상세는 기본 1) +``` + +어느 등록 호스트로 불러도 같습니다(예 `https://webmail.ariweb.net/api/usage`). 경로 접두사 아래의 도메인(`site.example.com/mail`)은 `https://site.example.com/mail/api/usage`. + +```bash +curl -s -H 'Authorization: Bearer au_…' 'https://mail.arihakdang.com/api/usage?days=30' | jq .totals +curl -s -H 'Authorization: Bearer au_…' 'https://mail.arihakdang.com/api/usage/ariweb.net?days=7' +``` + +## 3. 응답 + +전체 요약(`/api/usage`): + +```json +{ "ok": true, "version": "1.0.9", "generatedAt": "…", "primary": "arihakdang.com", + "totals": { "domains": 2, "accounts": 5, "storageBytes": 12345678, "largeFileBytes": 0, "messages": 420, "sentMonth": 31, "receivedMonth": 88 }, + "domains": [ { …도메인 보고서(아래, accountList·daily 없음)… } ] } +``` + +도메인 보고서(`/api/usage/<도메인>` 또는 `domains[]` 의 항목): + +| 필드 | 뜻 | +|---|---| +| `domain`, `primary`, `enabled`, `hosts`, `authMode`(local/www/webmaker), `mailbox`, `inbound`, `outbound`, `timezone` | 도메인 정보. `enabled:false` 는 중지한 도메인 | +| `accounts` | `{ local, external, total, limit, percent, externalSource }` — 게이트웨이 계정 수, 외부(웹메이커) 계정 수, 합계, 시스템 제한(0 = 없음) | +| `storage` | `{ bytes, limit, percent, largeFiles:{count, bytes, sent} }` — 모든 메일함의 사용량(MIME 기준 원본 크기 합), 시스템 제한, 대용량 첨부파일(별도 저장) | +| `messages` | `{ total, inbound, outbound, drafts, failed }` — 저장된 메일 수(휴지통 포함, 영구 삭제 제외) | +| `traffic` | `{ day, week, month }` 각각 `{ sent, received, total, sentBytes, receivedBytes, start, end }` — 통계 시간대(`STATS_TIMEZONE`, 기본 Asia/Seoul) 기준 오늘·이번 주(월~일)·이달. `monthlyLimit`, `monthlyPercent` 는 월간 통수 제한 대비 | +| `period` | `{ days, start, end }` — 아래 `daily`·계정별 기간 합계의 범위(`daily` 또는 `accountList` 를 포함할 때만 있음) | +| `daily[]` | `{ date:"YYYY-MM-DD", sent, received, sentBytes, receivedBytes }` 오래된 날부터 `days` 개 | +| `accountList[]` | `{ login, name, mailbox, role, source, status, messages, bytes, limit, percent, lastLoginAt, sent, received, sentBytes, receivedBytes }` — 계정(메일함)마다. `source` 는 local(게이트웨이 계정) / www(연결된 웹 서비스 관리자) / webmaker(웹메이커 계정) / orphan(계정은 지웠지만 메일이 남은 메일함). `sent`·`received`·`*Bytes` 는 `period` 안의 합계, `bytes`·`limit` 은 현재 사용량과 적용 중인 최대 크기(0 = 없음) | +| `limits` | `{ system, admin }` — 설치 시 시스템 제한(`LIMIT_*`)과 관리자 페이지의 제한 값(안내용) | + +"발신" 은 발송이 끝난 메일(`state='sent'`, 발송 시각 기준), "수신" 은 받은 시각 기준입니다. 바이트는 저장된 원본(.eml) 크기라 MIME 변환으로 늘어난 실제 크기입니다. 메일 본문·주소록·비밀번호·API 키는 어떤 응답에도 들어가지 않습니다. + +오류: `usage_api_disabled`(404, 키를 만들지 않음) · `unauthenticated`(401) · `forbidden_ip`(403) · `rate_limited`(429) · `domain_not_found`(404) · `method_not_allowed`(405). 모든 응답은 `Cache-Control: no-store`. 전체 요약에서 어느 도메인의 조회가 실패하면 그 항목만 `{ domain, error:'server_error' }` 로 표시되고 합계에서 빠집니다. `accounts.externalSource.error` 에는 웹메이커 파일을 읽지 못한 사유(파일 경로 포함)가 그대로 들어갑니다. + +## 4. 보안 대책 — 웹메이커의 해당 기능에서만 접근 + +| 장치 | 내용 | +|---|---| +| 비밀 키 | 32바이트 난수(`au_…`). 전체 설정 화면에서만 만들고 한 번만 표시, 비교는 상수 시간. 키가 없으면 API 자체가 404 | +| 접속 IP 제한 | `USAGE_API_ALLOW`(IP/CIDR 목록)에 웹메이커 서버만 적어 두면 키가 유출돼도 다른 곳에서는 403. 접속 IP 는 클라이언트가 넣을 수 있는 헤더가 아니라 nginx 가 보증하는 값 — `X-Real-IP`(1.0.9 템플릿) 또는 `X-Forwarded-For` 의 **마지막** 주소(nginx 가 덧붙인 실제 접속 주소) — 로 정합니다. Cloudflare 프록시 뒤라면 실제 접속 주소가 Cloudflare 이므로 환경 변수 `USAGE_TRUST_CF=1` 을 두어 `CF-Connecting-IP` 를 믿게 하거나(원본 서버 접근을 Cloudflare IP 로 제한한 경우만) IP 제한 대신 키만 쓰세요. 저장 때 항목 형식을 검사해 오타가 있으면 422 로 거부합니다 | +| 시도 제한 | 키가 틀린 요청 IP 마다 15분 10회(그 뒤 429), 성공 요청도 IP 마다 1분 120회 | +| 읽기 전용·최소 정보 | GET 만, 수(통·바이트)·제한·계정 이름만. 쓰기 동작 없음. 세션 쿠키와 무관(쿠키로는 못 부름) | +| 기록 | 성공 호출은 감사 기록(`audit_log`, actor `usage-api`), 거부(틀린 키·허용 밖 IP·빈도 초과)는 서버 로그에 IP·사유와 함께 남음 | +| 키를 만들 수 있는 사람 | 전체 설정 화면(전용 포트 토큰) 또는 기본 도메인 관리자 세션. 기본 도메인 관리자는 모든 도메인의 계정 목록·사용량을 볼 수 있는 키를 만들 수 있으므로 운영자만 두세요 | +| 전송 | HTTPS(nginx) 로만 노출. 키 교체·삭제는 즉시 적용(재시작 없음) | + +웹메이커 쪽에는 키를 비밀 보관소(`lib/vault.js`, 예: 종류 `arimail-usage`)에 두고 서버 간 호출에만 쓰세요(브라우저에 내려보내지 않음). + +## 5. 웹메이커에서 쓰는 예(Node, `lib/connectors/arimail.js` 에 더할 수 있는 모양) + +```js +// 설정: mail.url(= AriMail 공개 주소), 보관소 'arimail-usage' 에 키 +async function usage(domain = '', { days = 30 } = {}) { + const base = String(config.get('mail.url') || '').replace(/\/+$/, ''); + const key = vault.get('arimail-usage'); if (!base || !key) throw new Error('AriMail 사용량 API 주소/키 없음'); + const r = await fetch(`${base}/api/usage${domain ? '/' + encodeURIComponent(domain) : ''}?days=${days}`, { headers: { Authorization: 'Bearer ' + key }, signal: AbortSignal.timeout(20000) }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(`AriMail 사용량 API ${r.status} ${d.error || ''}`); + return d; // 전체: d.totals / d.domains[] 도메인 하나: d.storage / d.traffic / d.accountList … +} +// 예: 사이트(site.ariweb.net)의 디스크 사용량과 이달 발송량 +const u = await usage('site.ariweb.net', { days: 30 }); +console.log(u.storage.bytes, u.traffic.month.sent, u.accountList.map((a) => [a.mailbox, a.bytes, a.sent])); +``` + +웹메이커의 관리자 콘솔 › 메일 시스템 화면에서 도메인마다 `storage.bytes`(디스크), `traffic.month.sent`(이달 발송), `accounts.total`(계정 수)을 보여 주고, 사용자(사이트 관리자) 화면에는 `accountList` 에서 그 사용자의 `mailbox` 행만 골라 보여 주면 됩니다. 1분에 120회 제한이 있으므로 화면마다 호출하지 말고 1~5분 캐시를 두는 편이 좋습니다. diff --git a/modules/arimail/haraka/README.md b/modules/arimail/haraka/README.md new file mode 100644 index 0000000..576e234 --- /dev/null +++ b/modules/arimail/haraka/README.md @@ -0,0 +1,12 @@ +# haraka/ — AriMail 내장 Haraka (1.0.0) + +Haraka 를 게이트웨이와 함께 띄워 Resend 없이 메일을 받고(MX) 보낼 수 있게 하는 묶음입니다. 자세한 설명은 `docs/SETUP.md` 12절. + +| 파일 | 역할 | +|---|---| +| `package.json` | Haraka 3.3.4 + `haraka-plugin-dkim`. `deploy/haraka-apply.sh` 가 서버의 `/haraka` 에 npm 으로 설치합니다(저장소에는 node_modules 를 두지 않음). | +| `run.mjs` | 관리 프로세스(systemd `-haraka`). 설정 파일(config.json)을 5초마다 보고 수·발신 방식이 haraka 이면 `config/*` 와 `plugins/arimail.js` 를 만들어 Haraka 를 자식으로 띄우고, 아니면 끕니다. 상태는 `data/haraka-status.json`. | +| `plugins/arimail.js` | Haraka 플러그인. 로컬 제출 포트에서 온 접속은 relaying(발신), 그 밖은 수신으로 보고 RCPT 를 게이트웨이에 확인한 뒤 원본을 `POST /api/inbound/haraka` 로 넘깁니다. | + +개발 PC 에서 시험하려면: `npm install --prefix <어딘가>/haraka` 로 의존성을 두고 `HARAKA_DIR=<어딘가>/haraka PORT=8790 DB_FILE=<데이터>/mail-gateway.sqlite MAILBOX=admin@example.test node haraka/run.mjs`, +설정 파일의 `HARAKA_PORT`/`HARAKA_RELAY_PORT` 를 1024 이상으로 두면 root 권한 없이 뜹니다. diff --git a/modules/arimail/haraka/package.json b/modules/arimail/haraka/package.json new file mode 100644 index 0000000..c0a04ae --- /dev/null +++ b/modules/arimail/haraka/package.json @@ -0,0 +1,9 @@ +{ + "name": "arimail-haraka", + "private": true, + "description": "AriMail 내장 Haraka 메일 서버(MTA) — 수신(25) 과 발신(로컬 제출 포트 → 받는 쪽 MX 직접 배달). 설치는 deploy/haraka-apply.sh 가 서버의 /haraka 에 npm 으로 합니다.", + "dependencies": { + "Haraka": "3.3.4", + "haraka-plugin-dkim": "1.3.1" + } +} diff --git a/modules/arimail/haraka/plugins/arimail.js b/modules/arimail/haraka/plugins/arimail.js new file mode 100644 index 0000000..60b6142 --- /dev/null +++ b/modules/arimail/haraka/plugins/arimail.js @@ -0,0 +1,111 @@ +'use strict'; +/** + * AriMail ↔ Haraka 연결 플러그인(1.0.0, 요구사항 1). haraka/run.mjs 가 Haraka 설치 디렉터리(/haraka/plugins/)에 복사하고 + * config/plugins 에 'arimail' 로 올립니다. Haraka 플러그인은 CommonJS 라 이 파일만 require 문법을 씁니다. + * + * 하는 일(세 개의 훅) + * connect : 127.0.0.1 에서 "제출 포트(relay_port)" 로 들어온 접속은 게이트웨이가 보내는 메일이므로 connection.relaying = true 로 둡니다. + * relaying 이 켜진 접속은 Haraka 가 받는 쪽 MX 를 찾아 직접 배달하는 "발신" 경로(outbound 큐)로 처리합니다. + * 다른 주소·다른 포트에서 온 접속은 relaying 이 꺼진 "수신" 경로이며, 우리 도메인의 실제 계정으로 오는 메일만 받습니다(오픈 릴레이 방지). + * rcpt : 수신 접속의 받는 사람마다 (1) 우리 도메인인지, (2) 게이트웨이에 그 메일함이 있는지(GET /api/inbound/haraka/rcpt) 확인합니다. + * 없는 사용자는 RCPT 단계에서 바로 거부해(550) 받아 놓고 반송하는 역산란(backscatter)을 만들지 않습니다. + * queue : 수신한 메일 원본을 모아 게이트웨이의 POST /api/inbound/haraka 로 넘깁니다(헤더: 토큰, 트랜잭션 id, 봉투 보낸 사람/받는 사람). + * 게이트웨이가 저장하면 250(OK), 메일함이 가득 차 건너뛰면 452(DENYSOFT — 보낸 쪽이 나중에 다시 시도), 게이트웨이 오류면 451(DENYSOFT). + * + * 설정(config/arimail.ini, run.mjs 가 만듭니다) + * [main] gateway=http://127.0.0.1:8790 token= relay_port=2525 domains=example.com[,other.com] timeout_ms=30000 + */ +const http = require('node:http'); +const { URL } = require('node:url'); // Haraka 플러그인 샌드박스에는 전역 URL 이 없습니다 +const { Writable } = require('node:stream'); + +exports.register = function () { + this.load_arimail_ini(); + this.register_hook('connect', 'mark_relay'); + this.register_hook('rcpt', 'check_rcpt'); + this.register_hook('queue', 'deliver'); +}; + +/** arimail.ini 를 읽고, 파일이 바뀌면 다시 읽습니다(Haraka 의 config 감시 기능). */ +exports.load_arimail_ini = function () { + this.cfg = this.config.get('arimail.ini', { booleans: [] }, () => this.load_arimail_ini()); + const m = this.cfg.main || {}; + this.gateway = String(m.gateway || 'http://127.0.0.1:8790').replace(/\/+$/, ''); + this.token = String(m.token || ''); + this.relayPort = Number(m.relay_port || 2525); + this.domains = String(m.domains || '').split(/[,\s]+/).map((d) => d.trim().toLowerCase()).filter(Boolean); + this.timeoutMs = Number(m.timeout_ms || 30000); +}; + +const isLoopback = (ip) => ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; +// Haraka 3 의 주소 객체(address-rfc2821)는 address 가 속성(문자열)입니다. 예전 판은 메서드였으므로 둘 다 받습니다. +const addrOf = (a) => (a ? String(typeof a.address === 'function' ? a.address() : a.address || '') : ''); + +/** connect: 로컬 제출 포트로 온 접속은 발신(relaying). */ +exports.mark_relay = function (next, connection) { + const port = Number(connection.local && connection.local.port); + if (isLoopback(connection.remote && connection.remote.ip) && port === this.relayPort) { + connection.relaying = true; + connection.loginfo(this, `relay from gateway (local port ${port})`); + } + next(); +}; + +/** 게이트웨이 API 호출(JSON). 실패는 reject. */ +exports.gateway_request = function (method, path, headers, body) { + return new Promise((resolve, reject) => { + const url = new URL(this.gateway + path); + const req = http.request({ hostname: url.hostname, port: url.port || 80, path: url.pathname + url.search, method, headers: { 'X-AriMail-Token': this.token, ...headers } }, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { let data = null; try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { data = null; } resolve({ status: res.statusCode, data }); }); + }); + req.setTimeout(this.timeoutMs, () => req.destroy(new Error('gateway timeout'))); + req.on('error', reject); + if (body) req.end(body); else req.end(); + }); +}; + +/** rcpt: 수신 접속의 받는 사람 확인. */ +exports.check_rcpt = function (next, connection, params) { + if (connection.relaying) return next(); // 발신(게이트웨이 제출)은 어떤 받는 사람이든 허용 + const rcpt = params[0]; + const addr = addrOf(rcpt).toLowerCase(), domain = String(rcpt.host || addr.split('@')[1] || '').toLowerCase(); + if (!this.domains.includes(domain)) return next(DENY, 'Relay access denied'); + this.gateway_request('GET', '/api/inbound/haraka/rcpt?addr=' + encodeURIComponent(addr), {}) + .then(({ status, data }) => { + if (status === 200 && data && data.local) return next(OK); + if (status === 200 && data && data.local === false) { connection.loginfo(this, `rcpt rejected (no such user): ${addr}`); return next(DENY, 'No such user here'); } + connection.logwarn(this, `gateway rcpt check failed: HTTP ${status}`); next(DENYSOFT, 'Mailbox lookup temporarily unavailable'); + }) + .catch((e) => { connection.logerror(this, `gateway rcpt check error: ${e.message}`); next(DENYSOFT, 'Mailbox lookup temporarily unavailable'); }); +}; + +/** queue: 원본을 모아 게이트웨이로 넘깁니다. */ +exports.deliver = function (next, connection) { + const txn = connection.transaction; + if (!txn) return next(); + const chunks = []; + const sink = new Writable({ write(chunk, _enc, cb) { chunks.push(chunk); cb(); } }); + sink.on('error', (e) => { connection.logerror(this, `message stream error: ${e.message}`); next(DENYSOFT, 'Temporary failure reading message'); }); + sink.on('finish', () => { + const eml = Buffer.concat(chunks); + const rcpt = (txn.rcpt_to || []).map((r) => addrOf(r).toLowerCase()); + const from = addrOf(txn.mail_from); + const headers = { 'Content-Type': 'message/rfc822', 'Content-Length': String(eml.length), 'X-AriMail-Id': String(txn.uuid || ''), 'X-AriMail-From': from, 'X-AriMail-Rcpt': JSON.stringify(rcpt) }; + this.gateway_request('POST', '/api/inbound/haraka', headers, eml) + .then(({ status, data }) => { + if (status >= 200 && status < 300 && data && data.ok) { + if (data.skipped === 'quota_exceeded' || data.skipped === 'storage_full') { connection.lognotice(this, `mailbox full: ${rcpt.join(',')}`); return next(DENYSOFT, 'Mailbox full, try again later'); } + connection.loginfo(this, `delivered to gateway${data.imported ? ' id=' + data.id : ' (' + (data.skipped || 'skipped') + ')'}`); + return next(OK, 'Message accepted for delivery'); + } + connection.logerror(this, `gateway rejected: HTTP ${status} ${data && data.error ? data.error : ''}`); + next(DENYSOFT, 'Delivery to mailbox temporarily failed'); + }) + .catch((e) => { connection.logerror(this, `gateway delivery error: ${e.message}`); next(DENYSOFT, 'Delivery to mailbox temporarily failed'); }); + }); + // haraka-message-stream 의 옵션 뜻(3.1+): dot_stuffed:true = 저장된 SMTP 점 스터핑을 풀어 "로컬 전달용" 원본으로 내보냄(false 는 SMTP 중계용으로 그대로 둠). + // 이름이 직관과 반대이므로 주의. 줄 끝은 CRLF 로 맞춥니다(게이트웨이 해석기 기준). + txn.message_stream.pipe(sink, { line_endings: '\r\n', dot_stuffed: true }); +}; diff --git a/modules/arimail/haraka/run.mjs b/modules/arimail/haraka/run.mjs new file mode 100644 index 0000000..25d9ba8 --- /dev/null +++ b/modules/arimail/haraka/run.mjs @@ -0,0 +1,198 @@ +/** + * 내장 Haraka 관리 프로세스(1.0.0, 요구사항 1) — systemd 서비스 -haraka 가 실행합니다(deploy/systemd/mail-haraka.service.in). + * + * 왜 따로 두나 + * 게이트웨이 프로세스는 systemd 의 NoNewPrivileges 아래에서 돌아 다른 서비스를 켜고 끌 수 없습니다(sudo 불가). 대신 이 관리 프로세스가 + * 항상 떠 있으면서 설정 파일(config.json, config.mjs)을 5초마다 보고, 수신 또는 발신 방식이 haraka 이면 Haraka 설정을 만들어 Haraka 를 자식 + * 프로세스로 띄우고, 아니면 끕니다. 그래서 운영자는 웹 화면에서 방식만 고르면 되고 서버에서 명령을 칠 일이 없습니다. + * + * 하는 일 + * 1) 설정 읽기: CONFIG_FILE(없으면 DB 파일 옆 config.json). 값이 없으면 환경 변수(HARAKA_*)와 기본값을 씁니다. + * 2) Haraka 설정 만들기: /config/{smtp.ini, plugins, me, arimail.ini, tls.ini, dkim.ini, dkim.private.key, outbound.ini} 와 plugins/arimail.js. + * 내용이 바뀐 파일만 다시 쓰고, 바뀌었으면 Haraka 를 다시 띄웁니다. + * 3) Haraka 실행: node /node_modules/Haraka/bin/haraka -c . 죽으면 5초→최대 60초 간격으로 다시 띄웁니다. + * 4) 상태 기록: 데이터 디렉터리의 haraka-status.json 에 실행 여부·pid·대기 포트·최근 로그를 30초마다(변화가 있으면 즉시) 씁니다. + * 게이트웨이(setup.mjs harakaStatus)와 설정 화면이 이 파일로 상태를 보여 줍니다. + * + * 환경 변수(gateway.env 를 그대로 받음): DB_FILE 또는 CONFIG_FILE, HARAKA_DIR(기본 데이터 디렉터리 옆 haraka/), PORT(게이트웨이 HTTP 포트), MAILBOX(메일 도메인), + * PUBLIC_HOST(기본 호스트 이름), POP_TLS_CERT/POP_TLS_KEY(있으면 Haraka STARTTLS 에 같은 인증서), HARAKA_LOGLEVEL(기본 info). + * + * 다중 도메인(1.0.7): 데이터 디렉터리의 도메인 등록부(domains.json)와 각 추가 도메인의 설정 파일(domains/<도메인>/config.json)도 읽습니다. + * - 수신 방식이 haraka 인 모든(켜진) 도메인을 arimail.ini 의 domains= 에 넣어 Haraka 가 그 도메인들의 메일을 받습니다(게이트웨이가 받는 사람 주소로 도메인을 나눔). + * - 발신 방식이 haraka 이고 DKIM 키가 있는 도메인마다 config/dkim/<도메인>/{private,selector} 를 만들어 haraka-plugin-dkim 이 보낸 사람 도메인에 맞는 키로 서명합니다. + * - 포트·호스트 이름·게이트웨이 토큰은 기본 도메인(환경 파일의 도메인) 설정을 씁니다. Haraka 는 서버에 하나입니다. + */ +import { spawn } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync, statSync, readdirSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DB_FILE = process.env.DB_FILE || join(process.cwd(), 'data', 'mail-gateway.sqlite'); +const CONFIG_FILE = process.env.CONFIG_FILE || join(dirname(DB_FILE), 'config.json'); +const DATA_DIR = dirname(CONFIG_FILE); +const HARAKA_DIR = process.env.HARAKA_DIR || join(DATA_DIR, '..', 'haraka'); +const STATUS_FILE = join(DATA_DIR, 'haraka-status.json'); +const HARAKA_BIN = join(HARAKA_DIR, 'node_modules', 'Haraka', 'bin', 'haraka'); +const CHECK_MS = Number(process.env.HARAKA_CHECK_MS || 5000); +const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a); + +let child = null, startedAt = null, restarts = 0, lastExit = null, backoff = 5000, restartTimer = null, stopping = false, expectedExit = false; +const lastLog = []; const remember = (line) => { for (const l of String(line).split(/\r?\n/)) if (l.trim()) { lastLog.push(l.slice(0, 300)); if (lastLog.length > 15) lastLog.shift(); } }; +let lastStatusWrite = 0, lastStatusJson = ''; + +/** 설정 파일 + 환경 변수 → 이 프로세스가 쓰는 값. 파일을 못 읽으면 null(Haraka 를 띄우지 않음). */ +function readSettings() { + let cfg = {}; + try { cfg = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') log('설정 파일 읽기 실패:', e.message); return null; } + const pick = (key, fallback) => (cfg[key] !== undefined && cfg[key] !== '' && cfg[key] !== null ? cfg[key] : process.env[key] !== undefined && process.env[key] !== '' ? process.env[key] : fallback); + const mailbox = String(process.env.MAILBOX || 'admin@arihakdang.com'); + const domain = mailbox.split('@')[1] || 'localhost'; + const hostname = String(pick('HARAKA_HOSTNAME', process.env.PUBLIC_HOST || 'mail.' + domain)).trim(); + // 1.0.7: 등록부의 추가 도메인(켜진 것만)과 그 설정 파일. 등록부가 없으면 기본 도메인 하나(1.0.5 까지와 같음). + const all = [{ domain, cfg }]; + try { + const reg = JSON.parse(readFileSync(join(DATA_DIR, 'domains.json'), 'utf8')); + for (const [d, entry] of Object.entries(reg.domains || {})) { + if (d === reg.primary || d === domain || !entry || entry.enabled === false) continue; + let c = {}; try { c = JSON.parse(readFileSync(join(DATA_DIR, 'domains', d, 'config.json'), 'utf8')); } catch { c = {}; } + all.push({ domain: d, cfg: c }); + } + } catch { /* 등록부 없음 = 단일 도메인 */ } + const inboundDomains = all.filter((x) => x.cfg.inbound === 'haraka').map((x) => x.domain); + // 서명 키: 발신 haraka + DKIM 키가 있는 도메인. 기본 도메인은 설정 파일에 없으면 환경 변수(HARAKA_DKIM_*)도 봅니다(예전 방식). + const signers = all.filter((x) => x.cfg.outbound === 'haraka').map((x) => ({ domain: x.domain, selector: String((x.domain === domain ? pick('HARAKA_DKIM_SELECTOR', 'arimail') : x.cfg.HARAKA_DKIM_SELECTOR) || 'arimail'), privateKey: String((x.domain === domain ? pick('HARAKA_DKIM_PRIVATE', '') : x.cfg.HARAKA_DKIM_PRIVATE) || '') })).filter((x) => x.privateKey); + const inbound = inboundDomains.length > 0, outbound = all.some((x) => x.cfg.outbound === 'haraka'); + const tlsCert = process.env.POP_TLS_CERT, tlsKey = process.env.POP_TLS_KEY; + const tls = tlsCert && tlsKey && existsSync(tlsCert) && existsSync(tlsKey); + const primarySigner = signers.find((x) => x.domain === domain) || signers[0] || null; + return { + enabled: inbound || outbound, inbound, outbound, domain, domains: inboundDomains, signers, hostname, + port: Number(pick('HARAKA_PORT', 25)), relayPort: Number(pick('HARAKA_RELAY_PORT', 2525)), + token: String(pick('HARAKA_TOKEN', '')), gateway: `http://127.0.0.1:${Number(process.env.PORT || 8790)}`, + // dkimSelector/dkimPrivate: 단일 도메인 폴백([sign] 절). 도메인별 디렉터리(config/dkim/<도메인>)가 먼저 적용됩니다. + dkimSelector: primarySigner ? primarySigner.selector : String(pick('HARAKA_DKIM_SELECTOR', 'arimail')), dkimPrivate: primarySigner ? primarySigner.privateKey : '', + tls: tls ? { cert: tlsCert, key: tlsKey } : null, loglevel: String(process.env.HARAKA_LOGLEVEL || 'info'), + }; +} + +/** Haraka 설정 파일 내용. 키는 기준 상대 경로. */ +function renderFiles(s) { + const files = {}; + // 수신 포트는 모든 주소(IPv6 포함 [::0]), 제출 포트는 루프백만. 발신만 쓰면 25 를 열 필요가 없지만 수신·발신을 바꾸는 순간 재시작 없이 동작하도록 둘 다 엽니다. + const listen = [...(s.inbound ? [`[::0]:${s.port}`] : []), `127.0.0.1:${s.relayPort}`].join(','); + files['config/smtp.ini'] = `; AriMail 이 만든 파일 — 직접 고치지 마세요(haraka/run.mjs 가 설정 파일로 다시 만듭니다)\nlisten=${listen}\nnodes=1\ndaemonize=false\nspool_dir=${join(HARAKA_DIR, 'queue')}\n`; + files['config/me'] = s.hostname + '\n'; + files['config/plugins'] = ['; AriMail 이 만든 파일', ...(s.tls ? ['tls'] : []), 'arimail', ...(s.outbound && s.signers.length ? ['dkim'] : [])].join('\n') + '\n'; + // domains=: 수신 방식이 haraka 인 모든 도메인(1.0.7). 하나도 없으면(발신만) 기본 도메인만 적어 두되 수신 포트는 열지 않습니다. + files['config/arimail.ini'] = `[main]\ngateway=${s.gateway}\ntoken=${s.token}\nrelay_port=${s.relayPort}\ndomains=${(s.domains.length ? s.domains : [s.domain]).join(',')}\ntimeout_ms=30000\n`; + files['config/tls.ini'] = s.tls ? `key=${s.tls.key}\ncert=${s.tls.cert}\n[outbound]\n` : `; TLS 인증서 없음 — POP_TLS_CERT/POP_TLS_KEY 가 읽히면 STARTTLS 를 켭니다\n[outbound]\n`; + files['config/outbound.ini'] = `received_header=AriMail\nenable_tls=true\n`; + files['config/log.ini'] = `level=${s.loglevel}\ntimestamps=false\nformat=default\n`; + // 1.0.7: 단일 도메인 폴백([sign] 의 domain/selector + dkim.private.key)은 쓰지 않습니다 — 키가 없는 다른 도메인의 메일이 기본 도메인 키(d=기본 도메인)로 서명되어 + // DMARC 정렬이 조용히 깨지기 때문입니다. 키는 도메인별 디렉터리(config/dkim/<도메인>/)에만 두고, 키 없는 도메인은 서명하지 않습니다. + files['config/dkim.ini'] = `[sign]\nenabled=${s.outbound && s.signers.length ? 'true' : 'false'}\nheaders=From, Sender, Reply-To, Subject, Date, Message-ID, To, Cc, MIME-Version, Content-Type, Content-Transfer-Encoding\n[canon]\nbody=relaxed\n[verify]\nenabled=true\n`; + // 도메인별 서명 키(1.0.7): haraka-plugin-dkim 은 보낸 사람 도메인의 config/dkim/<도메인>/ 디렉터리에서 키를 찾습니다. + for (const x of s.signers) { files[`config/dkim/${x.domain}/selector`] = x.selector + '\n'; files[`config/dkim/${x.domain}/private`] = x.privateKey.endsWith('\n') ? x.privateKey : x.privateKey + '\n'; } + files['plugins/arimail.js'] = readFileSync(join(HERE, 'plugins', 'arimail.js'), 'utf8'); + return files; +} + +/** + * Haraka 기본 설정 파일 채우기. `haraka -c ` 는 /config 만 보므로 Haraka 패키지의 기본 설정(connection.ini·outbound.ini·host_list …)이 없으면 + * 핵심 코드가 cfg.message 같은 값을 못 읽어 접속 때 예외를 냅니다(`haraka -i` 가 하는 일과 같음). 우리가 만드는 파일은 덮어쓰고, 그 밖의 기본 파일은 없을 때만 복사합니다. + */ +function seedDefaults(ours) { + const src = join(HARAKA_DIR, 'node_modules', 'Haraka', 'config'); let entries = []; + try { entries = readdirSync(src); } catch { return; } + for (const name of entries) { + const rel = 'config/' + name; if (ours[rel]) continue; + const dst = join(HARAKA_DIR, rel); + try { if (!existsSync(dst) && statSync(join(src, name)).isFile()) copyFileSync(join(src, name), dst); } catch (e) { log('기본 설정 복사 실패', name, e.message); } + } +} +/** 바뀐 파일만 씁니다. 하나라도 바뀌면 true. */ +function writeFiles(files) { + let changed = false; + mkdirSync(join(HARAKA_DIR, 'config'), { recursive: true }); mkdirSync(join(HARAKA_DIR, 'plugins'), { recursive: true }); mkdirSync(join(HARAKA_DIR, 'queue'), { recursive: true }); + seedDefaults(files); + for (const [rel, content] of Object.entries(files)) { + const file = join(HARAKA_DIR, rel); let current = null; + mkdirSync(dirname(file), { recursive: true }); // config/dkim/<도메인>/ 같은 하위 디렉터리(1.0.7) + try { current = readFileSync(file, 'utf8'); } catch { current = null; } + if (current === content) continue; + writeFileSync(file, content, { mode: rel.includes('private') || rel.includes('arimail.ini') ? 0o600 : 0o644 }); changed = true; + try { chmodSync(file, rel.includes('private') || rel.includes('arimail.ini') ? 0o600 : 0o644); } catch { /* 무시 */ } + } + // 더는 서명하지 않는 도메인의 키 디렉터리는 지웁니다(도메인 삭제·발신 방식 변경). config/dkim 은 이 프로그램이 전부 관리하는 디렉터리입니다. + const keep = new Set(Object.keys(files).map((rel) => rel.match(/^config\/dkim\/([^/]+)\//)?.[1]).filter(Boolean)); + // 1.0.5 까지 쓰던 단일 도메인 키 파일은 지웁니다(폴백 서명 방지). + try { if (existsSync(join(HARAKA_DIR, 'config', 'dkim.private.key'))) { rmSync(join(HARAKA_DIR, 'config', 'dkim.private.key')); changed = true; } } catch { /* 무시 */ } + try { for (const name of readdirSync(join(HARAKA_DIR, 'config', 'dkim'))) { const dir = join(HARAKA_DIR, 'config', 'dkim', name); if (!keep.has(name) && statSync(dir).isDirectory()) { rmSync(dir, { recursive: true, force: true }); changed = true; } } } catch { /* 디렉터리 없음 */ } + return changed; +} + +function installed() { return existsSync(HARAKA_BIN); } +function harakaVersion() { try { return JSON.parse(readFileSync(join(HARAKA_DIR, 'node_modules', 'Haraka', 'package.json'), 'utf8')).version; } catch { return null; } } + +function startHaraka(s) { + if (child || stopping) return; + log(`Haraka 시작: ${HARAKA_BIN} -c ${HARAKA_DIR} (수신 ${s.inbound ? s.port : '꺼짐'} · 제출 127.0.0.1:${s.relayPort})`); + child = spawn(process.execPath, [HARAKA_BIN, '-c', HARAKA_DIR], { cwd: HARAKA_DIR, env: { ...process.env, HARAKA: HARAKA_DIR }, stdio: ['ignore', 'pipe', 'pipe'] }); + startedAt = new Date().toISOString(); + child.stdout.on('data', (c) => { process.stdout.write(c); remember(c.toString('utf8')); }); + child.stderr.on('data', (c) => { process.stderr.write(c); remember(c.toString('utf8')); }); + child.on('exit', (code, signal) => { + lastExit = { code: code ?? signal, at: new Date().toISOString() }; const uptime = Date.now() - Date.parse(startedAt); + child = null; startedAt = null; log(`Haraka 종료: ${code ?? signal}`); + if (stopping) return; + // 우리가 끈 것(설정 변경·사용 중지)은 장애가 아니므로 재시작 횟수·대기 시간을 늘리지 않고 다음 점검(tick)에서 필요하면 바로 다시 띄웁니다. + if (expectedExit) { expectedExit = false; backoff = 5000; writeStatus(readSettings(), true); return; } + // 스스로 죽었으면: 오래 잘 돌다 죽었으면 5초 뒤, 금방 죽었으면 간격을 늘려 다시 띄웁니다(설정 오류로 무한 재시작 폭주 방지). + backoff = uptime > 60000 ? 5000 : Math.min(60000, backoff * 2); restarts++; + restartTimer = setTimeout(() => { restartTimer = null; const s2 = readSettings(); if (s2?.enabled && installed()) startHaraka(s2); }, backoff); + writeStatus(readSettings(), true); + }); + writeStatus(s, true); +} +function stopHaraka(reason) { + if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; } + if (!child) return; + log(`Haraka 중지(${reason})`); + expectedExit = true; const c = child; c.kill('SIGTERM'); setTimeout(() => { try { c.kill('SIGKILL'); } catch { /* 이미 끝남 */ } }, 8000).unref(); +} + +/** 상태 파일 쓰기. 내용이 같으면 30초마다 updatedAt 만 갱신(살아 있음 표시). */ +function writeStatus(s, force = false) { + const status = { + enabled: !!s?.enabled, inbound: !!s?.inbound, outbound: !!s?.outbound, installed: installed(), version: harakaVersion(), + running: !!child, pid: child?.pid || null, startedAt, restarts, lastExit, lastLog, + listen: s ? [...(s.inbound ? [`0.0.0.0:${s.port}`] : []), `127.0.0.1:${s.relayPort}`] : [], hostname: s?.hostname || null, domain: s?.domain || null, + // 1.0.7: 수신하는 도메인 목록과 DKIM 서명 도메인 목록(설정 화면의 Haraka 상태에 보임) + domains: s?.domains || [], signers: (s?.signers || []).map((x) => x.domain), + dkim: !!(s?.signers?.length), tls: !!s?.tls, configDir: join(HARAKA_DIR, 'config'), updatedAt: new Date().toISOString(), + }; + const json = JSON.stringify({ ...status, updatedAt: null }); + if (!force && json === lastStatusJson && Date.now() - lastStatusWrite < 30000) return; + try { writeFileSync(STATUS_FILE + '.tmp', JSON.stringify(status, null, 1)); chmodSync(STATUS_FILE + '.tmp', 0o644); copyFileSync(STATUS_FILE + '.tmp', STATUS_FILE); } catch (e) { log('상태 파일 쓰기 실패:', e.message); } + lastStatusJson = json; lastStatusWrite = Date.now(); +} + +let lastFiles = null; +function tick() { + const s = readSettings(); + if (!s || !s.enabled) { if (child) stopHaraka('설정에서 Haraka 를 쓰지 않음'); writeStatus(s); return; } + if (!installed()) { writeStatus(s); return; } + if (!s.token) { log('HARAKA_TOKEN 이 없어 기다립니다(게이트웨이가 처음 뜨면 만듭니다)'); writeStatus(s); return; } + const files = renderFiles(s); + const changed = writeFiles(files); + const key = JSON.stringify(files); + if (child && (changed || (lastFiles && lastFiles !== key))) { log('설정이 바뀌어 Haraka 를 다시 띄웁니다'); stopHaraka('설정 변경'); } + lastFiles = key; + if (!child && !restartTimer) startHaraka(s); + writeStatus(s); +} + +log(`AriMail Haraka 관리 시작 — 설정 ${CONFIG_FILE}, Haraka ${HARAKA_DIR}${installed() ? ' (v' + harakaVersion() + ')' : ' (미설치: deploy/haraka-apply.sh 로 설치)'}`); +tick(); const timer = setInterval(tick, CHECK_MS); +for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopping = true; clearInterval(timer); stopHaraka(sig); writeStatus(readSettings(), true); setTimeout(() => process.exit(0), child ? 2000 : 0); }); diff --git a/modules/arimail/lang/ko.json b/modules/arimail/lang/ko.json new file mode 100644 index 0000000..b05638e --- /dev/null +++ b/modules/arimail/lang/ko.json @@ -0,0 +1,666 @@ +{ + "title": "아리학당 메일 게이트웨이", + "signIn": "접속", + "loginId": "아이디 또는 이메일", + "loginHelpBtn": "접속 안내", + "loginHelpTitle": "{host} 접속 안내", + "loginHelpIntro": "{host} 은(는) {domain} 도메인의 메일 서비스입니다. 이 도메인으로 받은 메일을 보고 보내는 웹 메일함이며, 메일 프로그램(IMAP·POP·SMTP)에서도 같은 계정으로 쓸 수 있습니다.", + "loginHelp1": "아이디: 부여받은 계정 이름(예: {example}) 또는 도메인을 포함한 이메일 주소(예: {example}@{domain})를 입력합니다. 둘 다 같은 계정입니다.", + "loginHelp2": "비밀번호: 웹 접속에 쓰는 계정의 비밀번호를 그대로 입력합니다. 메일 전용 비밀번호는 따로 없습니다.", + "loginHelp3": "{www} 관리자는 www 사이트에 로그인할 때 쓰는 아이디(또는 이메일)와 비밀번호를 그대로 씁니다. admin 또는 admin@{domain} 으로도 접속할 수 있습니다.", + "loginHelp4": "관리자가 만들어 준 계정은 전달받은 아이디와 비밀번호를 씁니다. 비밀번호를 바꾸거나 잊었으면 관리자에게 문의하세요.", + "loginHelp5": "여기서 쓰는 아이디·비밀번호는 메일 프로그램의 받는 서버(IMAP·POP)와 보내는 서버(SMTP) 설정에도 그대로 씁니다. 설정 값은 로그인 후 설정 화면에서 볼 수 있습니다.", + "loginHelpFooter": "접속이 되지 않으면 아이디에 도메인까지 포함해 다시 시도하고, 그래도 안 되면 관리자에게 계정 상태를 확인해 달라고 하세요.", + "adminLoginTitle": "관리자 페이지 로그인", + "adminLoginHelp": "이 화면({host}/admin)은 관리자 페이지입니다. 관리자 역할 계정으로만 들어갈 수 있고, 웹 메일 로그인과는 별도로 다시 로그인합니다.", + "admin_only": "관리자 역할 계정만 관리자 페이지에 들어갈 수 있습니다.", + "admin_login_required": "계정 관리는 관리자 페이지({host}/admin)에서 로그인한 뒤 쓸 수 있습니다.", + "adminTitle": "관리자", + "password": "비밀번호", + "logout": "로그아웃", + "close": "닫기", + "mail": "메일", + "settings": "설정", + "mail_inbox": "받은 메일함", + "mail_sent": "보낸 메일함", + "mail_drafts": "초안 작성중", + "mail_trash": "휴지통", + "compose": "메일 쓰기", + "mailBody": "본문", + "mailSearch": "보낸 사람, 받는 사람, 제목, 본문 검색", + "search": "검색", + "refresh": "새로고침", + "previous": "이전", + "next": "다음", + "empty": "내역이 없습니다.", + "loading": "불러오는 중…", + "noSubject": "제목 없음", + "from": "보내는 사람", + "to": "받는 사람", + "cc": "참조", + "mailSubject": "메일 제목", + "save": "저장", + "saved": "저장했습니다.", + "error": "처리하지 못했습니다. 입력 내용을 확인해 주세요.", + "errorCode": "처리하지 못했습니다 ({code}). 화면을 새로 고친 뒤에도 같으면 관리자에게 이 코드를 알려 주세요.", + "delete": "삭제", + "deleteConfirm": "이 항목을 삭제하시겠습니까?", + "restore": "복원", + "deleteForever": "영구 삭제", + "moveTrash": "휴지통으로 이동", + "reply": "답장", + "replyAll": "전체 답장", + "forward": "전달", + "sendMail": "메일 보내기", + "retrySend": "다시 보내기", + "mailSent": "발송했습니다.", + "draftSaved": "초안을 저장했습니다. 아직 발송되지 않았습니다.", + "attachments": "첨부파일", + "addAttachment": "파일 첨부", + "attachment_too_large": "첨부 크기가 한도를 넘습니다(한 메일에 10개, 합계 20MB 까지).", + "attachmentUnavailable": "저장하지 못한 첨부(용량 초과)", + "original": "원본(.eml) 내려받기", + "originalStored": "Resend 원본 그대로 보관", + "originalBuilt": "저장한 본문·첨부로 재구성한 원본", + "originalNone": "원본 없음", + "showImages": "외부 이미지 표시", + "hideImages": "외부 이미지 숨기기", + "markUnread": "읽지 않음으로 표시", + "popDelivered": "POP 전달됨", + "popNotDelivered": "POP 미전달", + "popReset": "POP 전달 기록 초기화(다시 내려받기)", + "popResetConfirm": "이 메일을 POP 클라이언트에 다시 내려줄 수 있도록 전달 기록을 지웁니다. 계속할까요?", + "receivedAt": "수신 시각", + "sentAt": "발송 시각", + "source_webhook": "웹훅 수신", + "source_sync": "정기 동기화", + "source_web": "웹에서 작성", + "edit_bold": "굵게", + "edit_italic": "기울임", + "edit_underline": "밑줄", + "edit_strikeThrough": "취소선", + "edit_insertOrderedList": "번호 목록", + "edit_insertUnorderedList": "글머리 목록", + "edit_removeFormat": "서식 지우기", + "insertLink": "링크", + "unlink": "링크 삭제", + "quote": "인용", + "linkUrl": "연결할 주소를 입력하세요.", + "invalidUrl": "올바른 웹 주소를 입력하세요.", + "editorToolbar": "메일 서식 도구", + "editorHelp": "서식 있는 텍스트를 붙여넣을 수 있습니다. 스크립트와 외부 스타일은 제거됩니다.", + "mailServiceOk": "메일 서비스 연결 정상", + "mailServiceError": "메일 서비스 오류", + "syncing": "Resend 목록을 확인하는 중…", + "syncStarted": "동기화를 시작했습니다. 잠시 후 목록이 갱신됩니다.", + "invalid_credentials": "아이디 또는 비밀번호를 확인해 주세요.", + "rate_limited": "시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.", + "unauthenticated": "다시 접속해 주세요.", + "validation": "받는 사람, 제목, 본문을 확인해 주세요.", + "invalid_email": "받는 사람 주소 형식을 확인해 주세요.", + "not_found": "항목을 찾을 수 없습니다.", + "mail_provider_unavailable": "메일 서비스 요청에 실패했습니다. 잠시 후 다시 시도하세요.", + "mail_not_configured": "메일 서비스 연결 설정(RESEND_API_KEY)을 확인해 주세요.", + "mail_auth_failed": "Resend API 키 인증에 실패했습니다. 서버 환경 파일의 RESEND_API_KEY가 유효한 키인지 확인해 주세요.", + "mail_forbidden": "Resend가 요청을 거부했습니다(403). API 키의 권한(전체 접근)과 도메인 소유를 확인해 주세요.", + "mail_domain_unverified": "발신 도메인이 Resend에서 검증되지 않아 메일을 보낼 수 없습니다. 관리자 › 서버 상태에서 원인(DNS 레코드)을 확인해 주세요.", + "mail_domain_missing": "발신 도메인이 Resend에 등록되어 있지 않습니다. 관리자 › 메일 서비스 설정에서 자동 설정을 실행해 주세요.", + "mail_rate_limited": "Resend 요청 한도를 초과했습니다. 잠시 후 다시 시도해 주세요.", + "mail_rejected": "Resend가 메일 내용을 거부했습니다. 받는 사람 주소, 제목, 본문을 확인해 주세요.", + "mail_locked": "발송을 시작한 내용은 수정할 수 없습니다.", + "mail_sending": "발송 처리 중입니다. 잠시 후 새로고침하세요.", + "mail_trash_first": "휴지통으로 옮긴 뒤 영구 삭제할 수 있습니다.", + "confirmCloseUnsaved": "작성 중인 메일을 저장하지 못했습니다. 그래도 닫을까요? 닫으면 저장하지 못한 내용은 사라집니다.", + "mail_move_invalid": "이 메일은 그 폴더로 옮길 수 없습니다. 받은 메일은 받은 메일함, 보낸 메일은 보낸 메일함, 초안은 임시 보관함과 휴지통으로만 옮길 수 있습니다.", + "moveSkipped": "일부 메일은 상태에 맞지 않아 옮기지 않았습니다(받은 메일 → 받은 메일함, 보낸 메일 → 보낸 메일함, 초안 → 임시 보관함).", + "message_too_large": "본문이 너무 큽니다. 내용을 줄여 주세요.", + "webhook_not_configured": "웹훅 비밀키가 설정되지 않았습니다.", + "settingsIntro": "메일 프로그램(IMAP·POP·SMTP) 연결에 필요한 값과 화면 설정입니다. 메일 프로그램에는 이 사이트의 로그인 아이디와 비밀번호를 그대로 씁니다.", + "smtpSettings": "SMTP 설정 (보내는 메일 서버)", + "smtpIntro": "메일 프로그램(Outlook, Thunderbird, Apple Mail, 스마트폰 메일 앱 등)에서 이 계정으로 메일을 보내려면 아래 값을 보내는 메일 서버(SMTP)로 등록합니다. 받는 메일 서버(IMAP·POP)와 같은 서버·같은 계정을 씁니다.", + "smtpHost": "보내는 메일 서버(SMTP 호스트)", + "smtpPortSmtps": "포트 (SSL/TLS, 권장)", + "smtpPortStarttls": "포트 (STARTTLS)", + "smtpFrom": "보내는 사람 주소", + "smtpAuth": "인증 방식", + "smtpAuthValue": "일반 비밀번호(PLAIN/LOGIN). 보안 연결(SSL/TLS 또는 STARTTLS)을 반드시 켭니다.", + "smtpSteps": "설정 순서", + "smtpStep1": "메일 프로그램의 계정 설정에서 보내는 메일 서버(SMTP)를 엽니다.", + "smtpStep2": "서버 이름에 위 SMTP 호스트를 입력하고, 포트와 보안 연결을 465 + SSL/TLS(권장) 또는 587 + STARTTLS 로 맞춥니다.", + "smtpStep3": "인증을 켜고(일반 비밀번호), 사용자 이름과 비밀번호는 이 사이트에 로그인할 때와 같게 입력합니다. 받는 메일 서버와 같은 계정이므로 \"받는 서버와 같은 설정 사용\"을 켜 두어도 됩니다.", + "smtpStep4": "보내는 사람 주소는 위의 내 메일 주소로 둡니다. 다른 주소를 보내는 사람으로 쓰면 서버가 발송을 거부합니다.", + "smtpStep5": "테스트 메일을 보내 받는 사람에게 도착하는지, 이 사이트의 보낸 메일함에 저장되는지 확인합니다.", + "smtpNotes": "참고", + "smtpNote1": "이 사이트의 비밀번호를 바꾸면 메일 프로그램의 받는 서버·보내는 서버 비밀번호도 함께 바꿉니다.", + "smtpNote2": "보낸 메일은 이 사이트의 보낸 메일함에 자동으로 저장됩니다. 메일 프로그램이 보낸 메일함에 사본을 올려도 한 통으로 합쳐집니다.", + "smtpNote3": "한 통에 받는 사람(참조·숨은 참조 포함) 수와 첨부 크기는 관리자 설정을 따릅니다(설정 화면의 \"메일함 사용량·제한\" 참고). 메일 전체 크기는 30MB 까지입니다.", + "popSettings": "POP 설정 (메일 클라이언트로 받기)", + "popIntro": "받은 메일을 메일 클라이언트로 내려받는 설정입니다. 계정은 웹 메일 로그인 계정(아이디 또는 메일함 주소 + 비밀번호)과 같습니다.", + "popHost": "받는 메일 서버(POP3 호스트)", + "popPortTls": "포트 (SSL/TLS, 권장)", + "popUser": "사용자 이름", + "popUserValue": "로그인 ID 또는 메일함 주소(예: admin 또는 admin@{domain}). 웹 메일에 로그인하는 아이디·비밀번호와 같습니다.", + "popPasswordValue": "이 사이트에 로그인할 때 쓰는 비밀번호 그대로 (비밀번호를 바꾸면 바로 적용)", + "popTlsActive": "TLS 인증서 적용됨", + "popTlsMissing": "TLS 인증서 미설정 — 995 포트가 열리지 않았습니다", + "popPlainAllowed": "일반 접속(암호화 없음) 로그인 허용", + "popPlainRequired": "STARTTLS 이후에만 로그인 (일반 접속 거부)", + "popPortPlain": "포트 (일반 접속 / STARTTLS)", + "popNoteGmail": "Gmail 의 \"다른 계정에서 메일 확인(POP3)\" 은 110 포트에서 STARTTLS(STLS)를 쓰지 않습니다. \"보안 연결(SSL) 사용\" 을 켜고 포트 995 를 쓰는 것을 권장하며, 110 으로 쓰려면 관리자 설정의 \"일반 접속 로그인 허용\" 이 켜져 있어야 합니다(꺼져 있으면 Gmail 은 사용자 이름·비밀번호 오류로 보여 줍니다).", + "popDedupe": "중복 방지: 한 번 내려받은(RETR) 메일이나 클라이언트가 삭제(DELE)한 메일은 같은 계정에 다시 내려주지 않습니다. 서버 저장본은 지우지 않으며 웹에서 영구 삭제하기 전까지 계속 볼 수 있습니다. 필요하면 메일 상세에서 전달 기록을 초기화합니다.", + "popDeliveries": "POP 전달 기록", + "popDeliveriesValue": "{count}건 · 계정 {accounts}개 · 최근 {last}", + "receiveSettings": "수신 설정 (Resend 웹훅 + 1시간 동기화)", + "receiveIntro": "받은 메일은 두 경로로 저장합니다. Resend 웹훅(email.received)이 즉시 알리고, 1시간마다 Resend 수신 목록을 다시 확인해 놓친 메일을 보충합니다. 같은 메일은 한 번만 저장합니다.", + "webhookUrl": "웹훅 주소 (Resend → Webhooks → Add Endpoint)", + "webhookEvent": "구독 이벤트", + "webhookSecret": "서명 비밀키", + "webhookSecretOk": "설정됨 (RESEND_WEBHOOK_SECRET)", + "webhookSecretMissing": "미설정 — 웹훅 요청을 거부합니다. Resend 엔드포인트의 Signing Secret(whsec_…)을 서버 환경 파일에 넣고 재시작하세요.", + "webhookEvents": "웹훅 처리 내역", + "syncInterval": "동기화 주기", + "syncIntervalValue": "{minutes}분마다 · 시작 10초 후 1회", + "syncRuns": "최근 동기화", + "syncNow": "지금 동기화", + "retention": "보관 기간", + "retentionValue": "제한 없음. 메일은 원본·첨부와 함께 휴지통에서 영구 삭제할 때까지 보관합니다. 영구 삭제한 메일은 동기화가 다시 가져오지 않습니다.", + "storage": "저장 현황", + "storageValue": "받은 메일 {total}건 · 원본 그대로 {raw}건 · 재구성 {built}건 · 웹훅 {webhook}건 · 동기화 {sync}건", + "status_ok": "완료", + "status_partial": "일부 실패", + "status_failed": "실패", + "status_running": "진행 중", + "status_stored": "저장", + "status_skipped": "건너뜀(이미 있음)", + "status_ignored": "무시", + "status_duplicate": "중복", + "minutes": "분", + "yes": "예", + "no": "아니요", + "imapSettings": "IMAP 설정 (메일 클라이언트와 동기화)", + "imapIntro": "IMAP 은 메일을 내려받아 없애는 POP 과 달리 서버의 메일함을 그대로 보여 주고, 읽음·삭제·이동 상태를 여러 기기와 웹 메일함이 함께 공유합니다. 휴대폰과 PC 에서 같은 메일함을 쓰려면 IMAP 을 권장합니다.", + "imapHost": "받는 메일 서버(IMAP 호스트)", + "imapPortTls": "포트 (SSL/TLS, 권장)", + "imapMailboxes": "메일함", + "imapMailboxesValue": "INBOX(받은 메일함) · Sent(보낸 메일함) · Drafts(초안) · Trash(휴지통). 웹 메일함의 4개 폴더와 같은 저장소이며 새 폴더는 만들 수 없습니다.", + "imapSteps": "설정 순서", + "imapStep1": "메일 클라이언트에서 계정 추가 → 수동 설정(IMAP)을 고릅니다. 이메일 주소는 자기 메일함 주소(예: admin@{domain})입니다.", + "imapStep2": "받는 서버: 위 IMAP 호스트, 포트 993, 보안 SSL/TLS. 사용자 이름은 로그인 ID 또는 메일함 주소, 비밀번호는 웹 메일 로그인 비밀번호입니다.", + "imapStep3": "보내는 서버: 아래 SMTP 설정의 값(포트 465 SSL/TLS 또는 587 STARTTLS)을 입력하고, 받는 서버와 같은 사용자 이름·비밀번호를 씁니다.", + "imapStep4": "클라이언트가 보낸 메일 복사본을 Sent 에, 작성 중인 메일을 Drafts 에, 삭제한 메일을 Trash 에 저장하도록 폴더를 지정합니다(대부분 자동 인식).", + "imapStep5": "동기화 후 받은 메일함에서 메일을 읽으면 웹 메일함에도 읽음으로 표시되고, 웹에서 휴지통으로 옮긴 메일은 클라이언트에서도 Trash 로 이동합니다.", + "imapNotes": "참고", + "imapNote1": "클라이언트에서 삭제(Trash 로 이동) → 휴지통에서 다시 삭제하면 서버에서 영구 삭제되며 같은 메일을 다시 가져오지 않습니다.", + "imapNote2": "서버의 메일은 기한 없이 보관됩니다. 클라이언트에서 지운 메일은 휴지통으로 옮겨지고, 휴지통에서 지우면 웹 메일함에서도 영구 삭제됩니다.", + "imapNote3": "POP 와 IMAP 을 같은 계정에서 함께 쓸 수 있습니다. POP 전달 기록은 IMAP 에 영향을 주지 않습니다.", + "imapTlsActive": "TLS 인증서 적용됨", + "imapTlsMissing": "TLS 인증서 미설정 — 993 포트가 열리지 않았습니다", + "imapPortPlain": "포트 (일반 접속 / STARTTLS)", + "smtpTlsMissing": "TLS 인증서 미설정 — 465 포트가 열리지 않았고 587 은 STARTTLS 이후에만 로그인됩니다", + "appName": "아리학당 메일", + "brand": "Ari Hakdang Mail", + "searchPlaceholder": "메일 검색 (보낸 사람, 제목, 본문)", + "searchChip": "검색: \"{q}\"", + "searchClear": "검색 해제", + "folder_inbox": "받은 메일함", + "folder_starred": "중요 표시", + "folder_sent": "보낸 메일함", + "folder_drafts": "임시 보관함", + "folder_trash": "휴지통", + "filters": "보기", + "filterUnread": "읽지 않음만", + "filterAttachments": "첨부 있음", + "filterAll": "전체", + "toolsSection": "도구", + "nav_settings": "설정", + "nav_admin": "계정 관리", + "nav_adminPage": "관리자 페이지", + "nav_status": "서버 상태", + "nav_mailConfig": "메일 서비스 설정", + "mailConfigIntro": "수·발신 방식, Resend 키, SMTP 중계 서버, 내장 Haraka, 사이트 정보는 설정 파일(config.json)에 있고 전용 화면에서 재시작 없이 바꿉니다.", + "mailConfigOpen": "메일 서비스 설정 열기", + "statusMailSection": "메일 서비스 (1.0.0)", + "statusInbound": "수신 방식", + "statusOutbound": "발신 방식", + "statusOutboundState": "발신 서비스 연결", + "statusHaraka": "내장 Haraka", + "statusConfigFile": "설정 파일", + "mode_resend": "Resend", + "mode_haraka": "내장 Haraka", + "mode_smtp": "외부 SMTP 중계", + "harakaOff": "사용 안 함", + "harakaNotInstalled": "미설치", + "harakaStopped": "서비스 꺼짐", + "harakaRunning": "실행 중", + "harakaWaiting": "시작 대기", + "openMailbox": "메일함 열기", + "nav_mail": "메일", + "mailboxSelect": "메일함 선택", + "usageLabel": "저장 사용량", + "retentionShort": "보관 기간 제한 없음", + "selectAll": "전체 선택", + "selected": "{count}개 선택", + "actionDelete": "삭제", + "actionRestore": "복원", + "actionDeleteForever": "영구 삭제", + "actionMarkRead": "읽음", + "actionMarkUnread": "읽지 않음", + "actionStar": "중요", + "actionUnstar": "중요 해제", + "actionMove": "이동", + "moveTo": "이동 위치", + "emptyFolder": "이 폴더에 메일이 없습니다.", + "noSelection": "왼쪽 목록에서 메일을 선택하면 여기에 내용이 표시됩니다.", + "loadingMail": "메일을 불러오는 중…", + "today": "오늘", + "yesterday": "어제", + "me": "나", + "attachmentsCount": "첨부 {count}개", + "downloadAll": "모두 내려받기", + "replyPrefix": "Re:", + "forwardPrefix": "Fwd:", + "forwardedHeader": "---------- 전달된 메일 ----------", + "quoteHeader": "{date}, {from} 작성:", + "composeTitle": "새 메일", + "replyTitle": "답장", + "forwardTitle": "전달", + "editDraft": "임시 보관 메일 편집", + "bcc": "숨은 참조", + "bccFromMailClient": "메일 프로그램에서 저장한 초안의 숨은 참조입니다. 발송할 때 함께 보냅니다(웹에서는 고칠 수 없음).", + "showCc": "참조/숨은참조", + "fromLabel": "보내는 사람", + "send": "보내기", + "saveDraft": "임시 저장", + "discard": "삭제", + "closeCompose": "닫기", + "attachFiles": "파일 첨부", + "insertInlineImage": "본문에 이미지 넣기", + "sending": "보내는 중…", + "draftAutosaved": "임시 저장됨 {time}", + "tb_undo": "실행 취소", + "tb_redo": "다시 실행", + "tb_font": "글꼴", + "tb_size": "크기", + "tb_bold": "굵게", + "tb_italic": "기울임", + "tb_underline": "밑줄", + "tb_strike": "취소선", + "tb_color": "글자색", + "tb_highlight": "강조색", + "tb_moreColors": "더 많은 색상", + "tb_noHighlight": "강조 없음", + "color_black": "검정", + "color_red": "빨강", + "color_orange": "주황", + "color_yellow": "노랑", + "color_green": "초록", + "color_blue": "파랑", + "color_purple": "보라", + "color_gray": "회색", + "color_white": "흰색", + "tb_alignLeft": "왼쪽 정렬", + "tb_alignCenter": "가운데 정렬", + "tb_alignRight": "오른쪽 정렬", + "tb_ol": "번호 목록", + "tb_ul": "글머리 목록", + "tb_outdent": "내어쓰기", + "tb_indent": "들여쓰기", + "tb_link": "링크", + "tb_unlink": "링크 제거", + "tb_quote": "인용", + "tb_hr": "구분선", + "tb_image": "이미지", + "tb_clear": "서식 지우기", + "tb_html": "HTML 편집", + "size_small": "작게", + "size_normal": "보통", + "size_large": "크게", + "size_huge": "매우 크게", + "confirmDiscard": "작성 중인 메일을 삭제할까요?", + "confirmDeleteForever": "선택한 메일을 영구 삭제할까요? 되돌릴 수 없습니다.", + "confirmEmptyTrash": "휴지통을 비울까요?", + "emptyTrash": "휴지통 비우기", + "unreadBadge": "읽지 않음 {count}", + "popTag": "POP", + "flagged": "중요", + "answered": "답장함", + "adminUsers": "계정 관리", + "adminUsersIntro": "관리자 페이지입니다. 여기서 만든 계정은 웹 메일함·IMAP·POP·SMTP 에 자기 메일함 주소와 비밀번호로 접속합니다. 메일함 주소는 로그인 ID@{domain} 으로 자동 지정되며, 계정마다 POP·IMAP 접속 허용을 켜고 끌 수 있습니다.", + "adminUsersIntroWww": "www 관리자 계정 자체는 www 사이트에서 관리합니다.", + "addUser": "계정 추가", + "editUser": "계정 수정", + "userLogin": "로그인 ID", + "userName": "이름", + "userMailbox": "메일함 주소", + "userMailboxAuto": "메일함 주소는 로그인 ID@도메인으로 자동 지정됩니다(따로 입력하지 않음).", + "userServices": "메일 프로그램", + "editMyServices": "관리자 계정 — 메일 프로그램 접속", + "wwwAdminServicesHelp": "www 관리자 계정(admin)의 이름·비밀번호는 www 사이트에서 관리합니다. 여기서는 이 계정의 POP·IMAP 접속 허용만 바꿉니다.", + "userRole": "역할", + "userStatus": "상태", + "userPassword": "비밀번호", + "userPasswordEdit": "새 비밀번호 (비우면 유지)", + "userPasswordConfirm": "새 비밀번호 확인", + "userCurrentPassword": "현재 비밀번호", + "currentPasswordHelp": "자기 계정의 비밀번호를 바꿀 때는 본인 확인을 위해 현재 비밀번호를 함께 입력합니다. 새 비밀번호는 두 번 같게 입력하세요.", + "showPassword": "비밀번호 보기", + "hidePassword": "비밀번호 숨기기", + "password_mismatch": "새 비밀번호 두 입력이 서로 다릅니다.", + "current_password_invalid": "현재 비밀번호가 맞지 않습니다.", + "current_password_required": "현재 비밀번호를 입력하세요.", + "passwordChangedRelogin": "비밀번호를 바꿨습니다. 새 비밀번호로 다시 로그인하세요.", + "userCreatedAt": "만든 날짜", + "userLastLogin": "최근 로그인", + "role_admin": "관리자", + "role_user": "사용자", + "status_active": "사용", + "status_suspended": "정지", + "deleteUser": "계정 삭제", + "confirmDeleteUser": "이 계정을 삭제할까요? 메일함의 메일은 남습니다.", + "userSaved": "계정을 저장했습니다.", + "invalid_login": "로그인 ID 는 영문 소문자·숫자·점·밑줄·하이픈 2~64자여야 합니다(www 연동 사이트에서는 admin 을 쓸 수 없음).", + "invalid_mailbox": "메일함 주소는 이 도메인의 주소여야 합니다.", + "weak_password": "비밀번호가 정책에 맞지 않습니다.", + "weakPasswordDetail": "비밀번호가 정책에 맞지 않습니다: {rules}", + "passwordRule": "비밀번호는 {rules}이어야 합니다.", + "passwordRuleLength": "{n}자 이상", + "passwordRuleMixedCase": "영문 대문자와 소문자 모두 포함", + "passwordRuleDigit": "숫자 포함", + "passwordRuleSpecial": "특수문자(!@#$ 등) 포함", + "policySection": "비밀번호 정책", + "policyIntro": "새로 정하는 비밀번호에 적용하는 규칙입니다. 이미 있는 계정의 비밀번호에는 소급하지 않으며, 계정 추가·수정과 명령줄 도구 모두 같은 규칙을 씁니다.", + "policyMinLength": "최소 글자 수", + "policyMinLengthHelp": "6자 이상 64자 이하에서 정합니다(6 미만으로는 내릴 수 없음).", + "policyMixedCase": "영문 대문자와 소문자를 모두 포함해야 함", + "policyDigit": "숫자를 포함해야 함", + "policySpecial": "특수문자를 포함해야 함", + "policySaved": "비밀번호 정책을 저장했습니다.", + "policy_invalid": "최소 글자 수는 6 이상 64 이하의 정수여야 합니다.", + "initialPasswordWarn": "초기 비밀번호(adminpasswd)를 쓰고 있습니다. 계정 관리에서 관리자 계정을 열어 비밀번호를 바꾸세요.", + "initialPasswordShort": "초기 비밀번호 사용 중", + "login_taken": "이미 사용 중인 로그인 ID 입니다.", + "cannot_delete_self": "자기 계정은 삭제할 수 없습니다.", + "forbidden": "관리자만 사용할 수 있는 기능입니다.", + "userHelpMailbox": "메일함 주소는 로그인 ID 에 따라 자동으로 정해집니다(예: info → info@{domain}). 수신 메일은 받는 사람 주소와 같은 메일함으로 들어옵니다. 0.8.4 이전에 만든 계정은 등록된 메일함 주소를 그대로 유지합니다.", + "wwwAdminNote": "www 관리자 (별칭 admin)", + "menu": "메뉴", + "back": "목록으로", + "refreshed": "새로고침했습니다.", + "openOriginal": "원본 보기", + "details": "상세 정보", + "hideDetails": "상세 정보 숨기기", + "toMe": "나에게", + "showRemote": "외부 이미지 표시", + "hideRemote": "외부 이미지 숨기기", + "remoteBlocked": "외부 이미지는 개인정보 보호를 위해 차단되었습니다.", + "linkText": "링크 텍스트", + "imageUploadFailed": "이미지를 올리지 못했습니다.", + "htmlMode": "HTML 원본 편집 중 — 다시 누르면 편집기로 돌아갑니다.", + "composeFromHelp": "보내는 주소는 로그인한 계정의 메일함 주소입니다.", + "prefsSection": "화면 기본값", + "prefRemoteImages": "외부 이미지 기본 표시", + "prefRemoteImagesHelp": "켜면 메일을 열 때 외부 이미지를 바로 불러옵니다. 끄면 차단하고, 본문 오른쪽 위 작은 버튼으로 메일마다 표시/숨기기를 바꿀 수 있습니다. 외부 이미지는 발신자에게 열람 사실을 알릴 수 있습니다.", + "prefSaved": "기본값을 저장했습니다.", + "servicesSection": "메일 프로그램 접속", + "servicesIntro": "이 계정으로 메일 프로그램(Outlook, Thunderbird, 스마트폰 메일 앱 등)이 받는 서버에 접속하는 것을 허용할지 정합니다. 끄면 그 방식의 로그인이 거부되고, 웹 메일함과 보내는 서버(SMTP)에는 영향이 없습니다.", + "prefPop": "POP3 접속 허용", + "prefImap": "IMAP 접속 허용", + "servicesHelp": "둘 다 끄면 메일 프로그램으로 받을 수 없고 웹 메일함에서만 볼 수 있습니다. 바꾸면 바로 적용되며 이미 연결된 메일 프로그램은 다음 접속부터 거부됩니다.", + "statusIntro": "{host} · 버전 {version}. 발송 서비스 연동과 저장 상태를 보여 줍니다.", + "statusServices": "서비스 포트", + "statusTls": "TLS 인증서", + "statusTlsMissing": "미설정 — 993/995/465 포트가 열리지 않았습니다(587 은 STARTTLS 이후에만 인증)", + "statusPlainLogin": "일반 접속 로그인 (110 / 143)", + "statusMailboxes": "메일함별 저장", + "showRemoteShort": "이미지 표시", + "hideRemoteShort": "이미지 숨기기", + "dropHint": "여기에 놓으면 첨부됩니다", + "dropTip": "파일을 이 창에 끌어 놓아 첨부할 수 있습니다.", + "imageChoiceTitle": "이미지 {count}개를 어떻게 넣을까요?", + "choiceInline": "본문에 표시", + "choiceAttach": "첨부 파일로", + "choiceCancel": "취소", + "inlineAdded": "본문에 이미지를 넣었습니다.", + "attached": "첨부했습니다.", + "imgSize": "이미지 크기", + "imgFit": "창에 맞춤", + "imgOriginal": "원본 크기", + "imgWidthPx": "너비(px)", + "imgResizeTip": "이미지를 클릭하면 크기를 바꿀 수 있습니다. 모서리 손잡이를 끌어도 됩니다.", + "forwardSection": "메일 전달", + "forwardIntro": "이 메일함으로 받는 메일을 지정한 주소로 전부 전달합니다. 원본은 받은 메일함에 그대로 남습니다.", + "prefForward": "받은 메일을 아래 주소로 전달", + "forwardTo": "전달 주소", + "forwardHelp": "주소는 쉼표로 구분해 5개까지 넣을 수 있습니다. 전달 메일의 보내는 사람은 이 메일함 주소이고, 전달받은 쪽에서 답장하면 원래 보낸 사람에게 갑니다. 첨부와 본문 이미지도 함께 전달하며(첨부 합계 25MB 까지), 전달받은 메일이 되돌아와도 다시 전달하지 않습니다. 전달 주소에서 보낸 메일과 72시간이 지난 뒤 뒤늦게 가져온 메일은 전달하지 않습니다.", + "forward_invalid": "전달 주소를 확인해 주세요. 올바른 이메일 주소를 쉼표로 구분해 5개까지 넣을 수 있고, 자기 메일함 주소는 넣을 수 없습니다.", + "autoReplySection": "자동 응답", + "autoReplyIntro": "받은 메일에 정해 둔 메시지로 자동 회신합니다. 휴가·부재중 안내나 접수 확인에 씁니다.", + "prefAutoReply": "자동 응답 사용", + "autoReplySubject": "응답 제목", + "autoReplySubjectHelp": "비우면 \"Re: 원래 제목\" 으로 보냅니다", + "autoReplyMessage": "응답 메시지", + "autoReplyMessageHelp": "예: 휴가 중입니다. 10월 15일에 돌아와 답장드리겠습니다.", + "autoReplyPeriod": "기간", + "autoReplyNoLimit": "기한 제한 없음 (끌 때까지 계속)", + "autoReplyRange": "기간 지정 (날짜·시간)", + "autoReplyStart": "시작", + "autoReplyEnd": "종료", + "autoReplyPeriodHelp": "기간 지정을 고르면 시작·종료를 날짜와 시간으로 정합니다. 시작을 비우면 저장하는 즉시 시작하고, 종료 시각이 지나면 자동으로 멈춥니다. 기한 제한 없음이면 체크를 풀고 저장할 때까지 계속 보냅니다.", + "autoReplyHelp": "같은 상대에게는 24시간에 한 번만 보냅니다. 자동 발송 메일(다른 자동 응답·알림·메일링 리스트·no-reply 발신)과 72시간이 지난 뒤 뒤늦게 가져온 메일에는 응답하지 않습니다. 보낸 자동 응답은 보낸 메일함에 남고 원래 메일에는 \"답장함\" 표시가 붙습니다.", + "autoreply_message": "자동 응답 메시지를 입력해 주세요.", + "autoreply_period": "자동 응답 기간을 확인해 주세요. 기간을 지정하면 종료 시각이 필요하고, 종료는 시작보다 뒤여야 합니다.", + "sidebarForward": "메일 전달 중", + "sidebarAutoReply": "자동 응답 작동 중", + "autoActions": "자동 처리", + "auto_forward_sent": "전달됨 → {target}", + "auto_forward_failed": "전달 실패 → {target} ({detail})", + "auto_forward_skipped": "전달하지 않음 — {detail}", + "auto_forward_pending": "전달 처리 중", + "auto_reply_sent": "자동 응답 보냄 → {target}", + "auto_reply_failed": "자동 응답 실패 → {target} ({detail})", + "auto_reply_skipped": "자동 응답 안 함 — {detail}", + "auto_reply_pending": "자동 응답 처리 중", + "auto_pending": "처리 중", + "reason_loop": "전달받은 메일(되풀이 방지)", + "reason_self": "전달 주소에서 보낸 메일", + "reason_stale": "72시간 지난 메일", + "reason_recent": "같은 상대에게 24시간 안에 이미 응답함", + "reason_claimed": "이미 처리됨", + "reason_no_address": "회신할 주소 없음", + "reason_no_target": "전달 주소 없음", + "reason_auto-submitted": "자동 발송 메일(Auto-Submitted)", + "reason_bulk": "대량·자동 발송 메일", + "reason_list": "메일링 리스트 메일", + "reason_suppressed": "자동 응답 거부 요청 메일", + "reason_noreply": "회신받지 않는 발신 주소(no-reply)", + "forwardOnNote": "현재 메일은 다음 주소로 전달되고 있습니다", + "autoReplyOnNote": "메일이 수신되면 자동으로 아래 메일을 발신자에게 회신합니다.", + "autoReplyOutsideNote": "지정한 기간({start} ~ {end}) 밖이라 지금은 회신을 보내지 않습니다. 기간이 되면 자동으로 시작합니다.", + "autoReplyNow": "저장 시점", + "attachment_too_largeLimit": "첨부 한 개는 {mb}(MIME 변환 기준, 파일 크기의 약 1.37배)까지 올릴 수 있습니다. 한 메일에 10개, 합계 20MB 까지입니다.", + "signupLink": "계정이 없으신가요? 회원 가입", + "signupTitle": "회원 가입", + "signupIntro": "{domain} 메일 계정을 만듭니다. 로그인 ID 가 곧 메일 주소(ID@{domain})가 됩니다.", + "signupHelp": "로그인 ID 는 영문 소문자·숫자·점·밑줄·하이픈 2~64자입니다. 비밀번호는 두 번 같게 입력하고, 눈 단추로 입력한 글자를 확인할 수 있습니다.", + "signupSubmit": "가입", + "signupDone": "가입이 끝났습니다. 만든 아이디와 비밀번호로 로그인하세요.", + "signupPending": "가입 신청을 받았습니다. 관리자가 승인하면 로그인할 수 있습니다.", + "signup_disabled": "지금은 회원 가입을 받지 않습니다. 관리자에게 계정을 요청하세요.", + "pending_approval": "가입 승인을 기다리는 계정입니다. 관리자가 승인하면 로그인할 수 있습니다.", + "account_limit_reached": "더 이상 계정을 만들 수 없습니다(시스템의 계정 수 제한에 이름).", + "account_limit_reachedLimit": "더 이상 계정을 만들 수 없습니다. 이 메일 시스템은 계정을 최대 {limit}개까지 둘 수 있습니다.", + "passwordSection": "내 비밀번호 변경", + "passwordIntro": "웹 메일·IMAP·POP·SMTP 에 함께 쓰는 비밀번호를 바꿉니다. 본인 확인을 위해 현재 비밀번호를 먼저 입력하고 새 비밀번호를 두 번 같게 입력합니다.", + "newPassword": "새 비밀번호", + "changePassword": "비밀번호 변경", + "passwordChangeHelp": "바꾸면 이 계정의 웹 세션이 모두 끝나므로 새 비밀번호로 다시 로그인합니다. 메일 프로그램의 비밀번호도 함께 바꾸세요.", + "passwordWwwManaged": "www 관리자 계정의 비밀번호는 www 사이트에서 관리합니다.", + "www_managed": "www 관리자 계정의 비밀번호는 www 사이트에서 바꿉니다.", + "self_password_settings": "자기 계정의 비밀번호는 웹 메일의 도구 → 설정에서 현재 비밀번호를 확인한 뒤 바꿉니다.", + "selfPasswordNote": "내 비밀번호는 웹 메일의 도구 → 설정 → 내 비밀번호 변경에서 바꿉니다.", + "quotaSection": "메일함 사용량·제한", + "quotaUnlimited": "용량 제한 없음", + "quotaPercent": "{percent}% 사용", + "quotaLimit": "최대 크기", + "quotaNow": "현재 적용 {limit}", + "limitAttachment": "첨부 한 개 최대 크기", + "limitAttachmentValue": "{mb} (MIME 변환 기준)", + "limitRecipients": "한 메일의 받는 사람 수", + "limitRecipientsValue": "최대 {n}명 (받는 사람·참조·숨은 참조 합계)", + "mimeNote": "크기는 메일로 저장·전송될 때의 MIME 기준입니다. 첨부 파일은 base64 로 바뀌어 원래 크기의 약 1.37배가 됩니다.", + "serviceOffByAdmin": "관리자가 서비스를 껐습니다", + "serviceOffByAdminHelp": "관리자가 끈 서비스는 계정 설정과 무관하게 접속할 수 없습니다.", + "too_many_recipients": "받는 사람이 너무 많습니다.", + "too_many_recipientsLimit": "받는 사람(참조·숨은 참조 포함)은 한 메일에 최대 {limit}명입니다.", + "quota_exceeded": "메일함이 가득 찼습니다. 필요 없는 메일을 영구 삭제해 공간을 비우세요.", + "quota_exceededLimit": "메일함이 가득 찼습니다(최대 {mb}). 휴지통에서 영구 삭제해 공간을 비우세요.", + "storage_full": "메일 시스템의 저장 공간이 가득 찼습니다. 관리자에게 알려 주세요.", + "storage_fullLimit": "메일 시스템의 저장 공간(최대 {mb})이 가득 찼습니다. 관리자에게 알려 주세요.", + "monthly_limit_exceeded": "이달의 메일 수발신 한도에 이르러 지금은 보낼 수 없습니다.", + "monthly_limit_exceededLimit": "이달의 메일 수발신 한도({limit}통)에 이르러 지금은 보낼 수 없습니다. 다음 달 1일부터 다시 보낼 수 있습니다.", + "limit_invalid": "제한 값은 0 이상의 숫자여야 합니다(0 = 제한 없음).", + "limit_exceeds_system": "시스템 설정의 제한을 넘는 값은 저장할 수 없습니다.", + "limit_exceeds_systemLimit": "{field} 값이 시스템 제한({max})을 넘습니다. 그 이내로 입력하세요.", + "limit_mailboxBytes": "메일함 최대 크기", + "limit_attachmentBytes": "첨부 최대 크기", + "limit_recipients": "받는 사람 수", + "limit_accounts": "허용 계정 수", + "limit_totalBytes": "전체 저장 용량", + "limit_monthlyMessages": "월간 수발신 통수", + "reason_limit": "월간 수발신 한도에 이름", + "status_pending": "승인 대기", + "pendingNotice": "회원 가입으로 만들어진 계정입니다. 승인하면 바로 쓸 수 있고, 거절하면 계정이 삭제됩니다.", + "approveUser": "승인", + "rejectUser": "거절", + "confirmRejectUser": "이 가입 신청을 거절하고 계정을 삭제할까요?", + "userApproved": "계정을 승인했습니다.", + "userRejected": "가입 신청을 거절했습니다.", + "userQuotaMb": "메일함 최대 크기 (MB, 0 = 기본값)", + "systemCap": "시스템 제한: {max} 이내", + "systemNoCap": "시스템 제한 없음", + "noLimit": "제한 없음", + "countUnit": "{n}개", + "signupSection": "회원 가입", + "signupAdminIntro": "사용자가 로그인 화면에서 직접 계정을 만들 수 있게 할지 정합니다. 끄면 관리자가 만든 계정만 메일을 쓸 수 있습니다.", + "signupEnable": "로그인 화면에서 회원 가입 받기", + "signupApprovalLegend": "가입한 계정의 사용 시점", + "signupImmediate": "즉시 사용", + "signupApprove": "관리자 승인 후 사용", + "signupAdminHelp": "승인 대기 계정은 계정 목록에 \"승인 대기\" 로 보이며, 계정을 열어 승인하거나 거절합니다. 가입 계정은 항상 일반 사용자 역할이고 비밀번호 정책·계정 수 제한을 똑같이 따릅니다.", + "limitsSection": "메일 제한", + "limitsIntro": "계정별 기본 메일함 크기, 첨부 한 개의 최대 크기, 한 메일의 받는 사람 수를 정합니다. 시스템 설정(설치 시 지정)에 제한이 있으면 그 값 이내로만 저장됩니다.", + "limitMailboxMb": "계정별 메일함 최대 크기 (MB)", + "limitAttachmentMb": "첨부 한 개 최대 크기 (MB)", + "limitRecipientsN": "한 메일의 받는 사람 수", + "limitsHelp": "0 또는 빈 값은 시스템 값을 그대로 따릅니다. 메일함 크기는 계정마다 따로 정할 수도 있습니다(계정 수정 창). 받는 사람 수는 내장 상한 50명을 넘을 수 없습니다.", + "systemSection": "시스템 설정 (설치 시 지정)", + "systemIntro": "환경 파일(gateway.env)의 LIMIT_* 값입니다. 메일 시스템 전체의 상위 제한이라 여기서는 바꿀 수 없습니다. 0 은 제한 없음.", + "servicesAdminSection": "POP / IMAP 서비스", + "servicesAdminIntro": "메일 프로그램 접속 서비스를 통째로 켜고 끕니다. 끄면 모든 계정의 POP(또는 IMAP) 로그인이 거부됩니다.", + "servicePop": "POP3 서비스 사용", + "serviceImap": "IMAP 서비스 사용", + "servicePlainLogin": "일반 접속(암호화 없음) 로그인 허용 — POP3 110 / IMAP 143", + "servicePlainLoginHelp": "켜면 110/143 포트에서 STARTTLS 없이 보낸 비밀번호로도 로그인할 수 있습니다. Gmail 의 \"다른 계정에서 메일 확인\" 처럼 STARTTLS 를 지원하지 않는 클라이언트에 필요합니다. 비밀번호가 암호화되지 않고 지나가므로, 모든 사용자가 995/993(SSL) 또는 STARTTLS 를 쓸 수 있다면 끄는 것이 안전합니다. 995/993 과 웹 메일에는 영향이 없습니다.", + "servicesAdminHelp": "웹 메일과 SMTP(보내는 서버)에는 영향이 없습니다. 이미 연결된 메일 프로그램은 다음 접속부터 거부됩니다.", + "configSaved": "설정을 저장했습니다.", + "statsSection": "메일 현황", + "statsIntro": "기간은 {tz} 시간대 기준입니다. 일일은 오늘 0시부터, 주간은 이번 주 월요일부터, 월간은 이달 1일부터 말일까지입니다.", + "statPeriod": "기간", + "statDay": "일일", + "statWeek": "주간", + "statMonth": "월간", + "statSent": "발신", + "statReceived": "수신", + "statTotal": "수발신 합계", + "statLimits": "시스템 제한 대비 사용량", + "statAccounts": "메일 계정", + "statTotalUsage": "총 메일함 사용량", + "statMonthly": "이달 수발신 통수", + "statMailboxes": "사용자별 메일함 사용량", + "statMessages": "메일 수", + "usageOfMax": "{used} 사용 중 / 최대 {max} ({percent}%)", + "usageNoLimit": "{used} 사용 중 · 제한 없음", + "serviceOn": "사용", + "serviceOff": "중지", + "reason_forwarded": "자동 전달된 메일(되풀이 방지)", + "largeAttach": "대용량 첨부", + "largeAttachHelp": "파일을 메일에 붙이지 않고 서버에 두고 다운로드 링크를 보냅니다(파일당 {mb}까지).", + "largeAttached": "대용량 파일을 올렸습니다. 발송 조건(보관 일수·다운로드 횟수)을 확인하세요.", + "largeAutoRouted": "일반 첨부 한도를 넘는 파일은 대용량 첨부로 올렸습니다.", + "largeTerms": "대용량 첨부 발송 조건", + "largeDays": "보관", + "largeDownloads": "다운로드", + "largeTermsHelp": "일수 1~{days}, 횟수 1~{downloads}. 기한이 지나거나 횟수를 다 쓰면 파일이 서버에서 삭제됩니다(제한 없는 발송은 할 수 없음).", + "largeFileHeading": "대용량 첨부파일", + "largeFileTerms": "{until} 까지 · {downloads}회", + "largeFileNote": "링크는 기한이 지나거나 다운로드 횟수를 다 쓰면 만료되어 파일을 받을 수 없습니다.", + "largeStatus": "남은 {remaining}/{max}회 · {until} 까지", + "largeExpired": "만료", + "largeRevoke": "링크 끊기(파일 삭제)", + "confirmRevoke": "이 파일의 다운로드 링크를 끊고 서버에서 삭제할까요? 받는 사람은 더 이상 받을 수 없습니다.", + "largeLimitsValue": "파일당 {mb} · 보관 최대 {days}일 · 다운로드 최대 {downloads}회", + "large_files_disabled": "대용량 첨부파일 기능이 꺼져 있습니다. 관리자에게 문의하세요.", + "too_many_files": "대용량 첨부는 한 메일에 10개까지입니다.", + "file_too_large": "대용량 파일이 한도를 넘습니다.", + "file_too_largeLimit": "대용량 파일은 한 개에 {mb}까지 올릴 수 있습니다.", + "large_file_terms": "대용량 첨부의 발송 조건(보관 일수·다운로드 횟수)을 확인하세요.", + "large_file_termsLimit": "대용량 첨부의 {field}은(는) 1 이상 {limit} 이하여야 합니다.", + "limit_days": "보관 일수", + "limit_downloads": "다운로드 횟수", + "limit_maxBytes": "최대 크기", + "limit_maxDays": "보관 최대 기간", + "limit_maxDownloads": "다운로드 최대 횟수", + "largeSection": "대용량 첨부파일", + "largeAdminIntro": "큰 파일을 메일에 첨부하지 않고 서버에 보관한 뒤 다운로드 링크를 보내는 기능입니다. 보낸 사람이 정한 보관 기간이 지나거나 다운로드 횟수를 다 쓰면 서버에서 지워집니다.", + "largeEnable": "대용량 첨부파일 기능 사용", + "largeMaxMb": "파일 한 개 최대 크기 (MB)", + "largeMaxDays": "보관 최대 기간 (일)", + "largeMaxDownloads": "다운로드 최대 횟수", + "largeAdminHelp": "세 값은 비울 수 없고(무제한 불가) 시스템 설정 이내여야 합니다. 기본값은 시스템 설정의 50% 입니다. 사용자는 발송할 때 이 값 이내에서 보관 일수와 횟수를 정합니다.", + "largeUsage": "현재 서버에 보관 중: {count}개, {size}", + "systemCapStrict": "시스템 상한 {max} (반드시 그 이내의 값)", + "daysUnit": "{n}일", + "timesUnit": "{n}회", + "dl_title": "대용량 첨부파일 다운로드", + "dl_file": "파일", + "dl_size": "크기", + "dl_remaining": "남은 횟수", + "dl_until": "기한", + "dl_button": "다운로드", + "dl_note": "다운로드 단추를 누를 때마다 남은 횟수가 1 줄어듭니다. 기한이 지나거나 횟수를 다 쓰면 파일이 삭제됩니다.", + "dl_expiredTitle": "만료된 링크", + "dl_expired": "이 파일은 보관 기한이 지났거나 다운로드 횟수를 다 써서 더 이상 받을 수 없습니다. 보낸 사람에게 다시 요청하세요.", + "dl_missingTitle": "링크를 찾을 수 없습니다", + "dl_missing": "주소가 잘못됐거나 파일이 삭제되었습니다.", + "cannot_modify_self": "자기 계정의 역할을 내리거나 정지할 수 없습니다(다른 관리자 계정으로 하세요).", + "selfRoleNote": "자기 계정의 역할·상태는 바꿀 수 없습니다.", + "bccHelp": "숨은 참조 주소는 받는 사람·참조에게 보이지 않습니다.", + "cancelUpload": "업로드 취소", + "uploadFinishing": "서버 저장 중…", + "upload_too_large_proxy": "서버 앞단(nginx)이 요청 크기를 거부했습니다(HTTP 413). 관리자에게 프록시의 업로드 크기 제한을 확인해 달라고 하세요.", + "upload_failed": "파일을 올리지 못했습니다.", + "upload_network": "파일을 올리는 중 연결이 끊겼습니다. 다시 시도하세요.", + "upload_cancelled": "업로드를 취소했습니다.", + "upload_write_failed": "서버가 파일을 저장하지 못했습니다(디스크 오류). 관리자에게 알려 주세요.", + "domainMissingTitle": "메일 서비스가 존재하지 않습니다", + "domainMissing": "이 주소에는 등록된 메일 서비스가 없습니다.", + "domainDisabledTitle": "메일 서비스가 활성화되지 않았습니다", + "domainDisabled": "이 도메인의 메일 서비스는 지금 사용이 중지되어 있습니다.", + "domainAskAdmin": "서비스가 필요하면 관리자에게 이 도메인의 메일 서비스 등록(또는 활성화)을 요청하세요.", + "domain_not_found": "이 도메인에는 메일 서비스가 없습니다. 관리자에게 요청하세요.", + "domain_disabled": "이 도메인의 메일 서비스는 활성화되지 않았습니다. 관리자에게 요청하세요.", + "loginHelpWebmaker": "이 메일 서비스는 웹메이커(webmaker) 계정과 연동되어 있습니다. 웹메이커에 로그인할 때 쓰는 아이디와 비밀번호를 그대로 입력하세요(아이디 또는 아이디@{domain}). 웹메이커 관리자(admin)와 사이트 소유자는 관리자, 사이트에 추가한 사용자는 일반 계정입니다. 비밀번호는 웹메이커에서 바꿉니다.", + "webmakerAccount": "웹메이커 계정", + "kind_member": "사이트 사용자", + "kind_owner": "사이트 소유자", + "kind_admin": "웹메이커 관리자", + "kind_user": "웹메이커 회원", + "adminUsersIntroWebmaker": "웹메이커 계정 {n}개가 연동되어 있습니다(사이트: {site}). 이 계정들의 이름·비밀번호·역할은 웹메이커에서 관리하며, 여기서는 POP·IMAP 접속 허용과 메일함 크기만 바꿉니다.", + "webmakerNoSite": "도메인에 대응하는 사이트 없음 — 웹메이커의 모든 회원", + "webmakerReadFailed": "웹메이커 계정 파일을 읽지 못했습니다", + "editExternalUser": "웹메이커 계정 설정", + "webmakerAccountHelp": "{login} ({mailbox}) 은 웹메이커 계정입니다. 이름·비밀번호·역할은 웹메이커에서 관리하고, 여기서는 메일함 크기와 POP·IMAP 접속 허용만 바꿉니다.", + "passwordWebmakerManaged": "웹메이커 계정의 비밀번호는 웹메이커에서 바꿉니다. 바꾸면 웹 메일·IMAP·POP·SMTP 에도 바로 적용됩니다.", + "webmaker_managed": "웹메이커 계정은 웹메이커에서 관리합니다(여기서는 접속 허용·메일함 크기만).", + "webmaker_dir_unreadable": "웹메이커 데이터 디렉터리를 읽을 수 없습니다." +} diff --git a/modules/arimail/migrations/0001_init.sql b/modules/arimail/migrations/0001_init.sql new file mode 100644 index 0000000..b602f6f --- /dev/null +++ b/modules/arimail/migrations/0001_init.sql @@ -0,0 +1,81 @@ +-- 메일 게이트웨이 저장소. www 사이트의 DB와 분리하며 인증만 www DB(users)를 읽습니다. +CREATE TABLE messages ( + id TEXT PRIMARY KEY, + provider_id TEXT UNIQUE, + message_id TEXT, + direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')), + folder TEXT NOT NULL CHECK(folder IN ('inbox','sent','drafts','trash')), + previous_folder TEXT, + sender TEXT NOT NULL DEFAULT '', + recipients_json TEXT NOT NULL DEFAULT '[]', + cc_json TEXT NOT NULL DEFAULT '[]', + bcc_json TEXT NOT NULL DEFAULT '[]', + reply_to_json TEXT NOT NULL DEFAULT '[]', + subject TEXT NOT NULL DEFAULT '', + html TEXT, + text TEXT NOT NULL DEFAULT '', + original_html TEXT, + headers_json TEXT NOT NULL DEFAULT '{}', + source TEXT NOT NULL DEFAULT 'sync' CHECK(source IN ('webhook','sync','web')), + state TEXT NOT NULL DEFAULT 'received' CHECK(state IN ('draft','sending','sent','received','failed')), + send_key TEXT, + send_started_at TEXT, + read_at TEXT, + received_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + sent_at TEXT, + raw_status TEXT NOT NULL DEFAULT 'none' CHECK(raw_status IN ('none','stored','built','unavailable')), + raw_size INTEGER NOT NULL DEFAULT 0, + expires_at TEXT +); +CREATE INDEX messages_folder_date ON messages(folder, received_at DESC); +CREATE INDEX messages_expires ON messages(expires_at); +CREATE INDEX messages_pop ON messages(direction, folder, received_at); +-- 원본(RFC 5322 .eml)은 그대로 보관합니다. POP 은 이 바이트를 그대로 전달합니다. +CREATE TABLE message_raw ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + eml BLOB NOT NULL, + stored_at TEXT NOT NULL +); +CREATE TABLE attachments ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + provider_id TEXT, + filename TEXT NOT NULL DEFAULT '', + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + content_id TEXT, + disposition TEXT NOT NULL DEFAULT 'attachment', + size INTEGER NOT NULL DEFAULT 0, + bytes BLOB, + created_at TEXT NOT NULL +); +CREATE INDEX attachments_message ON attachments(message_id); +-- 영구 삭제하거나 보관 기간이 지난 메일을 동기화가 다시 가져오지 않게 합니다. +CREATE TABLE tombstones (provider_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, reason TEXT NOT NULL DEFAULT 'deleted'); +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, user_id TEXT NOT NULL, login TEXT NOT NULL, + expires_at TEXT NOT NULL, created_at TEXT NOT NULL, ip TEXT, ua TEXT +); +-- POP 으로 한 번 전달한 메일은 같은 계정에 다시 내려주지 않습니다. +CREATE TABLE pop_deliveries ( + user_id TEXT NOT NULL, + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + delivered_at TEXT NOT NULL, + via TEXT NOT NULL DEFAULT 'retr' CHECK(via IN ('retr','dele')), + PRIMARY KEY(user_id, message_id) +); +CREATE TABLE webhook_events ( + id TEXT PRIMARY KEY, type TEXT NOT NULL, provider_id TEXT, + received_at TEXT NOT NULL, status TEXT NOT NULL, detail TEXT +); +CREATE INDEX webhook_events_date ON webhook_events(received_at DESC); +CREATE TABLE sync_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, trigger TEXT NOT NULL DEFAULT 'timer', + started_at TEXT NOT NULL, finished_at TEXT, imported INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'running', detail TEXT +); +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, actor TEXT, action TEXT NOT NULL, target TEXT, detail TEXT, created_at TEXT NOT NULL +); +CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL); diff --git a/modules/arimail/migrations/0002_imap.sql b/modules/arimail/migrations/0002_imap.sql new file mode 100644 index 0000000..e6f79af --- /dev/null +++ b/modules/arimail/migrations/0002_imap.sql @@ -0,0 +1,16 @@ +-- IMAP: 메일함별 UID(전역 단조 증가)와 영구 플래그. +CREATE TABLE imap_uids ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + folder TEXT NOT NULL, + UNIQUE(message_id, folder) +); +CREATE TABLE imap_flags ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + flags TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL +); +INSERT INTO settings(key,value,updated_at) VALUES('imap_uidvalidity', CAST(strftime('%s','now') AS TEXT), strftime('%Y-%m-%dT%H:%M:%SZ','now')); +INSERT INTO imap_uids(message_id, folder) SELECT id, folder FROM messages ORDER BY received_at, id; +-- 보낸 메일·초안은 읽음 상태로 둡니다. IMAP 의 \Seen 은 read_at 으로 표현합니다. +UPDATE messages SET read_at = COALESCE(read_at, sent_at, received_at) WHERE direction = 'outbound'; diff --git a/modules/arimail/migrations/0003_accounts.sql b/modules/arimail/migrations/0003_accounts.sql new file mode 100644 index 0000000..4177479 --- /dev/null +++ b/modules/arimail/migrations/0003_accounts.sql @@ -0,0 +1,19 @@ +-- 게이트웨이 사용자 계정과 메일함 분리. +-- www 관리자 계정은 계속 www DB 로 인증하며(별칭 admin 포함), 여기 계정은 관리자가 만드는 추가 사용자입니다. +CREATE TABLE mail_users ( + id TEXT PRIMARY KEY, + login TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + mailbox TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')), + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','suspended')), + password_hash TEXT NOT NULL, + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT +); +CREATE INDEX mail_users_mailbox ON mail_users(mailbox); +-- 메시지가 속한 메일함 주소. 기존 메시지는 기본 메일함(admin@arihakdang.com)입니다. +ALTER TABLE messages ADD COLUMN mailbox TEXT NOT NULL DEFAULT 'admin@arihakdang.com'; +CREATE INDEX messages_mailbox_folder ON messages(mailbox, folder, received_at DESC); diff --git a/modules/arimail/migrations/0004_no_retention.sql b/modules/arimail/migrations/0004_no_retention.sql new file mode 100644 index 0000000..4b5c252 --- /dev/null +++ b/modules/arimail/migrations/0004_no_retention.sql @@ -0,0 +1,20 @@ +-- 0.8.0: 메일 보관 기간(30일) 제한 삭제와 시각 형식 통일. +-- +-- 1) 보관 기간 제한 삭제 +-- 예전 릴리스는 메일마다 expires_at(수신 후 30일)을 두고 매시간 지난 메일을 원본·첨부와 함께 지웠습니다. +-- 이제 메일은 사용자가 영구 삭제할 때까지 보관합니다. 열 자체는 지우지 않고 값만 비웁니다. +-- 열을 남기는 이유: 배포 실패로 이전 릴리스로 되돌아가도 그 코드가 넣는 INSERT 가 깨지지 않게 하기 위해서입니다. +-- 값이 NULL 이면 이전 릴리스의 정리 작업(expires_at IS NOT NULL 조건)도 이 메일들을 지우지 않습니다. +UPDATE messages SET expires_at = NULL WHERE expires_at IS NOT NULL; +-- 만료 정리에만 쓰던 색인은 필요 없습니다. +DROP INDEX IF EXISTS messages_expires; + +-- 2) 시각 형식 통일 +-- Resend 보낸 메일 목록은 "2026-09-28 06:20:26.514000+00" 형식(공백 구분, 분 없는 시간대)을 돌려주고, +-- 0.8.0 이전에는 이 값을 그대로 received_at·sent_at 에 저장했습니다. 다른 메일은 ISO 형식(…T…Z)이라 +-- 같은 날짜에서 문자열 정렬이 시각과 무관하게 뒤섞였습니다. "+00" 뒤에 ":00" 을 붙여 SQLite 가 해석할 수 있게 한 뒤 +-- ISO 8601 UTC(밀리초 3자리, Z)로 바꿉니다. 이미 ISO 형식인 값은 GLOB 조건에 맞지 않아 그대로 둡니다. +UPDATE messages SET received_at = strftime('%Y-%m-%dT%H:%M:%fZ', received_at || ':00') + WHERE received_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]*[+-][0-9][0-9]'; +UPDATE messages SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ', sent_at || ':00') + WHERE sent_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]*[+-][0-9][0-9]'; diff --git a/modules/arimail/migrations/0005_imap_uid_reassign.sql b/modules/arimail/migrations/0005_imap_uid_reassign.sql new file mode 100644 index 0000000..57cd94e --- /dev/null +++ b/modules/arimail/migrations/0005_imap_uid_reassign.sql @@ -0,0 +1,16 @@ +-- 0.8.0: 메일이 이전에 있던 IMAP 메일함으로 돌아올 때 새 UID 를 받게 합니다. +-- +-- 문제: imap_uids 는 (message_id, folder) 마다 UID 한 개를 두고 INSERT OR IGNORE 로 채웁니다. +-- 메일이 다른 폴더로 나갔다가(휴지통 이동·IMAP MOVE·웹 "이동") 돌아오면 예전 행이 남아 있어 옛 UID 를 다시 썼습니다. +-- RFC 3501 은 메일함에 새로 들어온 메일의 UID 가 그때까지의 모든 UID 보다 커야 한다고 정합니다. 옛 UID 로 돌아온 +-- 메일은 "UIDNEXT 이후만" 받아 가는 클라이언트(Thunderbird·Outlook 등)에서 보이지 않았습니다. +-- 해결: 폴더가 바뀌는 순간 떠난 폴더의 UID 행을 지웁니다. 돌아오면 다음 동기화(SELECT·NOOP·IDLE)나 MOVE/APPEND 가 +-- INSERT OR IGNORE 로 새(더 큰) UID 를 줍니다. 웹·IMAP 등 어떤 경로로 옮기든 같은 규칙이 적용되도록 트리거로 둡니다. +CREATE TRIGGER IF NOT EXISTS messages_folder_leaves_imap_uid +AFTER UPDATE OF folder ON messages +WHEN OLD.folder <> NEW.folder +BEGIN + DELETE FROM imap_uids WHERE message_id = OLD.id AND folder = OLD.folder; +END; +-- 이미 남아 있는 "지금은 그 폴더에 없는 메일"의 UID 행을 정리합니다. 현재 폴더의 UID 는 그대로 두므로 클라이언트 캐시는 유지됩니다. +DELETE FROM imap_uids WHERE folder <> (SELECT m.folder FROM messages m WHERE m.id = imap_uids.message_id); diff --git a/modules/arimail/migrations/0006_session_scope.sql b/modules/arimail/migrations/0006_session_scope.sql new file mode 100644 index 0000000..4b9c500 --- /dev/null +++ b/modules/arimail/migrations/0006_session_scope.sql @@ -0,0 +1,6 @@ +-- 0.8.4: 관리자 페이지(/admin) 전용 세션. +-- 세션마다 범위(scope)를 둡니다. 'mail' 은 웹 메일(/), 'admin' 은 관리자 페이지(/admin)입니다. +-- 관리자 페이지는 별도의 로그인을 거치고 다른 이름의 쿠키(mg_admin)를 쓰므로, 웹 메일에 로그인돼 있어도 +-- 관리자 페이지를 열려면 다시 로그인해야 하고, 관리자 세션 쿠키로는 메일 API 를 쓸 수 없습니다. +-- 기존 세션은 모두 웹 메일 세션입니다. +ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'mail'; diff --git a/modules/arimail/migrations/0007_auto_actions.sql b/modules/arimail/migrations/0007_auto_actions.sql new file mode 100644 index 0000000..2cc0aeb --- /dev/null +++ b/modules/arimail/migrations/0007_auto_actions.sql @@ -0,0 +1,17 @@ +-- 0.8.16: 수신 메일 자동 처리 기록(메일 전달·자동 응답). +-- 설정 화면의 "메일 전달"(지정 주소로 받은 메일 전부 전달)과 "자동 응답"(지정 기간 동안 정해진 메시지로 회신)이 +-- 메일 한 통에 무엇을 했는지 남깁니다. 같은 메일에 같은 작업을 두 번 하지 않게 (message_id, kind) 를 기본 키로 둡니다 +-- (웹훅과 동기화가 같은 메일을 거의 동시에 저장하려 할 때, 또는 원본 없이 저장했던 메일을 다시 채울 때 중복 발송 방지). +CREATE TABLE auto_actions ( + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('forward','reply')), + mailbox TEXT NOT NULL, -- 작업을 설정한 메일함(받은 메일의 메일함) + target TEXT NOT NULL DEFAULT '', -- 전달: 전달한 주소 목록(쉼표 구분) / 자동 응답: 회신한 상대 주소 + status TEXT NOT NULL CHECK(status IN ('pending','sent','skipped','failed')), -- pending: 처리 중(먼저 행을 넣어 중복 처리를 막음) + detail TEXT, -- 건너뛴 이유(loop·stale·recent·bulk …) 또는 오류 메시지, 보냈으면 발송 id + sent_message_id TEXT, -- 자동 응답을 보낸 메일함(sent)에 저장한 메시지 id + created_at TEXT NOT NULL, + PRIMARY KEY(message_id, kind) +); +-- 자동 응답 "같은 상대에게는 하루 한 번" 검사: 메일함·종류·상대 주소로 최근 기록을 찾습니다. +CREATE INDEX auto_actions_target ON auto_actions(mailbox, kind, target, created_at DESC); diff --git a/modules/arimail/migrations/0008_large_files.sql b/modules/arimail/migrations/0008_large_files.sql new file mode 100644 index 0000000..e081bc4 --- /dev/null +++ b/modules/arimail/migrations/0008_large_files.sql @@ -0,0 +1,26 @@ +-- 0.9.3: 대용량 첨부파일(다운로드 링크 발송). +-- 큰 파일은 메일에 첨부하지 않고 서버 디스크(FILES_DIR, 기본 /files/)에 두고 메일에는 다운로드 링크만 넣습니다. +-- 파일 바이트는 DB 에 넣지 않습니다(1GB 급 BLOB 은 SQLite 와 백업에 부담). 이 표는 파일의 메타데이터와 링크 상태만 가집니다. +-- message_id : 파일이 딸린 초안/보낸 메일. 메일이 영구 삭제돼도 보낸 링크는 기한까지 살아 있어야 하므로 NULL 로 풀립니다(초안이면 정리 작업이 파일을 지움). +-- token : 다운로드 링크(/dl/)의 비밀 값. 발송할 때 만들어지며 그 전(초안)에는 NULL. +-- state : draft(초안에 올려 둠) → sent(링크 발송됨) → expired(기한·횟수 만료 또는 보낸 사람이 링크를 끊음, 파일은 지워짐) +-- max_downloads: 허용 다운로드 횟수, downloads: 지금까지 받은 횟수(랜딩 페이지의 다운로드 단추를 누를 때마다 1), expires_at: 보관 기한(ISO) +-- 발송 전에 기한과 횟수를 반드시 정하므로 sent 상태의 행은 둘 다 NULL 이 아닙니다(기한·횟수 제한 없는 발송 불가). +CREATE TABLE large_files ( + id TEXT PRIMARY KEY, + message_id TEXT REFERENCES messages(id) ON DELETE SET NULL, + mailbox TEXT NOT NULL, + filename TEXT NOT NULL DEFAULT 'file', + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size INTEGER NOT NULL DEFAULT 0, + token TEXT UNIQUE, + state TEXT NOT NULL DEFAULT 'draft' CHECK(state IN ('draft','sent','expired')), + max_downloads INTEGER, + downloads INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL, + sent_at TEXT, + last_download_at TEXT +); +CREATE INDEX large_files_message ON large_files(message_id); +CREATE INDEX large_files_state ON large_files(state, expires_at); diff --git a/modules/arimail/package.json b/modules/arimail/package.json new file mode 100644 index 0000000..6631bf3 --- /dev/null +++ b/modules/arimail/package.json @@ -0,0 +1,19 @@ +{ + "name": "arimail", + "version": "1.0.11", + "private": true, + "description": "AriMail — 웹 메일함, IMAP·POP3·SMTP 서버, Resend 또는 내장 Haraka 로 수·발신하는 메일 게이트웨이", + "type": "module", + "scripts": { + "start": "node server/index.mjs", + "migrate": "node server/db-migrate.mjs", + "test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs" + }, + "engines": { + "node": ">=22.13" + }, + "repository": { + "type": "git", + "url": "https://source.ibit.kr/OZ-Group/AriMail" + } +} diff --git a/modules/arimail/server/api.mjs b/modules/arimail/server/api.mjs new file mode 100644 index 0000000..003dd86 --- /dev/null +++ b/modules/arimail/server/api.mjs @@ -0,0 +1,479 @@ +/** + * HTTP API. 표준 Request/Response 로 작성해 www 사이트 핸들러와 같은 방식으로 실행합니다. + * 모든 메일 조회는 세션 사용자의 메일함으로 범위를 제한합니다. 0.9.0 부터는 관리자도 예외가 아닙니다 + * (예전의 ?mailbox= 로 다른 메일함을 여는 기능은 요구사항에 따라 삭제했습니다. 관리자 권한은 /admin 의 계정·설정 관리에만 쓰입니다). + * + * 세션 범위(0.8.4~): 요청 헤더 X-Mail-Scope 가 'admin' 이면 관리자 페이지(/admin)의 요청입니다. 이 요청은 관리자 세션 쿠키(mg_admin)로 + * 인증하고 /api/admin/* 와 /api/session 만 쓸 수 있습니다. 헤더가 없으면 웹 메일 요청이며 메일 세션 쿠키(mg_session)로 인증하고 + * /api/admin/* 는 쓸 수 없습니다(관리자도 /admin 에서 따로 로그인해야 합니다). + * + * 0.9.0 에 더한 경로 + * POST /api/signup 회원 가입(관리자가 켠 경우). 옵션에 따라 즉시 사용 또는 승인 대기. + * PATCH /api/password 로그인한 계정의 비밀번호 변경(현재 비밀번호 + 새 비밀번호 2회). 웹 메일 "도구 → 설정" 이 씁니다. + * GET/PATCH /api/admin/config 회원 가입 설정·메일 제한(관리자 층)·POP/IMAP 서비스 스위치. 시스템 제한 값도 함께 돌려줍니다. + * PATCH /api/admin/users/ quotaBytes(계정별 메일함 크기), status:'active'(승인) 추가. 자기 비밀번호는 여기서 바꿀 수 없음(설정 화면으로). + */ +import { json, bad, now, uid, clean, addresses } from './util.mjs'; +import { verifyUser, createSession, sessionCookie, currentUser, destroySession, requireUser, audit, listUsers, createUser, updateUser, deleteUser, knownMailboxes, readPrefs, writePrefs, mailDomain, readPasswordPolicy, writePasswordPolicy, usesInitialPassword, checkCurrentPassword, pendingLogin, externalUsers, authMode } from './auth.mjs'; +import { outboundStatus, inboundMode, outboundMode, usesHaraka } from './providers/index.mjs'; +import { configFile } from './config.mjs'; +import { setupApi, harakaStatus } from './setup.mjs'; +import { ingestRaw as ingestRawMail } from './store.mjs'; +import { safeEqual as timingEqual, address as bareAddress, clean as cleanStr, uid as newId, now as nowIso } from './util.mjs'; +import { knownMailboxes as mailboxList } from './auth.mjs'; +import { sanitizeHtml } from './sanitize.mjs'; +import { verifySvix } from './webhook.mjs'; +import { setFlag, mailboxOf, ingestReceived, startSync, isSyncing, saveDraft, sendDraft, addAttachment, removeAttachment, copyAttachments } from './store.mjs'; +import { autoActionsOf, autoReplyActive } from './automation.mjs'; +import { readLargeFileSettings, writeLargeFileSettings, systemLargeFiles, listLargeFiles, removeLargeFile, largeFileStats } from './files.mjs'; +import { systemLimits, readAdminLimits, writeAdminLimits, readUserQuota, writeUserQuota, checkUserQuota, mailboxQuota, mailboxUsage, attachmentLimit, recipientLimit, readServices, writeServices, plainLoginAllowed, readSignup, writeSignup, mailStats, accountCount } from './limits.mjs'; + +const FOLDERS = ['inbox', 'sent', 'drafts', 'trash']; +/** + * 메일이 상태와 맞게 들어갈 수 있는 폴더 목록(웹의 "이동" 메뉴·API 공통 규칙). + * - 받은 메일: 받은 메일함·휴지통 + * - 발송한 메일(state='sent'): 보낸 메일함·휴지통 + * - 그 밖의 보내는 메일(초안·발송 실패): 임시 보관함·휴지통 + * 왜: 예전에는 아무 폴더로나 옮길 수 있어, 초안을 보낸 메일함에 넣으면 보내지 않은 메일이 보낸 것처럼 보이고, + * 받은 메일을 임시 보관함에 넣으면 작성 창으로 열려 저장·발송이 mail_locked 로 실패했습니다. + */ +export const movableFolders = (m) => m.direction === 'inbound' ? ['inbox', 'trash'] : m.state === 'sent' ? ['sent', 'trash'] : ['drafts', 'trash']; +// prefix(1.0.8): 화면이 경로 접두사(/mail) 아래에서 돌 때 인라인 이미지 주소도 그 아래로 만듭니다(저장된 본문은 늘 /api/mail/… 로 두고 내보낼 때만 붙임). +const previewHtml = (html, id, attachments, prefix = '') => String(html || '').replace(/(]*\bsrc=")cid:([^"]+)("[^>]*>)/g, (m, a, cid, b) => { + const match = attachments.find((x) => x.content_id && x.content_id === cid.replace(/&/g, '&')); + return match ? `${a}${prefix}/api/mail/${encodeURIComponent(id)}/attachments/${encodeURIComponent(match.id)}?inline=1${b}` : `${a}${b}`; + // 초안·보낸 메일에 이미 /api/mail/… 로 저장된 인라인 이미지도 접두사 아래에서 열리게 같이 바꿉니다(없으면 사이트의 404 를 보게 됨). +}).replace(/(]*\bsrc=")\/api\/mail\//g, (m, a) => prefix ? `${a}${prefix}/api/mail/` : m); +/** + * 초안 본문의 인라인 이미지 주소에서 경로 접두사를 뗍니다(1.0.8). 편집기는 화면 기준 경로(/mail/api/mail/…)로 넣는데, 저장·발송(cid: 변환)은 /api/mail/… 만 알아보므로 + * 저장 전에 되돌립니다. 접두사가 없으면 그대로입니다. + */ +const stripPrefix = (html, prefix, env) => { let out = String(html || ''); for (const p of new Set([prefix, env?.PUBLIC_PATH].filter(Boolean))) out = out.split(`src="${p}/api/mail/`).join('src="/api/mail/'); return html ? out : html; }; +async function applyAction(env, mail, body, user) { + if (body.folder === 'trash' || body.action === 'trash') { if (mail.state === 'sending') throw new Error('mail_sending'); await env.DB.prepare("UPDATE messages SET previous_folder=folder,folder='trash',updated_at=? WHERE id=? AND folder<>'trash'").bind(now(), mail.id).run(); } + else if (body.restore === true || body.action === 'restore') await env.DB.prepare("UPDATE messages SET folder=COALESCE(previous_folder,CASE direction WHEN 'inbound' THEN 'inbox' ELSE 'sent' END),previous_folder=NULL,updated_at=? WHERE id=? AND folder='trash'").bind(now(), mail.id).run(); + else if (body.action === 'move' && FOLDERS.includes(body.target)) { if (mail.state === 'sending') throw new Error('mail_sending'); if (!movableFolders(mail).includes(body.target)) throw new Error('mail_move_invalid'); await env.DB.prepare('UPDATE messages SET previous_folder=folder,folder=?,updated_at=? WHERE id=?').bind(body.target, now(), mail.id).run(); } + else if (body.unread === true || body.action === 'unread') await env.DB.prepare('UPDATE messages SET read_at=NULL WHERE id=?').bind(mail.id).run(); + else if (body.read === true || body.action === 'read') await env.DB.prepare('UPDATE messages SET read_at=COALESCE(read_at,?) WHERE id=?').bind(now(), mail.id).run(); + else if (body.flagged !== undefined || body.action === 'flag' || body.action === 'unflag') await setFlag(env, mail.id, '\\Flagged', body.flagged !== undefined ? !!body.flagged : body.action === 'flag'); + else if (body.popReset === true || body.action === 'popReset') { await env.DB.prepare('DELETE FROM pop_deliveries WHERE message_id=?').bind(mail.id).run(); await audit(env, user.login, 'pop_reset', mail.id); } + else if (body.action === 'delete') { if (mail.folder !== 'trash') throw new Error('mail_trash_first'); await purge(env, mail); await audit(env, user.login, 'mail_delete', mail.id); } + else return false; + return true; +} +async function purge(env, mail) { + const statements = []; + if (mail.provider_id) statements.push(env.DB.prepare("INSERT OR IGNORE INTO tombstones(provider_id,deleted_at,reason) VALUES(?,?,'deleted')").bind(mail.provider_id, now())); + statements.push(env.DB.prepare('DELETE FROM messages WHERE id=?').bind(mail.id)); + await env.DB.batch(statements); +} +/** 메일함 폴더별 건수·읽지 않음 수(사이드바용). */ +const folderCounts = async (env, mailbox) => (await env.DB.prepare("SELECT folder,COUNT(*) n,SUM(CASE WHEN read_at IS NULL AND direction='inbound' THEN 1 ELSE 0 END) unread FROM messages WHERE mailbox=? GROUP BY folder").bind(mailbox).all()).results; + +export async function handle(env, request, params) { + const { path, ip } = params, prefix = params.prefix || ''; + const method = request.method; + // scope: 이 요청이 어느 화면에서 왔는지. 관리자 페이지는 모든 API 호출에 X-Mail-Scope: admin 을 붙입니다. + const sessionScope = request.headers.get('x-mail-scope') === 'admin' ? 'admin' : 'mail'; + try { + // ---------------------------------------------------------------- 공개 + // 상태 확인. version 은 배포 후 실제로 새 코드가 돌고 있는지 확인하는 데 씁니다. + if (path[0] === 'health') return json({ ok: true, version: env.APP_VERSION || null }); + if (path[0] === 'webhooks' && path[1] === 'resend' && method === 'POST') return await webhook(env, request); + // 내장 Haraka 수신 전달(1.0.0): 로컬 Haraka 플러그인이 토큰으로 인증해 부릅니다(수신 방식이 haraka 일 때만 열림). + if (path[0] === 'inbound' && path[1] === 'haraka') return await harakaInbound(env, request, path, method); + if (path[0] === 'login' && method === 'POST') { + const body = await request.json().catch(() => null); if (!body) return bad('bad_request'); + // 로그인 제한(IP 당·계정 당 15분 10회)은 실패만 셉니다. 예전에는 성공도 세어 공유 IP(사무실) 에서 10명이 로그인하면 나머지가 막혔습니다. + // 로그인 ID 는 login 또는 email 필드로 옵니다. 제한 키·검사·기록 모두 같은 값을 씁니다(예전에는 email 필드 로그인이 빈 계정 키 'login:' 하나를 함께 써서 서로 잠겼습니다, 0.9.3 수정). + const loginId = clean(body.login ?? body.email, 200).toLowerCase(); + const keys = ['login:' + ip, 'login:' + loginId]; + if (keys.some((k) => env.RATE.blocked(k, { limit: 10 }))) return bad('rate_limited', 429); + const password = String(body.password ?? ''); + const user = await verifyUser(env, loginId, password); + if (!user) { + // 회원 가입 뒤 승인을 기다리는 계정(비밀번호까지 맞을 때만)에는 "승인 대기" 를 알려 줍니다. 실패 횟수로 세지 않습니다. + if (await pendingLogin(env, loginId, password)) { await audit(env, null, 'login_pending', loginId, { ip }); return bad('pending_approval', 403); } + keys.forEach((k) => env.RATE.hit(k, { limit: 10, windowSec: 900 })); await audit(env, null, 'login_failed', loginId, { ip, scope: sessionScope }); return bad('invalid_credentials', 401); + } + // 관리자 페이지 로그인은 관리자 역할 계정만 통과합니다. 비밀번호는 맞아도 일반 계정이면 거절 사유를 따로 알려 줍니다. + if (sessionScope === 'admin' && user.role !== 'admin') { await audit(env, user.login, 'admin_login_denied', null, { ip }); return bad('admin_only', 403); } + const token = await createSession(env, user, request, { scope: sessionScope }); await audit(env, user.login, sessionScope === 'admin' ? 'admin_login' : 'login', null, { ip, source: user.source }); + // 쿠키 Path(1.0.8): 경로 접두사 아래의 웹 메일이면 그 접두사(/mail)로 한정해 같은 사이트의 다른 경로(사이트 자체 백엔드)에는 세션 쿠키가 가지 않게 합니다. + return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie(token, { scope: sessionScope, path: prefix }) }); + } + if (path[0] === 'session' && method === 'GET') { + const user = await currentUser(env, request, { scope: sessionScope }); + const prefs = user ? await readPrefs(env, user) : {}; + // autoReplyActive: 자동 응답이 지금 동작 중인지(기간 판단은 서버 시각 기준). 화면은 이 값으로 사이드바에 "자동 응답 작동 중" 을 표시합니다. + // initialPassword(0.8.24~): 독립 실행의 초기 관리자가 아직 adminpasswd 를 쓰면 true. 화면이 비밀번호를 바꾸라는 경고를 띄웁니다. + // signup(0.9.0~): 회원 가입이 켜져 있는지(로그인 화면의 "회원 가입" 단추). mailboxes 는 항상 자기 메일함 하나입니다(메일함 전환 삭제). + // largeFiles(0.9.3): 대용량 첨부파일 설정(사용 여부·상한). 작성 창이 단추를 보일지와 발송 조건 입력 칸의 기본값·상한을 정하는 데 씁니다. attachmentBytes 는 일반 첨부 상한(이보다 큰 파일은 대용량으로 안내). + return json({ ok: true, user, mailbox: user?.mailbox || mailboxOf(env), mailboxes: user ? [user.mailbox] : [], prefs, autoReplyActive: user ? autoReplyActive(prefs.autoReply) : false, initialPassword: user?.source === 'local' ? await usesInitialPassword(env, user) : false, domain: mailDomain(env), host: (env.PUBLIC_HOST || 'mail.arihakdang.com') + (env.PUBLIC_PATH || ''), authMode: authMode(env), version: env.APP_VERSION || null, signup: (await readSignup(env)).enabled, + ...(user && sessionScope === 'mail' ? { largeFiles: await readLargeFileSettings(env), attachmentBytes: await attachmentLimit(env) } : {}) }); + } + if (path[0] === 'session' && method === 'DELETE') { await destroySession(env, request, { scope: sessionScope }); return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie('', { clear: true, scope: sessionScope, path: prefix }) }); } + // return await: try 안에서 프라미스를 그냥 return 하면 거부가 아래 catch 에 잡히지 않습니다(createUser 의 weak_password 등이 500 으로 새어 나감). + if (path[0] === 'signup' && method === 'POST') return await signup(env, request, ip); + // ---------------------------------------------------------------- 로그인 사용자 + const user = await requireUser(env, request, { scope: sessionScope }); if (user instanceof Response) return user; + // ---------------------------------------------------------------- 관리자 페이지(/admin) 전용 + // 관리자 세션으로는 아래 경로만 쓸 수 있고, 반대로 웹 메일 세션으로는 /api/admin 을 쓸 수 없습니다(관리자 페이지에서 따로 로그인). + // 초기 설정 API(1.0.0): 관리자 세션으로도 같은 API 를 씁니다(/setup 화면을 관리자 페이지에서 열 때). 토큰 인증 쪽은 setup.mjs 의 전용 포트 서버가 처리합니다. + if (path[0] === 'setup') { if (sessionScope !== 'admin') return bad('admin_login_required', 403); return await setupApi(env, request, path.slice(1), { ip, user }); } + if (path[0] === 'admin') { + if (sessionScope !== 'admin') return bad('admin_login_required', 403); + if (path[1] === 'status' && method === 'GET') return await adminStatus(env); + if (path[1] === 'sync' && method === 'POST') return json({ ok: true, ...startSync(env, 'manual') }); + // 비밀번호 정책(0.8.24~): GET 은 현재 정책, PATCH 는 { minLength?, mixedCase?, digit?, special? } 를 저장합니다. 기존 비밀번호에는 소급하지 않습니다. + if (path[1] === 'policy' && method === 'GET') return json({ ok: true, policy: await readPasswordPolicy(env) }); + if (path[1] === 'policy' && method === 'PATCH') { + const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request'); + const policy = await writePasswordPolicy(env, body); await audit(env, user.login, 'policy_update', null, policy); + return json({ ok: true, policy }); + } + // 관리자 설정(0.9.0~): 회원 가입·메일 제한·서비스 스위치. system 은 설치 시 정한 상위 제한(읽기 전용)입니다. + if (path[1] === 'config' && method === 'GET') return json({ ok: true, ...(await adminConfig(env)) }); + if (path[1] === 'config' && method === 'PATCH') { + const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request'); + const changed = {}; + if (body.signup && typeof body.signup === 'object') changed.signup = await writeSignup(env, body.signup); + if (body.limits && typeof body.limits === 'object') changed.limits = await writeAdminLimits(env, body.limits); + if (body.services && typeof body.services === 'object') changed.services = await writeServices(env, body.services); + if (body.largeFiles && typeof body.largeFiles === 'object') changed.largeFiles = await writeLargeFileSettings(env, body.largeFiles); + await audit(env, user.login, 'config_update', null, changed); + return json({ ok: true, ...(await adminConfig(env)) }); + } + if (path[1] !== 'users') return bad('not_found', 404); + const target = path[2]; + if (method === 'GET' && !target) { + // 계정마다 메일 프로그램 접속 허용 여부(prefs 의 pop/imap)와 메일함 크기·사용량(0.9.0)을 붙여 돌려줍니다. + const users = await listUsers(env); + for (const u of users) { + const p = await readPrefs(env, u); u.services = { pop: p.pop, imap: p.imap }; + u.quotaBytes = await readUserQuota(env, u.id); u.limit = await mailboxQuota(env, u.mailbox); u.usageBytes = await mailboxUsage(env, u.mailbox); + } + // me: www 관리자 계정의 행. 역할이 관리자인 게이트웨이 계정으로 들어온 경우에는 그 계정이 users 목록에 이미 있으므로 me 를 주지 않습니다 + // (예전에는 로그인한 누구든 "admin" 행으로 그려져, 게이트웨이 관리자의 설정이 www 관리자 행에 표시됐습니다). 웹메이커 관리자(1.0.8)는 아래 external 목록에 있으므로 역시 me 가 없습니다. + const mine = user.source === 'www' ? await readPrefs(env, user) : null; + // external(1.0.8): webmaker 연동 도메인의 웹메이커 계정(읽기 전용 — 이름·비밀번호·역할은 웹메이커가 관리). POP/IMAP 허용·메일함 크기·사용량만 붙여 줍니다. + const ext = await externalUsers(env); + const external = []; + for (const a of ext.accounts) { const p = await readPrefs(env, a); external.push({ ...a, services: { pop: p.pop, imap: p.imap }, quotaBytes: await readUserQuota(env, a.id), limit: await mailboxQuota(env, a.mailbox), usageBytes: await mailboxUsage(env, a.mailbox) }); } + // policy: 계정 추가·수정 창이 비밀번호 입력 칸의 최소 길이와 안내문을 정책대로 그리는 데 씁니다. system/limits: 메일함 크기 입력 칸의 상한 안내. + const sys = systemLimits(env); + return json({ ok: true, users, external, externalSource: authMode(env) === 'webmaker' ? { kind: 'webmaker', ok: ext.ok, error: ext.error || null, site: ext.site || null, readAt: ext.readAt || null } : null, defaultMailbox: mailboxOf(env), domain: mailDomain(env), authMode: authMode(env), policy: await readPasswordPolicy(env), system: sys, limits: await readAdminLimits(env), accounts: { used: await accountCount(env), limit: sys.accounts }, + me: mine ? { id: user.id, login: user.login, name: user.name, source: user.source, services: { pop: mine.pop, imap: mine.imap }, usageBytes: await mailboxUsage(env, user.mailbox), limit: await mailboxQuota(env, user.mailbox) } : null }); + } + const body = method === 'DELETE' ? {} : await request.json().catch(() => null); if (!body) return bad('bad_request'); + if (method === 'POST' && !target) { + // 메일함 크기 값은 계정을 만들기 전에 검사합니다(틀린 값으로 계정만 생기고 저장은 실패하는 일 방지). + if (body.quotaBytes !== undefined) checkUserQuota(env, body.quotaBytes); + const id = await createUser(env, body, user.login); + if (body.services && typeof body.services === 'object') await writePrefs(env, { id }, body.services); + if (body.quotaBytes !== undefined) await writeUserQuota(env, id, body.quotaBytes); + await audit(env, user.login, 'user_create', id, { login: clean(body.login, 64) }); return json({ ok: true, id }, 201); + } + // 'me' 는 관리자 자신(www 계정)의 메일 프로그램 접속 설정만 바꿉니다. www 계정 자체는 www 사이트에서 관리합니다. + if (method === 'PATCH' && target === 'me') { if (user.source !== 'www') return bad('not_found', 404); const prefs = await writePrefs(env, user, body.services || {}); await audit(env, user.login, 'user_update', user.id, { services: true }); return json({ ok: true, services: { pop: prefs.pop, imap: prefs.imap } }); } + // 웹메이커 계정(1.0.8, id 'wm:…'): 이름·비밀번호·역할·상태는 웹메이커가 주인이라 받지 않고, 메일 프로그램 접속 허용(services)과 메일함 크기(quotaBytes)만 저장합니다. 삭제도 할 수 없습니다. + if (target && target.startsWith('wm:')) { + if (method !== 'PATCH') return bad('webmaker_managed', 403); + const ext = await externalUsers(env); const acc = ext.accounts.find((a) => a.id === target); if (!acc) return bad('not_found', 404); + if (body.quotaBytes !== undefined) { checkUserQuota(env, body.quotaBytes); await writeUserQuota(env, target, body.quotaBytes); } + const prefs = body.services && typeof body.services === 'object' ? await writePrefs(env, acc, body.services) : await readPrefs(env, acc); + await audit(env, user.login, 'user_update', target, { services: !!body.services, ...(body.quotaBytes !== undefined ? { quotaBytes: Number(body.quotaBytes) } : {}) }); + return json({ ok: true, services: { pop: prefs.pop, imap: prefs.imap } }); + } + if (method === 'PATCH' && target) { + // 0.9.0: 자기 계정의 비밀번호는 관리자 페이지에서 바꾸지 않습니다(웹 메일 "도구 → 설정" 에서 현재 비밀번호 확인을 거쳐 바꿈). + // 다른 계정의 비밀번호는 관리자가 한 번 입력으로 재설정합니다. 화면 보조 필드(passwordConfirm/currentPassword)는 무시합니다. + const { currentPassword, passwordConfirm, quotaBytes, ...fields } = body; + if (fields.password && target === user.id) return bad('self_password_settings', 422); + // 자기 계정의 역할을 내리거나 정지하면 관리자 페이지에서 바로 쫓겨나고(독립 실행이면 남은 관리자가 없어 영영 잠김) 되돌릴 길이 없으므로 거부합니다(0.9.3). + if (target === user.id && ((fields.role !== undefined && fields.role !== 'admin') || fields.status === 'suspended')) return bad('cannot_modify_self', 409); + // 메일함 크기는 먼저 검사만 하고, 계정 수정이 성공한 뒤에 저장합니다(예전에는 없는 계정·정책 위반 비밀번호로 실패해도 크기가 먼저 저장됐습니다, 0.9.3). + if (quotaBytes !== undefined) checkUserQuota(env, quotaBytes); + await updateUser(env, target, fields); + if (quotaBytes !== undefined) await writeUserQuota(env, target, quotaBytes); + await audit(env, user.login, 'user_update', target, { fields: Object.keys(fields).filter((k) => k !== 'password'), password: fields.password ? true : undefined, ...(quotaBytes !== undefined ? { quotaBytes: Number(quotaBytes) } : {}), ...(fields.status === 'active' ? { approve: true } : {}) }); + return json({ ok: true }); + } + if (method === 'DELETE' && target) { if (target === user.id) return bad('cannot_delete_self', 409); await deleteUser(env, target); await audit(env, user.login, 'user_delete', target); return json({ ok: true }); } + return bad('not_found', 404); + } + if (sessionScope === 'admin') return bad('forbidden', 403); + // ---------------------------------------------------------------- 웹 메일 + if (path[0] === 'settings' && method === 'GET') return await settings(env, user); + if (path[0] === 'prefs' && method === 'GET') return json({ ok: true, prefs: await readPrefs(env, user) }); + if (path[0] === 'prefs' && method === 'PATCH') { + // 화면 기본값(remoteImages)·메일 프로그램 접속 허용(pop/imap)·메일 전달(forward)·자동 응답(autoReply)을 사용자가 직접 바꿉니다. + // 아는 키만 저장하고, 전달 주소·자동 응답 기간 검사 실패는 writePrefs 가 던지는 코드(forward_invalid 등)를 422 로 돌려줍니다. + const body = await request.json().catch(() => null); if (!body) return bad('bad_request'); + const next = await writePrefs(env, user, body); + await audit(env, user.login, 'prefs_update', null, { keys: Object.keys(body).slice(0, 10), ...(body.forward ? { forward: next.forward.enabled ? next.forward.to : 'off' } : {}), ...(body.autoReply ? { autoReply: next.autoReply.enabled ? (next.autoReply.noLimit ? 'unlimited' : `${next.autoReply.start || 'now'}~${next.autoReply.end}`) : 'off' } : {}) }); + return json({ ok: true, prefs: next, autoReplyActive: autoReplyActive(next.autoReply) }); + } + // 비밀번호 변경(0.9.0~): 웹 메일 설정 화면. 게이트웨이 계정만(www 관리자는 www 사이트에서 관리). 현재 비밀번호가 맞고 새 비밀번호 두 입력이 같아야 합니다. + // 바꾸면 이 계정의 세션이 모두 끝나므로(탈취된 세션 차단) 화면은 다시 로그인하게 안내합니다. + if (path[0] === 'password' && method === 'PATCH') { + const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request'); + // 외부 계정(www 관리자·웹메이커 계정)의 비밀번호는 그쪽 서비스에서 바꿉니다(1.0.8: webmaker_managed). + if (user.source !== 'local') return bad(user.source === 'webmaker' ? 'webmaker_managed' : 'www_managed', 403); + const password = String(body.password ?? ''); if (!password) return bad('bad_request'); + if (body.passwordConfirm !== undefined && String(body.passwordConfirm) !== password) return bad('password_mismatch', 422); + // 현재 비밀번호 확인에도 로그인과 같은 제한(계정·IP 당 15분 10회)을 둡니다. 훔친 세션으로 비밀번호를 무제한 대입하지 못하게(0.9.3). + const pwKeys = ['pwchange:' + user.id, 'pwchange:' + ip]; + if (pwKeys.some((k) => env.RATE.blocked(k, { limit: 10 }))) return bad('rate_limited', 429); + if (!(await checkCurrentPassword(env, user.id, body.currentPassword))) { pwKeys.forEach((k) => env.RATE.hit(k, { limit: 10, windowSec: 900 })); await audit(env, user.login, 'password_change_denied', user.id, { ip }); return bad('current_password_invalid', 422); } + await updateUser(env, user.id, { password }); await audit(env, user.login, 'password_change', user.id, { ip }); + return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie('', { clear: true, scope: 'mail', path: prefix }) }); + } + if (path[0] !== 'mail') return bad('not_found', 404); + const [, id, action, sub] = path; + const url = new URL(request.url); + // 메일함은 언제나 로그인한 계정의 메일함입니다(0.9.0: 관리자의 다른 메일함 열람 삭제). ?mailbox= 가 와도 무시합니다. + const mailbox = user.mailbox, scope = user; + const canSee = (m) => m.mailbox === mailbox; + if (id === 'status' && method === 'GET') return json({ ok: true, mailbox, ...await outboundStatus(env, { force: url.searchParams.get('force') === '1' }) }); + // 동기화 시작. 목록 화면의 1분 자동 새로 고침은 { quick: true } 를 보내 "빠른 확인"(이미 아는 메일에서 멈춤)으로, + // 설정 화면의 "지금 동기화" 버튼은 본문 없이 보내 전체 훑기로 동작합니다(store.syncMailbox 설명 참고). + if (id === 'sync' && method === 'POST') { const b = await request.json().catch(() => ({})); const quick = b?.quick === true; return json({ ok: true, ...startSync(env, quick ? 'poll' : 'manual', { quick }) }); } + // usageBytes/quotaBytes(0.9.0): 사용량은 MIME 기준(저장 원본 크기), quotaBytes 는 이 메일함에 적용 중인 최대 크기(0 = 제한 없음). 사이드바 사용량 막대가 씁니다. + if (id === 'counts' && method === 'GET') return json({ ok: true, counts: await folderCounts(env, mailbox), usageBytes: await mailboxUsage(env, mailbox), quotaBytes: await mailboxQuota(env, mailbox), mailbox, syncing: isSyncing(env) }); + if (id === 'sanitize' && method === 'POST') { const b = await request.json().catch(() => ({})); return json({ ok: true, ...sanitizeHtml(b.html) }); } + if (id === 'bulk' && method === 'POST') { + const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.ids)) return bad('bad_request'); + let done = 0; + for (const mid of body.ids.slice(0, 200)) { const m = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(String(mid)).first(); if (!m || !canSee(m)) continue; try { if (await applyAction(env, m, body, user)) done++; } catch { /* 개별 실패는 건너뜁니다 */ } } + return json({ ok: true, done }); + } + if (method === 'GET' && !id) { + const folder = url.searchParams.get('folder') || 'inbox', q = clean(url.searchParams.get('q'), 200), unreadOnly = url.searchParams.get('unread') === '1', flaggedOnly = url.searchParams.get('flagged') === '1', attachOnly = url.searchParams.get('attach') === '1'; + if (!FOLDERS.includes(folder)) return bad('validation', 422); + const page = Math.max(0, Math.min(100000, Number(url.searchParams.get('page')) || 0)), size = Math.min(100, Math.max(10, Number(url.searchParams.get('size')) || 50)); + // 검색어의 % _ \ 는 LIKE 와일드카드가 아니라 글자 그대로 찾습니다(0.9.0 수정, 예전에는 "_" 하나로 모든 메일이 나왔음). + const like = Array(4).fill('%' + q.replace(/[\\%_]/g, '\\$&') + '%'); + const where = `mailbox=? AND folder=? AND (subject LIKE ? ESCAPE '\\' OR text LIKE ? ESCAPE '\\' OR sender LIKE ? ESCAPE '\\' OR recipients_json LIKE ? ESCAPE '\\')${unreadOnly ? ' AND read_at IS NULL' : ''}${flaggedOnly ? " AND EXISTS(SELECT 1 FROM imap_flags f WHERE f.message_id=messages.id AND f.flags LIKE '%\\\\Flagged%')" : ''}${attachOnly ? " AND EXISTS(SELECT 1 FROM attachments a WHERE a.message_id=messages.id AND (a.disposition='attachment' OR a.content_id IS NULL))" : ''}`; + // attachOnly: "첨부" 보기 필터. 예전에는 화면이 받아 온 한 페이지(50통) 안에서만 걸러, 페이지 수·합계는 전체 기준인데 + // 목록은 비어 보이는 문제가 있었습니다. 서버 조건으로 옮겨 합계·페이지가 필터와 일치합니다. + const rows = await env.DB.prepare(`SELECT id,direction,folder,sender,recipients_json,subject,substr(text,1,160) AS preview,state,source,raw_status,read_at,received_at,created_at,updated_at,mailbox, + (SELECT COUNT(*) FROM attachments a WHERE a.message_id=messages.id AND (a.disposition='attachment' OR a.content_id IS NULL)) AS attachment_count,(SELECT COUNT(*) FROM pop_deliveries d WHERE d.message_id=messages.id) AS pop_delivered, + (SELECT flags FROM imap_flags f WHERE f.message_id=messages.id) AS flags + FROM messages WHERE ${where} ORDER BY received_at DESC LIMIT ? OFFSET ?`).bind(mailbox, folder, ...like, size, page * size).all(); + const total = await env.DB.prepare(`SELECT COUNT(*) n FROM messages WHERE ${where}`).bind(mailbox, folder, ...like).first(); + return json({ ok: true, messages: rows.results.map((m) => ({ ...m, flagged: String(m.flags || '').includes('\\Flagged'), flags: undefined })), total: total.n, page, size, counts: await folderCounts(env, mailbox), mailbox, usageBytes: await mailboxUsage(env, mailbox), quotaBytes: await mailboxQuota(env, mailbox), syncing: isSyncing(env) }); + } + if (method === 'POST' && !id) { const body = await request.json().catch(() => null); if (!body) return bad('bad_request'); if (body.html) body.html = stripPrefix(body.html, prefix, env); return json({ ok: true, id: await saveDraft(env, body, undefined, scope) }, 201); } + if (!id) return bad('not_found', 404); + const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); if (!mail || !canSee(mail)) return bad('not_found', 404); + if (method === 'GET') { + const attachments = (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,(bytes IS NOT NULL) AS available FROM attachments WHERE message_id=? ORDER BY rowid').bind(id).all()).results; + if (action === 'original') { + const raw = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first(); if (!raw) return bad('not_found', 404); + return new Response(Buffer.from(raw.eml), { headers: { 'Content-Type': 'message/rfc822', 'Content-Disposition': `attachment; filename="${id}.eml"`, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } }); + } + if (action === 'attachments' && !sub) return json({ ok: true, attachments }); + if (action === 'attachments' && sub) { + const row = await env.DB.prepare('SELECT filename,content_type,bytes FROM attachments WHERE id=? AND message_id=?').bind(sub, id).first(); if (!row?.bytes) return bad('not_found', 404); + const inline = url.searchParams.get('inline') === '1' && /^image\//.test(row.content_type); + return new Response(Buffer.from(row.bytes), { headers: { 'Content-Type': inline ? row.content_type : 'application/octet-stream', 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.filename)}`, 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } }); + } + if (action === 'files' && !sub) return json({ ok: true, files: await listLargeFiles(env, id) }); // 대용량 파일 목록(0.9.3) + if (action) return bad('not_found', 404); + if (!mail.read_at && mail.direction === 'inbound' && url.searchParams.get('peek') !== '1') await env.DB.prepare('UPDATE messages SET read_at=? WHERE id=?').bind(now(), id).run(); + const popDeliveries = (await env.DB.prepare('SELECT user_id,delivered_at,via FROM pop_deliveries WHERE message_id=?').bind(id).all()).results; + const flags = JSON.parse((await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first())?.flags || '[]'); + // autoActions: 이 메일에 한 자동 처리(전달·자동 응답) 기록. 상세 정보 상자에 "전달됨 → 주소 / 자동 응답 보냄" 으로 보입니다. + // largeFiles(0.9.3): 보내는 메일(초안·보낸 메일)에 딸린 대용량 파일과 링크 상태(남은 횟수·기한·만료). + return json({ ok: true, message: { ...mail, original_html: undefined, previewHtml: previewHtml(mail.html, id, attachments, prefix), attachments, popDeliveries, autoActions: await autoActionsOf(env, id), flagged: flags.includes('\\Flagged'), answered: flags.includes('\\Answered'), largeFiles: mail.direction === 'outbound' ? await listLargeFiles(env, id) : [] } }); + } + // 대용량 파일 떼기(0.9.3): 초안이면 파일 삭제, 보낸 메일이면 링크 끊기(파일 삭제·만료 처리). 업로드는 index.mjs 의 스트리밍 경로(POST /api/files)가 맡습니다. + if (method === 'DELETE' && action === 'files' && sub) { await removeLargeFile(env, sub, mailbox, id); await audit(env, user.login, 'large_file_remove', sub, { message: id }); return json({ ok: true }); } + if (method === 'POST' && action === 'send') { + // body.largeFiles({ days, downloads }, 0.9.3): 초안에 대용량 파일이 있을 때의 발송 조건. 없으면 sendDraft 가 large_file_terms 로 거부합니다. + const b = await request.json().catch(() => ({})); + await sendDraft(env, id, { largeFiles: b && typeof b === 'object' ? b.largeFiles : null }); await audit(env, user.login, 'mail_send', id); if (mail.headers_json && JSON.parse(mail.headers_json)['In-Reply-To']) { const parent = await env.DB.prepare('SELECT id FROM messages WHERE message_id=? AND mailbox=?').bind(JSON.parse(mail.headers_json)['In-Reply-To'], mailbox).first(); if (parent) await setFlag(env, parent.id, '\\Answered', true); } return json({ ok: true }); } + if (method === 'POST' && action === 'attachments') { + // JSON { from, ids }: 다른 메일(전달하는 원본)의 첨부를 이 초안으로 복사합니다(0.8.25~, 웹 "전달" 이 첨부를 함께 보내기 위해). 같은 메일함의 메일만 원본이 될 수 있습니다. + if ((request.headers.get('content-type') || '').includes('application/json')) { + const body = await request.json().catch(() => null); if (!body || typeof body.from !== 'string' || !Array.isArray(body.ids)) return bad('bad_request'); + const origin = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(body.from).first(); if (!origin || !canSee(origin)) return bad('not_found', 404); + const ids = await copyAttachments(env, id, origin.id, body.ids.map(String).slice(0, 50)); + return json({ ok: true, ids }, 201); + } + const form = await request.formData().catch(() => null); if (!form) return bad('bad_request'); + const ids = []; const inline = url.searchParams.get('inline') === '1'; + for (const file of form.getAll('files')) { if (typeof file === 'string') continue; ids.push(await addAttachment(env, id, { filename: file.name, contentType: file.type, bytes: Buffer.from(await file.arrayBuffer()), inline })); } + return json({ ok: true, ids, urls: ids.map((aid) => `${prefix}/api/mail/${encodeURIComponent(id)}/attachments/${encodeURIComponent(aid)}?inline=1`) }, 201); + } + if (method === 'DELETE' && action === 'attachments' && sub) { await removeAttachment(env, id, sub); return json({ ok: true }); } + if (method === 'PATCH' && !action) { + const body = await request.json().catch(() => null); if (!body) return bad('bad_request'); + if (!(await applyAction(env, mail, body, user))) { + // 동작도 아니고 초안 내용도 없는 본문(예: {})은 거부합니다. 예전에는 saveDraft 로 흘러가 받는 사람·제목·본문이 빈 값으로 덮였습니다(0.8.16 수정). + if (!['to', 'cc', 'bcc', 'subject', 'html', 'text'].some((k) => body[k] !== undefined)) return bad('bad_request'); + if (body.html) body.html = stripPrefix(body.html, prefix, env); + await saveDraft(env, body, id, scope); + } + return json({ ok: true, id }); + } + if (method === 'DELETE' && !action) { + if (mail.folder !== 'trash') return bad('mail_trash_first', 409); + await purge(env, mail); await audit(env, user.login, 'mail_delete', id); return json({ ok: true }); + } + return bad('not_found', 404); + } catch (e) { + const status = e.message === 'not_found' ? 404 : e.message === 'bad_request' ? 400 : ['mail_move_invalid', 'mail_sending', 'mail_locked', 'mail_trash_first', 'login_taken', 'cannot_delete_self', 'cannot_modify_self', 'account_limit_reached'].includes(e.message) ? 409 : e.message === 'forbidden' ? 403 : ['signup_disabled', 'large_files_disabled'].includes(e.message) ? 404 : e.message === 'file_too_large' ? 413 : 422; + // weak_password 에는 어긋난 항목(problems)과 정책(policy)을 붙여 화면이 "6자 이상, 숫자 포함" 같은 구체적 안내를 만들 수 있게 합니다. + // 제한 오류(too_many_recipients·attachment_too_large·quota_exceeded·storage_full·monthly_limit_exceeded·limit_exceeds_system·account_limit_reached)에는 적용된 상한(limit)과 항목(field)을 붙입니다. + return bad(e.message || 'server_error', status, { ...(e.providerMessage ? { detail: e.providerMessage } : {}), ...(e.providerStatus ? { providerStatus: e.providerStatus } : {}), ...(e.problems ? { problems: e.problems, policy: e.policy } : {}), ...(e.limit !== undefined ? { limit: e.limit } : {}), ...(e.field ? { field: e.field } : {}) }); + } +} + +/** + * 회원 가입(0.9.0~). 관리자가 켜 두었을 때만 받습니다(아니면 404 signup_disabled). IP 당 1시간 5회로 제한합니다. + * 승인 옵션이 켜져 있으면 승인 대기 계정으로 만들고(로그인 불가, 관리자 페이지에 "승인 대기" 로 보임), 아니면 바로 쓸 수 있는 일반 계정을 만듭니다. + * 역할은 언제나 '사용자' 이고 비밀번호 정책·계정 수 제한은 관리자가 만드는 계정과 같습니다. + */ +async function signup(env, request, ip) { + const cfg = await readSignup(env); if (!cfg.enabled) return bad('signup_disabled', 404); + if (env.RATE.blocked('signup:' + ip, { limit: 5 })) return bad('rate_limited', 429); + const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request'); + const password = String(body.password ?? ''); + if (body.passwordConfirm !== undefined && String(body.passwordConfirm) !== password) return bad('password_mismatch', 422); + env.RATE.hit('signup:' + ip, { limit: 5, windowSec: 3600 }); + const id = await createUser(env, { login: body.login, name: body.name, password, role: 'user' }, 'signup', { pending: cfg.approval }); + await audit(env, null, 'signup', id, { login: clean(body.login, 64), ip, pending: cfg.approval }); + return json({ ok: true, id, pending: cfg.approval }, 201); +} +/** 관리자 설정 응답: 회원 가입·관리자 제한·서비스 스위치 + 시스템 제한(읽기 전용)·계정 수. */ +async function adminConfig(env) { + const sys = systemLimits(env); + // largeFiles / systemLargeFiles / largeFileUsage(0.9.3): 대용량 첨부파일의 관리자 설정, 시스템 상한(읽기 전용), 디스크 사용량. + return { signup: await readSignup(env), limits: await readAdminLimits(env), services: await readServices(env), system: sys, accounts: { used: await accountCount(env), limit: sys.accounts }, largeFiles: await readLargeFileSettings(env), systemLargeFiles: systemLargeFiles(env), largeFileUsage: await largeFileStats(env) }; +} + +/** + * 내장 Haraka → 게이트웨이 수신 전달(1.0.0). Haraka 플러그인(haraka/plugins/arimail.js)이 같은 서버 안에서 로컬 HTTP 로 부릅니다. + * GET /api/inbound/haraka/rcpt?addr=… RCPT 단계: 이 주소가 우리 메일함인지(아니면 Haraka 가 "No such user" 로 바로 거부해 역산란을 막습니다) + * POST /api/inbound/haraka DATA 가 끝난 메일 원본(본문) + X-AriMail-Id(트랜잭션 id) / X-AriMail-From / X-AriMail-Rcpt(JSON 배열) + * 인증: X-AriMail-Token 이 설정 파일의 HARAKA_TOKEN(자동 생성)과 같아야 하고, 수신 방식이 haraka 일 때만 열립니다(그 밖에는 404). + * 응답: 저장하면 200 { imported }, 건너뛰면 200 { skipped }(플러그인이 quota_exceeded/storage_full 은 452 로 되돌려 보낸 쪽이 나중에 다시 시도), 실패는 500(플러그인이 451). + * 기록: webhook_events 에 type 'haraka.received' 로 남겨 상태 화면의 수신 기록과 함께 보입니다. + */ +async function harakaInbound(env, request, path, method) { + // 1.0.7: Haraka 는 서버에 하나이므로 이 엔드포인트도 하나입니다. 토큰은 기본 도메인의 HARAKA_TOKEN(haraka/run.mjs 가 그 설정 파일을 읽음)으로 확인하고, + // 받는 사람 주소의 @ 뒤로 어느 도메인(테넌트)의 메일함인지 고릅니다. 수신 방식이 haraka 인 도메인이 하나도 없으면 예전처럼 404 입니다. + const tenants = env.TENANTS, primary = tenants?.primary?.env || env; + const receivers = tenants ? tenants.envs().filter((e) => inboundMode(e) === 'haraka') : (inboundMode(env) === 'haraka' ? [env] : []); + if (!receivers.length) return bad('not_found', 404); + const token = request.headers.get('x-arimail-token') || ''; + if (!primary.HARAKA_TOKEN || !timingEqual(String(token), String(primary.HARAKA_TOKEN))) return bad('unauthenticated', 401); + /** 주소가 속한 도메인의 env(수신 방식이 haraka 인 것만). 테넌트가 없으면(시험 등) 이 env. */ + const pick = (addr) => { if (!tenants) return receivers[0]; const t = tenants.byAddress(addr); return t && t.entry.enabled !== false && inboundMode(t.env) === 'haraka' ? t.env : null; }; + if (path[2] === 'rcpt' && method === 'GET') { const addr = bareAddress(new URL(request.url).searchParams.get('addr') || ''); const target = pick(addr); return json({ ok: true, local: !!target && (await mailboxList(target)).includes(addr) }); } + if (!path[2] && method === 'POST') { + const raw = Buffer.from(await request.arrayBuffer()); if (!raw.length) return bad('bad_request'); + let rcpt = []; try { rcpt = JSON.parse(request.headers.get('x-arimail-rcpt') || '[]'); } catch { rcpt = []; } + if (!Array.isArray(rcpt)) rcpt = []; + const id = cleanStr(request.headers.get('x-arimail-id'), 120) || newId(), stamp = nowIso(), from = request.headers.get('x-arimail-from') || ''; + // 받는 사람을 도메인별로 묶어 각 도메인의 DB 에 저장합니다(한 트랜잭션이 두 도메인의 주소를 담을 수 있음). 도메인을 못 찾는 주소는 첫 수신 도메인으로(예전 동작). + const groups = new Map(); + for (const a of rcpt) { const target = pick(bareAddress(a)) || receivers[0]; if (!groups.has(target)) groups.set(target, []); groups.get(target).push(a); } + if (!groups.size) groups.set(receivers[0], []); + const record = (target, status, detail) => target.DB.prepare('INSERT OR REPLACE INTO webhook_events(id,type,provider_id,received_at,status,detail) VALUES(?,?,?,?,?,?)').bind('haraka:' + id, 'haraka.received', 'haraka:' + id, stamp, status, detail || null).run(); + const results = []; let failed = null; + for (const [target, list] of groups) { + try { + const r = await ingestRawMail(target, raw, { id, rcpt: list, from, known: await mailboxList(target) }); + // 같은 트랜잭션이 다시 오면(Haraka 재시도) 이미 'stored' 로 남은 기록을 'skipped' 로 덮어쓰지 않습니다. + if (r.skipped !== 'exists') await record(target, r.imported ? 'stored' : 'skipped', r.skipped || null); + results.push(r); + } catch (e) { console.error('Haraka 수신 저장 실패', target.DOMAIN || '', id, e.message); await record(target, 'failed', String(e.message || 'ingest_failed').slice(0, 300)); failed = failed || e; } + } + const imported = results.reduce((n, r) => n + Number(r.imported || 0), 0); + if (failed && !imported) return bad(failed.message || 'ingest_failed', 500); + // 도메인이 하나면 예전과 같은 모양({ ok, imported, id, mailbox, … }), 여럿이면 합계와 도메인별 결과. + if (results.length === 1) return json({ ok: true, ...results[0] }); + const skipped = imported ? undefined : results.find((r) => r.skipped)?.skipped; + return json({ ok: true, imported, ...(skipped ? { skipped } : {}), results }); + } + return bad('not_found', 404); +} +async function webhook(env, request) { + const raw = Buffer.from(await request.arrayBuffer()); + const headers = { 'svix-id': request.headers.get('svix-id'), 'svix-timestamp': request.headers.get('svix-timestamp'), 'svix-signature': request.headers.get('svix-signature') }; + if (!env.RESEND_WEBHOOK_SECRET) { console.error('웹훅 비밀키 없음 — RESEND_WEBHOOK_SECRET 를 설정하세요'); return bad('webhook_not_configured', 503); } + const check = verifySvix(env.RESEND_WEBHOOK_SECRET, headers, raw); + if (!check.ok) return bad(check.error, 401); + let event; try { event = JSON.parse(raw.toString('utf8')); } catch { return bad('bad_request'); } + const eventId = headers['svix-id'] || uid(), stamp = now(); + const type = String(event?.type || ''), data = event?.data || {}; + // 같은 svix-id 는 한 번만 처리합니다. 단, 저장에 실패해 500 을 돌려준 이벤트('failed')는 Resend 가 같은 id 로 + // 다시 보내므로 중복으로 보지 않고 다시 처리합니다(예전에는 재시도가 duplicate 로 끝나 메일이 저장되지 않았습니다). + if (await env.DB.prepare("SELECT id FROM webhook_events WHERE id=? AND status<>'failed'").bind(eventId).first()) return json({ ok: true, duplicate: true }); + const record = (status, detail) => env.DB.prepare('INSERT OR REPLACE INTO webhook_events(id,type,provider_id,received_at,status,detail) VALUES(?,?,?,?,?,?)').bind(eventId, type, data.email_id || null, stamp, status, detail || null).run(); + if (type !== 'email.received') { await record('ignored', 'type'); return json({ ok: true, ignored: type }); } + const known = await knownMailboxes(env); + if (!addresses([...(data.to || []), ...(data.cc || []), ...(data.bcc || []), ...(data.received_for || [])]).some((a) => known.includes(a))) { await record('ignored', 'other_mailbox'); return json({ ok: true, ignored: 'other_mailbox' }); } + try { + const result = await ingestReceived(env, data.email_id, { source: 'webhook', known, summary: { from: data.from, to: data.to, subject: data.subject, created_at: data.created_at, attachments: data.attachments } }); + await record(result.imported ? 'stored' : 'skipped', result.skipped || result.raw); + return json({ ok: true, ...result }); + } catch (e) { + // 5xx 를 돌려주면 Resend 가 재시도합니다. 실패 이유는 기록만 남깁니다. + await record('failed', (e.message + ' ' + (e.providerMessage || '')).slice(0, 300)); + return bad(e.message || 'ingest_failed', 500); + } +} + +/** + * 웹 메일 설정 화면용 정보: 이 계정으로 메일 프로그램을 설정할 때 필요한 값(IMAP·SMTP·POP 호스트·포트·TLS 상태)과 사용자 설정. + * 서버 운영 정보(웹훅·동기화·저장 통계)는 0.8.4 부터 관리자 페이지의 adminStatus 로 옮겼습니다. + * 0.9.0: services(관리자가 POP/IMAP 서비스 자체를 껐는지), limits(첨부 한 개 최대 MIME 크기·받는 사람 수), quota(메일함 최대 크기·사용량), + * canChangePassword(게이트웨이 계정이면 설정 화면에서 비밀번호를 바꿀 수 있음)를 더했습니다. + */ +async function settings(env, user) { + // host: 웹 메일 주소. serverHost(1.0.7): 메일 프로그램이 접속할 IMAP·POP·SMTP 서버 이름 — 서버에 하나뿐인 메일 포트·인증서는 기본 도메인의 호스트 이름이므로 + // 추가 도메인의 사용자도 그 이름으로 접속합니다(자기 도메인 이름으로 접속하면 인증서 이름이 맞지 않음). + const host = env.PUBLIC_HOST || 'mail.arihakdang.com', serverHost = env.MAIL_SERVER_HOST || host; + const plainOk = await plainLoginAllowed(env); + return json({ + ok: true, mailbox: user.mailbox, user: { login: user.login, name: user.name, role: user.role, source: user.source }, host, prefs: await readPrefs(env, user), + // 보관 정책: 0.8.0 부터 기한 없음. 화면은 이 값으로 "삭제할 때까지 보관" 문구를 보여 줍니다. + retention: 'unlimited', + services: await readServices(env), limits: { attachmentBytes: await attachmentLimit(env), recipients: await recipientLimit(env) }, + // managedBy(1.0.8): 비밀번호를 어디서 바꾸는지(local 이면 여기, www / webmaker 면 그 서비스). 화면은 이 값으로 안내 문구를 고릅니다. + quota: { usageBytes: await mailboxUsage(env, user.mailbox), limit: await mailboxQuota(env, user.mailbox) }, canChangePassword: user.source === 'local', managedBy: user.source, policy: await readPasswordPolicy(env), largeFiles: await readLargeFileSettings(env), + // SMTP(보내는 메일 서버): 이 게이트웨이의 제출 서버(smtp.mjs). 계정·비밀번호는 IMAP·POP 과 같은 로그인 정보이고, + // 발송 서비스의 API 키는 서버 내부에만 있으므로 화면에 키 관련 정보(설정 여부 포함)를 내보내지 않습니다. + smtp: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { smtps: Number(env.SMTPS_PORT || 465), starttls: Number(env.SMTP_PORT || 587) }, from: `${user.name || env.MAIL_FROM_NAME || 'Ari Hak Dang'} <${user.mailbox}>` }, + // plaintext(0.9.7): 110/143 에서 TLS 없이 로그인할 수 있는지(관리자 설정 "평문 로그인 허용" 또는 POP_ALLOW_PLAINTEXT=1). 화면이 110 포트 줄에 표시합니다. + pop: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { pop3s: Number(env.POPS_PORT || 995), starttls: Number(env.POP_PORT || 110) }, plaintext: plainOk }, + imap: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { imaps: Number(env.IMAPS_PORT || 993), starttls: Number(env.IMAP_PORT || 143) }, plaintext: plainOk, mailboxes: ['INBOX', 'Sent', 'Drafts', 'Trash'] }, + }); +} +/** + * 관리자 페이지 "서버 상태": 발송 서비스 웹훅·동기화 기록·저장 통계·POP 전달 기록·서비스 포트 상태. 관리자 세션으로만 볼 수 있습니다. + * 0.9.0: stats(메일 현황 — 일·주·월 발신/수신/수발신, 메일함별·전체 사용량, 시스템 제한 대비 사용 비율)와 services(POP/IMAP 스위치)를 더했습니다. + */ +async function adminStatus(env) { + const last = (await env.DB.prepare('SELECT * FROM sync_runs ORDER BY id DESC LIMIT 5').all()).results; + const webhooks = (await env.DB.prepare("SELECT status,COUNT(*) n,MAX(received_at) last FROM webhook_events GROUP BY status").all()).results; + const counts = await env.DB.prepare("SELECT COUNT(*) total,SUM(CASE WHEN raw_status='stored' THEN 1 ELSE 0 END) raw_stored,SUM(CASE WHEN raw_status='built' THEN 1 ELSE 0 END) raw_built,SUM(CASE WHEN source='webhook' THEN 1 ELSE 0 END) via_webhook,SUM(CASE WHEN source='sync' THEN 1 ELSE 0 END) via_sync,SUM(CASE WHEN provider_id LIKE 'haraka:%' THEN 1 ELSE 0 END) via_haraka FROM messages WHERE direction='inbound'").first(); + const perMailbox = (await env.DB.prepare('SELECT mailbox,COUNT(*) n,COALESCE(SUM(raw_size),0) bytes FROM messages GROUP BY mailbox ORDER BY mailbox').all()).results; + const pop = await env.DB.prepare('SELECT COUNT(*) n,COUNT(DISTINCT user_id) accounts,MAX(delivered_at) last FROM pop_deliveries').first(); + const host = env.PUBLIC_HOST || 'mail.arihakdang.com'; + return json({ + ok: true, host, version: env.APP_VERSION || null, mailbox: mailboxOf(env), tls: !!env.POP_TLS_ACTIVE, retention: 'unlimited', + ports: { pop3s: Number(env.POPS_PORT || 995), pop3: Number(env.POP_PORT || 110), imaps: Number(env.IMAPS_PORT || 993), imap: Number(env.IMAP_PORT || 143), smtps: Number(env.SMTPS_PORT || 465), submission: Number(env.SMTP_PORT || 587) }, + services: await readServices(env), plainLogin: await plainLoginAllowed(env), + // 1.0.0: 수·발신 방식과 발신 서비스 상태(60초 캐시), Haraka 상태(haraka/run.mjs 가 쓰는 상태 파일), 설정 파일 경로. + mail: { inbound: inboundMode(env), outbound: outboundMode(env), status: await outboundStatus(env).catch((e) => ({ provider: e.message })), haraka: usesHaraka(env) ? harakaStatus(env) : null, configFile: configFile(env) }, + webhook: { url: `https://${host}/api/webhooks/resend`, secretConfigured: !!env.RESEND_WEBHOOK_SECRET, events: webhooks }, + sync: { intervalMinutes: Math.round(Number(env.SYNC_INTERVAL_MS || 3600000) / 60000), running: isSyncing(env), runs: last }, + storage: counts, mailboxes: perMailbox, pop: { deliveries: pop }, + stats: await mailStats(env, await knownMailboxes(env), await listUsers(env)), largeFiles: await largeFileStats(env), + }); +} diff --git a/modules/arimail/server/auth.mjs b/modules/arimail/server/auth.mjs new file mode 100644 index 0000000..e5d3c51 --- /dev/null +++ b/modules/arimail/server/auth.mjs @@ -0,0 +1,416 @@ +/** + * 인증. + * - www.arihakdang.com 관리자 계정: www DB(users)를 읽기 전용으로 읽어 login_id/email + PBKDF2(www 와 같은 형식)로 검사합니다. + * 이 계정은 게이트웨이의 관리자(계정 관리 가능)이며 기본 메일함(MAILBOX)을 씁니다. 별칭 admin / admin@... 도 여기로 연결됩니다. + * - 게이트웨이 사용자 계정(mail_users): 관리자가 만들거나(0.9.0 부터는 회원 가입으로도) 생기는 계정. 자기 메일함 주소를 갖고 웹·POP·IMAP·SMTP 에 같은 비밀번호로 접속합니다. + * - 0.9.0 부터 관리자도 메일 기능에서는 자기 메일함만 봅니다(다른 메일함으로 전환하는 기능 삭제). 관리자 권한은 관리자 페이지(/admin)의 계정·설정 관리에만 쓰입니다. + * - webmaker 연동(1.0.8, LOGIN_MODE=webmaker): 웹메이커의 사용자 파일을 읽기 전용으로 참조해 같은 방식(scrypt)으로 검사합니다(server/webmaker.mjs). + * 웹메이커 계정이 먼저이고, 그다음 게이트웨이 계정(mail_users)을 봅니다. 웹·POP·IMAP·SMTP 모두 이 verifyUser 를 거치므로 어디서나 같은 비밀번호입니다. + */ +import { openDb } from './db.mjs'; +import { json, now, plus, secret, sha256, safeEqual, clean, uid, isEmail, addresses } from './util.mjs'; +import { systemLimits, accountCount, clearUserQuota } from './limits.mjs'; +import { isWebmaker, verifyWebmaker, webmakerPrincipalById, webmakerByMailbox, webmakerMailboxes, webmakerAccounts, webmakerHasLogin } from './webmaker.mjs'; + +export const SESSION_COOKIE = 'mg_session'; +// 관리자 페이지(/admin)는 웹 메일과 별도의 로그인·세션을 씁니다(0.8.4~). 쿠키 이름이 달라 두 로그인이 서로 영향을 주지 않습니다. +export const ADMIN_COOKIE = 'mg_admin'; +// 세션 범위: 'mail' = 웹 메일(/), 'admin' = 관리자 페이지(/admin). 범위가 다른 세션 쿠키로는 상대편 API 를 쓸 수 없습니다. +export const SCOPES = ['mail', 'admin']; +const cookieName = (scope) => scope === 'admin' ? ADMIN_COOKIE : SESSION_COOKIE; +const SESSION_DAYS = 7; +/** 메일함 주소의 도메인 부분(예: arihakdang.com). 계정의 메일함은 항상 "로그인 ID@이 도메인" 입니다. */ +export const mailDomain = (env) => mailboxOf(env).split('@')[1]; +const mailboxOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase(); +/** + * 독립 실행(standalone) 여부(0.8.18~). www 사이트 DB(WWW_DB_FILE / 테스트의 WWW_DB)가 없으면 독립 실행입니다. + * 독립 실행에서는 www 관리자가 없으므로 기본 메일함(MAILBOX)도 게이트웨이 계정(mail_users)이 갖습니다: + * - 로그인 ID 가 기본 메일함의 앞부분(예: admin)인 계정을 만들 수 있고, 그 계정의 메일함이 곧 기본 메일함입니다. + * - 첫 관리자 계정은 화면에서 만들 수 없으므로 server/user-cli.mjs 로 만듭니다. + */ +export const standalone = (env) => !env.WWW_DB && !env.WWW_DB_FILE; +/** 로그인 방식 이름(1.0.8): 'webmaker'(웹메이커 계정 연동) | 'www'(웹 서비스 DB 연동) | 'local'(자체 계정만). 화면·API 안내용. */ +export const authMode = (env) => isWebmaker(env) ? 'webmaker' : standalone(env) ? 'local' : 'www'; + +async function pbkdf2(password, saltB64, iterations) { + const salt = Uint8Array.from(atob(saltB64), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, key, 256); + return btoa(String.fromCharCode(...new Uint8Array(bits))); +} +export async function verifyPassword(password, stored) { + if (!stored) return false; + const [scheme, iterations, salt, digest] = String(stored).split('$'); + const n = Number(iterations); + if (scheme !== 'pbkdf2' || !salt || !digest || !Number.isInteger(n) || n < 1000 || n > 5000000) return false; + return safeEqual(await pbkdf2(password, salt, n), digest); +} +// 없는 계정의 로그인 시도에 쓰는 더미 해시(프로세스마다 한 번 만들어 재사용). 반복 횟수가 실제 계정과 같아 걸리는 시간이 같습니다. +let dummy = null; const dummyHash = async () => (dummy ??= await hashPassword(crypto.randomUUID())); +export async function hashPassword(password, iterations = 210000) { + const salt = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16)))); + return `pbkdf2$${iterations}$${salt}$${await pbkdf2(password, salt, iterations)}`; +} + +// ------------------------------------------------------------------ www 관리자 +// www DB 핸들은 파일 경로별로 하나씩 둡니다(1.0.7: 도메인마다 다른 웹 서비스 DB 와 연동할 수 있으므로 모듈 전역 하나로는 부족). +const wwwDbs = new Map(); // WWW_DB_FILE → DatabaseSync(읽기 전용) +function wwwHandle(env) { + if (env.WWW_DB) return env.WWW_DB; + if (!env.WWW_DB_FILE) throw new Error('www_db_not_configured'); + let db = wwwDbs.get(env.WWW_DB_FILE); + if (!db) { db = openDb(env.WWW_DB_FILE, { readOnly: true }); wwwDbs.set(env.WWW_DB_FILE, db); } + return db; +} +function query(env, sql, params) { + try { return wwwHandle(env).prepare(sql).get(...params); } + catch { + // 배포 중 www DB 파일이 교체되면 핸들이 깨질 수 있어 한 번 닫고 다시 엽니다. + if (!env.WWW_DB && env.WWW_DB_FILE) { try { wwwDbs.get(env.WWW_DB_FILE)?.close(); } catch {} wwwDbs.delete(env.WWW_DB_FILE); } + return wwwHandle(env).prepare(sql).get(...params); + } +} +const COLS = 'SELECT id,email,login_id,name,role,status,password_hash FROM users'; +/** 메일함 주소(admin@...)와 그 앞부분(admin)은 메일함 소유 관리자 계정의 별칭입니다. + * 소유자는 MAILBOX_OWNER_LOGIN → 이메일이 메일함 주소인 관리자 → 유일한 활성 관리자 순입니다. */ +export function mailboxOwner(env) { + const mailbox = mailboxOf(env), configured = clean(env.MAILBOX_OWNER_LOGIN, 200).toLowerCase(); + if (configured) return query(env, `${COLS} WHERE ${configured.includes('@') ? 'email' : 'login_id'} = ?`, [configured]) || null; + const byEmail = query(env, `${COLS} WHERE email = ?`, [mailbox]); if (byEmail) return byEmail; + const admins = wwwHandle(env).prepare(`${COLS} WHERE role = 'admin' AND status = 'active'`).all(); + return admins.length === 1 ? admins[0] : null; +} +export function isMailboxAlias(env, login) { const mailbox = mailboxOf(env), key = clean(login, 200).toLowerCase(); return key === mailbox || key === mailbox.split('@')[0]; } +const wwwPrincipal = (row, env) => ({ id: 'www:' + row.id, source: 'www', login: row.login_id || row.email, email: row.email, name: row.name || '', role: 'admin', mailbox: mailboxOf(env) }); +const localPrincipal = (row) => ({ id: row.id, source: 'local', login: row.login, email: row.mailbox, name: row.name || '', role: row.role, mailbox: row.mailbox }); + +/** 로그인 이름과 비밀번호로 사용자를 찾습니다. www 관리자(별칭 포함) → 게이트웨이 계정 순. 실패 사유는 나누지 않습니다. */ +export async function verifyUser(env, login, password) { + const key = clean(login, 200).toLowerCase(); + if (!key || !password) return null; + // webmaker 연동(1.0.8): 웹메이커 계정이 먼저입니다. 같은 로그인 ID 의 게이트웨이 계정이 있어도(createUser 가 막지만 연동 전에 만든 것) 웹메이커 쪽이 그 주소의 주인입니다. + // 웹메이커 계정이 아니거나 비밀번호가 틀리면 아래 게이트웨이 계정으로 넘어갑니다(관리자가 따로 만든 로컬 계정도 함께 쓸 수 있게). + if (isWebmaker(env)) { const wm = await verifyWebmaker(env, key, password); if (wm) return wm; } + let row = null; + try { + row = isMailboxAlias(env, key) ? mailboxOwner(env) : key.includes('@') ? query(env, `${COLS} WHERE email = ?`, [key]) : query(env, `${COLS} WHERE login_id = ?`, [key]); + } catch (e) { if (e.message !== 'www_db_not_configured') throw e; } + if (row && row.role === 'admin' && row.status === 'active' && (await verifyPassword(password, row.password_hash))) return wwwPrincipal(row, env); + const local = await env.DB.prepare('SELECT * FROM mail_users WHERE (login = ? OR mailbox = ?) AND status = ?').bind(key, key, 'active').first(); + if (local && (await verifyPassword(password, local.password_hash))) { await env.DB.prepare('UPDATE mail_users SET last_login_at = ? WHERE id = ?').bind(now(), local.id).run(); return localPrincipal(local); } + // 시간 차이로 계정 존재 여부가 드러나지 않게 합니다. 예전에는 verifyPassword(password, null) 을 불렀는데 저장값이 없으면 해시 없이 바로 false 라 + // 없는 계정은 0.2ms, 있는 계정은 100ms 넘게 걸려 로그인 ID 를 추측할 수 있었습니다(0.8.24). 이제 더미 해시로 실제 PBKDF2 를 돕니다. + if (!row && !local) await verifyPassword(password, await dummyHash()); + return null; +} +/** @deprecated verifyUser 로 대체. 기존 호출부 호환. */ +export const verifyAdmin = verifyUser; + +export async function principalById(env, id) { + if (String(id).startsWith('wm:')) return isWebmaker(env) ? await webmakerPrincipalById(env, String(id)) : null; + if (String(id).startsWith('www:')) { const row = query(env, `${COLS} WHERE id = ?`, [String(id).slice(4)]); return row && row.role === 'admin' && row.status === 'active' ? wwwPrincipal(row, env) : null; } + const local = await env.DB.prepare("SELECT * FROM mail_users WHERE id = ? AND status = 'active'").bind(id).first(); + return local ? localPrincipal(local) : null; +} +// ------------------------------------------------------------------ 비밀번호 정책 (0.8.24~) +// 예전에는 "10자 이상" 이 코드에 고정돼 있었습니다. 이제 관리자가 관리자 페이지에서 정하며 settings 표의 password_policy 키에 JSON 으로 저장합니다. +// minLength : 최소 글자 수. 6 ~ 64. 기본 6 (요구사항: 최소 6자 이상, 관리자가 조정) +// mixedCase : true 면 영문 대문자와 소문자를 모두 포함해야 함 +// digit : true 면 숫자를 포함해야 함 +// special : true 면 특수문자(영문·숫자·공백이 아닌 글자)를 포함해야 함 +export const DEFAULT_PASSWORD_POLICY = { minLength: 6, mixedCase: false, digit: false, special: false }; +export const PASSWORD_MIN_FLOOR = 6, PASSWORD_MIN_CEIL = 64; +/** 독립 실행의 초기 관리자 비밀번호. 정책과 무관하게 글자 수 하한(6)만 만족하면 되고, 로그인 뒤 바꾸라고 안내합니다. */ +export const INITIAL_ADMIN_PASSWORD = 'adminpasswd'; +const POLICY_KEY = 'password_policy'; +/** 저장된 정책을 기본값과 합쳐 돌려줍니다. 손상된 값은 기본값으로 대체합니다. */ +export async function readPasswordPolicy(env) { + const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind(POLICY_KEY).first(); + let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; } + const p = { ...DEFAULT_PASSWORD_POLICY, ...(stored && typeof stored === 'object' ? stored : {}) }; + p.minLength = Number.isInteger(p.minLength) && p.minLength >= PASSWORD_MIN_FLOOR && p.minLength <= PASSWORD_MIN_CEIL ? p.minLength : DEFAULT_PASSWORD_POLICY.minLength; + for (const k of ['mixedCase', 'digit', 'special']) p[k] = !!p[k]; + return p; +} +/** + * 정책을 저장합니다(관리자 전용). minLength 는 6~64 의 정수여야 하며(아니면 policy_invalid), 나머지는 불리언으로 바꿉니다. + * 이미 있는 계정의 비밀번호에는 소급 적용하지 않습니다(새 비밀번호를 정할 때만 검사). + */ +export async function writePasswordPolicy(env, patch) { + const current = await readPasswordPolicy(env), next = { ...current }; + if (patch.minLength !== undefined) { const n = Number(patch.minLength); if (!Number.isInteger(n) || n < PASSWORD_MIN_FLOOR || n > PASSWORD_MIN_CEIL) throw new Error('policy_invalid'); next.minLength = n; } + for (const k of ['mixedCase', 'digit', 'special']) if (patch[k] !== undefined) next[k] = !!patch[k]; + await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(POLICY_KEY, JSON.stringify(next), now()).run(); + return next; +} +/** 비밀번호가 정책에 어긋나는 항목 목록(비어 있으면 통과): 'length' | 'mixedCase' | 'digit' | 'special'. 화면이 같은 규칙으로 안내문을 만듭니다. */ +export function passwordProblems(password, policy) { + const pw = String(password ?? ''), problems = []; + if ([...pw].length < policy.minLength) problems.push('length'); + if (policy.mixedCase && !(/[A-Z]/.test(pw) && /[a-z]/.test(pw))) problems.push('mixedCase'); + if (policy.digit && !/[0-9]/.test(pw)) problems.push('digit'); + if (policy.special && !/[^A-Za-z0-9\s]/.test(pw)) problems.push('special'); + return problems; +} +/** 정책 검사. 어긋나면 weak_password 오류를 던지며, 오류 객체에 problems(어긋난 항목)와 policy 를 붙여 API 가 화면에 그대로 전달합니다. */ +async function checkPassword(env, password) { + const policy = await readPasswordPolicy(env), problems = passwordProblems(password, policy); + if (problems.length) { const e = new Error('weak_password'); e.problems = problems; e.policy = policy; throw e; } +} +// 초기 비밀번호 표시: settings 의 initial_password:<계정 id> 키가 있으면 그 계정은 아직 초기 비밀번호를 씁니다. 비밀번호를 바꾸면 지웁니다. +const initialKey = (id) => 'initial_password:' + id; +export async function usesInitialPassword(env, user) { return !!(await env.DB.prepare('SELECT 1 AS x FROM settings WHERE key=?').bind(initialKey(user.id)).first()); } +/** + * 독립 실행의 초기 관리자 계정(0.8.24~). 서버가 시작할 때 부르며, 독립 실행이고 게이트웨이 계정이 하나도 없을 때만 + * 기본 메일함의 앞부분(보통 admin)을 로그인 ID 로, 비밀번호 adminpasswd 로 관리자 계정을 만듭니다. + * 예전에는 첫 관리자를 user-cli 로만 만들 수 있었고 "10자 이상" 규칙 때문에 admin 같은 짧은 초기 비밀번호가 거부돼 설치가 막혔습니다. + * @param {object} [o] password: 초기 비밀번호(기본 adminpasswd; 1.0.7 도메인 추가 폼에서 지정) + * @returns {Promise<{login:string, password:string}|null>} 만들었으면 계정 정보(시작 로그에 안내용), 아니면 null + */ +export async function ensureInitialAdmin(env, { password = INITIAL_ADMIN_PASSWORD } = {}) { + // webmaker 연동(1.0.8)에서는 웹메이커의 관리자(admin)가 곧 이 도메인의 관리자이므로 adminpasswd 계정을 만들지 않습니다. + if (!standalone(env) || isWebmaker(env)) return null; + const count = await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first(); + if (Number(count?.n || 0) > 0) return null; + const login = mailboxOf(env).split('@')[0]; + // password(1.0.7): 전체 설정 화면에서 도메인을 추가할 때 지정한 초기 비밀번호. 기본값은 예전과 같은 adminpasswd. + const id = await createUser(env, { login, name: '관리자', password, role: 'admin' }, 'system', { initial: true }); + await audit(env, 'system', 'user_create', id, { login, initial: true }); + return { login, password }; +} + +// ------------------------------------------------------------------ 게이트웨이 계정 관리 (관리자 전용) +const LOGIN_RE = /^[a-z0-9][a-z0-9._-]{1,63}$/; +// 승인 대기(0.9.0~): 회원 가입으로 만들어졌지만 관리자 승인 전인 계정. mail_users.status 는 'suspended'(로그인 불가)로 두고 +// settings 의 pending:<계정 id> 키로 "정지" 가 아니라 "승인 대기" 임을 표시합니다. 스키마의 status 제약(active/suspended)을 바꾸지 않으려는 선택이며, +// 로그인·세션·메일함 주인 판단은 모두 status='active' 만 보므로 대기 계정은 어디서도 들어올 수 없습니다. 승인 = status 를 active 로 바꾸고 이 키를 지움. +const pendingKey = (id) => 'pending:' + id; +/** 계정 목록. 승인 대기 계정은 status 를 'pending' 으로 바꿔 돌려줍니다(화면·API 용; DB 값은 suspended). */ +export async function listUsers(env) { + const rows = (await env.DB.prepare(`SELECT u.id,u.login,u.name,u.mailbox,u.role,CASE WHEN p.key IS NULL THEN u.status ELSE 'pending' END AS status,u.created_by,u.created_at,u.updated_at,u.last_login_at + FROM mail_users u LEFT JOIN settings p ON p.key = 'pending:' || u.id ORDER BY u.created_at`).all()).results; + return rows; +} +/** + * 게이트웨이 계정을 만듭니다(관리자 페이지·회원 가입·명령줄 도구). + * 메일함 주소는 따로 받지 않고 항상 "로그인 ID@도메인"(예: info → info@arihakdang.com)으로 정합니다(0.8.4~). + * 예전에는 메일함 주소를 직접 입력해 여러 계정이 한 메일함을 함께 쓸 수 있었지만, 계정마다 자기 주소를 갖는 편이 + * 받는 사람 주소와 로그인 ID 가 일치해 혼동이 없습니다. 기존 계정의 메일함 주소는 바꾸지 않습니다. + * 비밀번호는 관리자가 정한 정책(readPasswordPolicy)으로 검사합니다(0.8.24~, 예전에는 10자 이상 고정). + * 시스템 설정의 계정 수 제한(LIMIT_ACCOUNTS, 0.9.0~)에 이르렀으면 account_limit_reached 를 던집니다(정지·대기 계정도 셉니다). + * @param {object} body { login, name?, password, role?, status? } — body.mailbox 는 무시합니다. + * @param {string} actor 만든 주체(관리자 로그인 이름, 'signup', 'user-cli', 'system')(기록용) + * @param {object} [options] + * @param {boolean} [options.initial] 초기 관리자 계정: 정책 대신 글자 수 하한(6)만 보고, 초기 비밀번호 표시를 남깁니다. + * @param {boolean} [options.pending] 회원 가입 승인 대기 계정으로 만듭니다(관리자가 승인하기 전에는 로그인 불가). + * @returns {Promise} 새 계정 id + */ +export async function createUser(env, body, actor, { initial = false, pending = false } = {}) { + const login = clean(body.login, 64).toLowerCase(), password = String(body.password ?? ''); + // 기본 메일함의 별칭(admin)은 www 관리자 몫이라 막지만, 독립 실행에서는 그 메일함을 가질 게이트웨이 계정이 필요하므로 허용합니다. + if (!LOGIN_RE.test(login) || (isMailboxAlias(env, login) && !standalone(env))) throw new Error('invalid_login'); + const mailbox = `${login}@${mailDomain(env)}`; + if (!isEmail(mailbox)) throw new Error('invalid_login'); + if (initial) { if ([...password].length < PASSWORD_MIN_FLOOR) throw new Error('weak_password'); } else await checkPassword(env, password); + if (await env.DB.prepare('SELECT id FROM mail_users WHERE login = ?').bind(login).first()) throw new Error('login_taken'); + // webmaker 연동(1.0.8): 웹메이커 계정과 같은 로그인 ID 는 만들 수 없습니다(그 주소의 주인은 웹메이커 계정이고 verifyUser 도 그쪽을 먼저 봄). + if (isWebmaker(env) && (await webmakerHasLogin(env, login))) throw new Error('login_taken'); + const limit = systemLimits(env).accounts; + if (limit > 0 && (await accountCount(env)) >= limit) { const e = new Error('account_limit_reached'); e.limit = limit; throw e; } + const id = uid(), stamp = now(); + await env.DB.prepare('INSERT INTO mail_users(id,login,name,mailbox,role,status,password_hash,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)') + .bind(id, login, clean(body.name, 80), mailbox, body.role === 'admin' ? 'admin' : 'user', pending || body.status === 'suspended' ? 'suspended' : 'active', await hashPassword(password), actor || null, stamp, stamp).run(); + const mark = (key) => env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(key, '1', stamp).run(); + if (initial) await mark(initialKey(id)); + if (pending) await mark(pendingKey(id)); + return id; +} +/** 승인 대기 계정인지(settings 의 pending: 키). */ +export async function isPending(env, id) { return !!(await env.DB.prepare('SELECT 1 AS x FROM settings WHERE key=?').bind(pendingKey(id)).first()); } +/** + * 게이트웨이 계정을 수정합니다(관리자 전용). 이름·역할·상태·비밀번호만 바꿀 수 있고 메일함 주소는 로그인 ID 에 묶여 있어 바꾸지 않습니다. + * body.services({ pop, imap }) 가 있으면 그 계정의 메일 프로그램 접속 허용 여부(prefs)도 함께 저장합니다. + * body.status 를 'active' 로 두면 승인 대기 계정은 승인됩니다(대기 표시를 지움). 'pending' 은 받지 않습니다(대기는 가입 때만 생김). + */ +export async function updateUser(env, id, body) { + const row = await env.DB.prepare('SELECT * FROM mail_users WHERE id = ?').bind(id).first(); if (!row) throw new Error('not_found'); + // 역할·상태는 아는 값만 받습니다. 예전에는 'pending' 같은 다른 값이 모두 'active' 로 바뀌어 승인 대기 계정이 뜻하지 않게 승인됐습니다(0.9.3). + if (body.role !== undefined && !['admin', 'user'].includes(body.role)) throw new Error('validation'); + if (body.status !== undefined && !['active', 'suspended'].includes(body.status)) throw new Error('validation'); + const next = { name: body.name !== undefined ? clean(body.name, 80) : row.name, mailbox: row.mailbox, role: body.role !== undefined ? body.role : row.role, status: body.status !== undefined ? body.status : row.status }; + if (body.services && typeof body.services === 'object') await writePrefs(env, { id }, body.services); + let hash = row.password_hash; + if (body.password !== undefined && body.password !== '') { await checkPassword(env, String(body.password)); hash = await hashPassword(String(body.password)); } + await env.DB.prepare('UPDATE mail_users SET name=?,role=?,status=?,password_hash=?,updated_at=? WHERE id=?').bind(next.name, next.role, next.status, hash, now(), id).run(); + if (hash !== row.password_hash || next.status === 'suspended') await env.DB.prepare('DELETE FROM sessions WHERE user_id = ?').bind(id).run(); + // 비밀번호를 바꿨으면 "초기 비밀번호 사용 중" 표시를 지웁니다(initial_password:). + if (hash !== row.password_hash) await env.DB.prepare('DELETE FROM settings WHERE key = ?').bind(initialKey(id)).run(); + // 상태를 명시해 바꾸면 승인 대기 표시를 지웁니다: '사용' = 승인, '정지' = 대기가 아니라 정지 계정으로 둠(예전에는 정지로 바꿔도 "승인 대기" 로 남았습니다, 0.9.3). + if (body.status !== undefined) await env.DB.prepare('DELETE FROM settings WHERE key = ?').bind(pendingKey(id)).run(); +} +/** + * 로그인에 실패한 뒤 "승인 대기 계정이라서" 인지 알아봅니다(0.9.0~). 로그인 ID 와 비밀번호가 모두 맞는 대기 계정일 때만 true 이므로 + * 계정 존재 여부가 새어 나가지 않습니다. API 는 이 경우 invalid_credentials 대신 pending_approval(403)을 돌려줍니다. + */ +export async function pendingLogin(env, login, password) { + const key = clean(login, 200).toLowerCase(); if (!key || !password) return false; + const row = await env.DB.prepare("SELECT u.id,u.password_hash FROM mail_users u JOIN settings p ON p.key = 'pending:' || u.id WHERE (u.login = ? OR u.mailbox = ?) AND u.status = 'suspended'").bind(key, key).first(); + return !!row && (await verifyPassword(password, row.password_hash)); +} +/** + * 자기 비밀번호를 바꿀 때 현재 비밀번호가 맞는지 확인합니다(0.8.25~). 게이트웨이 계정만 대상입니다(www 관리자는 www 사이트에서 관리). + * 세션을 훔친 사람이 비밀번호를 바꿔 계정을 가로채지 못하게 하는 장치이므로, 틀리면 false 만 돌려주고 사유는 나누지 않습니다. + */ +export async function checkCurrentPassword(env, id, password) { + const row = await env.DB.prepare('SELECT password_hash FROM mail_users WHERE id = ?').bind(id).first(); + return !!row && (await verifyPassword(String(password ?? ''), row.password_hash)); +} +/** 계정을 지웁니다(회원 가입 거절에도 씀). 세션과 계정에 딸린 설정 표시(초기 비밀번호·승인 대기·메일함 크기)도 함께 지웁니다. 메일함의 메일은 남습니다. */ +export async function deleteUser(env, id) { + const r = await env.DB.prepare('DELETE FROM mail_users WHERE id = ?').bind(id).run(); if (!r.meta.changes) throw new Error('not_found'); + await env.DB.prepare('DELETE FROM sessions WHERE user_id = ?').bind(id).run(); + await env.DB.prepare('DELETE FROM settings WHERE key IN (?,?,?)').bind(initialKey(id), pendingKey(id), 'prefs:' + id).run(); // prefs(전달·자동 응답 등)도 함께 지움(0.9.3) + await clearUserQuota(env, id); +} +/** + * 수신을 받아들일 메일함 주소 목록: 기본 메일함 + 모든 계정(정지 포함)의 메일함. + * 예전에는 활성 계정만 넣어, 계정을 정지하면 그 주소로 오는 메일이 'other_mailbox' 로 버려졌고(동기화 창을 벗어나면 복구 불가) 정지를 풀어도 돌아오지 않았습니다(0.8.24). + * 정지는 로그인을 막는 것이지 메일함을 없애는 것이 아니므로, 메일은 계속 저장하고 관리자는 그 메일함을 볼 수 있습니다. + */ +export async function knownMailboxes(env) { + const rows = (await env.DB.prepare("SELECT DISTINCT mailbox FROM mail_users").all()).results; + // webmaker 연동(1.0.8): 웹메이커 계정의 메일함(아이디@도메인)도 받는 주소입니다. 파일을 읽지 못하면 빈 목록(마지막 스냅샷이 있으면 그것). + const external = isWebmaker(env) ? await webmakerMailboxes(env) : []; + return [...new Set([mailboxOf(env), ...rows.map((r) => r.mailbox), ...external])]; +} +/** + * 외부(웹메이커) 계정 목록(1.0.8) — 관리자 페이지에 읽기 전용 행으로 보여 주는 용도. 해시는 없습니다. + * @returns {Promise<{ok:boolean, error?:string, site?:object, accounts:object[]}>} webmaker 모드가 아니면 ok:true 에 빈 목록 + */ +export async function externalUsers(env) { return isWebmaker(env) ? webmakerAccounts(env) : { ok: true, accounts: [] }; } + +// ------------------------------------------------------------------ 사용자별 설정(prefs) +// settings 테이블에 prefs:<사용자 id> 로 저장합니다. www 관리자(id 'www:…')와 게이트웨이 계정 모두 같은 방식입니다. +// remoteImages: 메일을 열 때 외부 이미지를 바로 표시할지 +// pop / imap: 이 계정으로 POP3 / IMAP 서버에 접속할 수 있는지(0.8.4~). 끄면 메일 프로그램 로그인을 거부합니다. +// forward: 메일 전달(0.8.16~). enabled 면 이 메일함으로 받은 메일을 전부 to(주소 목록, 최대 5개)로 전달합니다. 원본은 메일함에 남습니다. +// autoReply: 자동 응답(0.8.16~). enabled 면 받은 메일에 message 로 자동 회신합니다. noLimit 이면 기한 없이, 아니면 start~end(ISO 시각) 동안만. +// subject 가 비어 있으면 "Re: 원래 제목" 으로 보냅니다. 실제 판단은 automation.mjs 의 autoReplyActive() 가 합니다. +export const DEFAULT_PREFS = { + remoteImages: false, pop: true, imap: true, + forward: { enabled: false, to: '' }, + autoReply: { enabled: false, noLimit: true, start: '', end: '', subject: '', message: '' }, +}; +// 전달 주소 수·자동 응답 글자 수 상한. 전달은 받은 메일마다 발송이 생기므로 주소를 적게 두고, 메시지는 화면 입력 상자 기준으로 넉넉히 둡니다. +export const FORWARD_MAX = 5, AUTOREPLY_MESSAGE_MAX = 5000, AUTOREPLY_SUBJECT_MAX = 200; +/** 사용자(id 만 있어도 됨)의 설정을 기본값과 합쳐 돌려줍니다. 중첩 설정(forward·autoReply)도 키마다 기본값을 채웁니다. */ +export async function readPrefs(env, user) { + const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('prefs:' + user.id).first(); + let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; } + if (!stored || typeof stored !== 'object') stored = {}; + return { + ...DEFAULT_PREFS, ...stored, + forward: { ...DEFAULT_PREFS.forward, ...(stored.forward && typeof stored.forward === 'object' ? stored.forward : {}) }, + autoReply: { ...DEFAULT_PREFS.autoReply, ...(stored.autoReply && typeof stored.autoReply === 'object' ? stored.autoReply : {}) }, + }; +} +/** ISO 시각이면 정규화한 ISO 문자열, 비어 있으면 '', 해석할 수 없으면 null. */ +const isoOrEmpty = (v) => { if (v === undefined || v === null || v === '') return ''; const ms = Date.parse(String(v)); return Number.isFinite(ms) ? new Date(ms).toISOString() : null; }; +/** + * 설정 중 아는 키만 골라 저장합니다. 불리언 설정은 불리언으로 바꾸고, forward·autoReply 는 검사한 뒤 정리된 값으로 저장합니다. + * 검사 실패는 예외로 알립니다(API 가 422 로 돌려줌): + * forward_invalid — 전달을 켰는데 주소가 없거나 형식이 틀리거나 5개를 넘거나 자기 메일함 주소를 넣음 + * autoreply_message — 자동 응답을 켰는데 메시지가 비어 있음 + * autoreply_period — 기한 제한을 두었는데 종료 시각이 없거나, 시각 형식이 틀리거나, 종료가 시작보다 앞섬 + * 돌려주는 값은 저장 후의 전체 설정입니다. + * @param {object} user { id, mailbox? } — mailbox 가 있으면 전달 주소에 자기 메일함을 넣지 못하게 막습니다(전달 고리 방지). + */ +export async function writePrefs(env, user, patch) { + const current = await readPrefs(env, user); + const next = { ...current }; + for (const key of ['remoteImages', 'pop', 'imap']) if (patch[key] !== undefined) next[key] = !!patch[key]; + if (patch.forward && typeof patch.forward === 'object') { + const f = patch.forward, enabled = f.enabled !== undefined ? !!f.enabled : current.forward.enabled; + const to = f.to !== undefined ? addresses(clean(f.to, 1000)) : addresses(current.forward.to); + if (enabled && (!to.length || to.length > FORWARD_MAX || to.some((a) => !isEmail(a)) || (user.mailbox && to.includes(String(user.mailbox).toLowerCase())))) throw new Error('forward_invalid'); + if (!enabled && to.some((a) => !isEmail(a))) throw new Error('forward_invalid'); + next.forward = { enabled, to: to.join(', ') }; + } + if (patch.autoReply && typeof patch.autoReply === 'object') { + const a = patch.autoReply, base = current.autoReply; + const enabled = a.enabled !== undefined ? !!a.enabled : base.enabled, noLimit = a.noLimit !== undefined ? !!a.noLimit : base.noLimit; + const start = a.start !== undefined ? isoOrEmpty(a.start) : base.start, end = a.end !== undefined ? isoOrEmpty(a.end) : base.end; + const subject = a.subject !== undefined ? clean(a.subject, AUTOREPLY_SUBJECT_MAX) : base.subject; + const message = a.message !== undefined ? String(a.message ?? '').replace(/\r\n?/g, '\n').trim().slice(0, AUTOREPLY_MESSAGE_MAX) : base.message; + if (start === null || end === null) throw new Error('autoreply_period'); + if (enabled && !message) throw new Error('autoreply_message'); + // 기한 제한이 있으면 종료 시각은 꼭 있어야 하고(시작은 비우면 "지금부터"), 종료가 시작보다 앞서면 기간이 성립하지 않습니다. + if (enabled && !noLimit && (!end || (start && Date.parse(end) <= Date.parse(start)))) throw new Error('autoreply_period'); + next.autoReply = { enabled, noLimit, start, end, subject, message }; + } + await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('prefs:' + user.id, JSON.stringify(next), now()).run(); + return next; +} +/** + * 메일함 주소로 그 메일함을 쓰는 사용자(설정의 주인)를 찾습니다. 받은 메일을 자동 처리(전달·자동 응답)할 때 메일함의 설정을 읽는 데 씁니다. + * 기본 메일함(MAILBOX)은 www 관리자(mailboxOwner), 그 밖의 주소는 활성 게이트웨이 계정입니다. 주인이 없으면 null. + * 독립 실행(standalone)에서는 기본 메일함도 게이트웨이 계정(로그인 ID 가 메일함 앞부분인 계정)이 주인입니다. + */ +export async function mailboxUser(env, mailbox) { + const box = String(mailbox || '').trim().toLowerCase(); + if (!box) return null; + // webmaker 연동(1.0.8): 웹메이커 계정의 메일함이면 그 계정(설정 prefs 는 'wm:…' id 로 저장됨). + if (isWebmaker(env)) { const wm = await webmakerByMailbox(env, box); if (wm) return wm; } + // 기본 메일함은 www 관리자 몫입니다. 독립 실행(www DB 없음)이면 아래의 게이트웨이 계정 조회로 넘어갑니다. + if (box === mailboxOf(env) && !standalone(env)) { try { const row = mailboxOwner(env); return row ? wwwPrincipal(row, env) : null; } catch { return null; } } + const local = await env.DB.prepare("SELECT * FROM mail_users WHERE mailbox = ? AND status = 'active' ORDER BY created_at LIMIT 1").bind(box).first(); + return local ? localPrincipal(local) : null; +} +/** + * 메일 프로그램(POP3/IMAP) 서버가 로그인 성공 직후 부르는 검사. 계정 설정에서 그 서비스를 꺼 두었으면 false. + * @param {'pop'|'imap'} service + */ +export async function serviceAllowed(env, user, service) { return (await readPrefs(env, user))[service] !== false; } + +// ------------------------------------------------------------------ 세션 +/** + * 세션을 만들고 쿠키에 넣을 토큰을 돌려줍니다. DB 에는 토큰의 SHA-256 만 저장합니다. + * @param {'mail'|'admin'} [scope] 세션 범위. 'admin' 은 관리자 페이지(/admin) 전용이며 관리자 역할 계정만 만들 수 있습니다. + */ +export async function createSession(env, user, request, { scope = 'mail' } = {}) { + if (!SCOPES.includes(scope)) throw new Error('bad_request'); + if (scope === 'admin' && user.role !== 'admin') throw new Error('forbidden'); + const token = secret(32); + await env.DB.prepare('INSERT INTO sessions (id,user_id,login,expires_at,created_at,ip,ua,scope) VALUES (?,?,?,?,?,?,?,?)') + .bind(await sha256(token), user.id, user.login, plus(SESSION_DAYS * 86400000), now(), request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || '', (request.headers.get('User-Agent') || '').slice(0, 300), scope).run(); + return token; +} +/** + * Set-Cookie 값. scope 에 따라 쿠키 이름이 다릅니다(mg_session / mg_admin). clear 면 즉시 만료시킵니다. + * path(1.0.8): 경로 접두사(/mail) 아래의 웹 메일이면 Path 를 그 접두사로 한정합니다(같은 호스트의 사이트 쪽 요청에 세션 쿠키가 실리지 않게). 보통은 '/'. + */ +export function sessionCookie(token, { clear = false, scope = 'mail', path = '' } = {}) { + return [`${cookieName(scope)}=${clear ? '' : token}`, `Path=${path || '/'}`, 'HttpOnly', 'Secure', 'SameSite=Lax', clear ? 'Max-Age=0' : `Max-Age=${SESSION_DAYS * 86400}`].join('; '); +} +function readCookie(request, name) { for (const part of (request.headers.get('Cookie') || '').split(';')) { const [k, ...v] = part.trim().split('='); if (k === name) return v.join('='); } return ''; } +/** + * 요청의 세션 쿠키로 사용자를 찾습니다. 범위(scope)가 맞는 세션만 인정합니다. + * 관리자 범위에서는 지금도 관리자 역할인지 다시 확인합니다(세션이 살아 있는 동안 역할이 바뀌었을 수 있음). + */ +export async function currentUser(env, request, { scope = 'mail' } = {}) { + const token = readCookie(request, cookieName(scope)); if (!token) return null; + const hash = await sha256(token); + const row = await env.DB.prepare('SELECT * FROM sessions WHERE id = ? AND scope = ?').bind(hash, scope).first(); if (!row) return null; + let principal = null; + // 사용자 조회가 일시적으로 실패하면(배포 중 www DB 교체, SQLITE_BUSY) 세션을 지우지 않고 이번 요청만 비로그인으로 처리합니다. + // 예전에는 이런 예외에도 세션을 지워 관리자가 영문 없이 로그아웃됐습니다. 만료·계정 없음·역할 불일치일 때만 지웁니다. + if (row.expires_at >= now()) { try { principal = await principalById(env, row.user_id); } catch { return null; } } + if (principal && scope === 'admin' && principal.role !== 'admin') principal = null; + if (!principal) { await env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(hash).run(); return null; } + return { ...principal, expires_at: row.expires_at, scope }; +} +export async function destroySession(env, request, { scope = 'mail' } = {}) { const token = readCookie(request, cookieName(scope)); if (token) await env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(await sha256(token)).run(); } +export async function requireUser(env, request, { scope = 'mail' } = {}) { const user = await currentUser(env, request, { scope }); return user || json({ ok: false, error: 'unauthenticated' }, 401); } +export async function audit(env, actor, action, target, detail) { + await env.DB.prepare('INSERT INTO audit_log (id,actor,action,target,detail,created_at) VALUES (?,?,?,?,?,?)').bind(uid(), actor || null, action, target || null, detail ? JSON.stringify(detail).slice(0, 2000) : null, now()).run(); +} diff --git a/modules/arimail/server/automation.mjs b/modules/arimail/server/automation.mjs new file mode 100644 index 0000000..af62ce5 --- /dev/null +++ b/modules/arimail/server/automation.mjs @@ -0,0 +1,191 @@ +/** + * 수신 메일 자동 처리(0.8.16~): 메일 전달과 자동 응답. + * + * 설정 화면(prefs, auth.mjs 의 DEFAULT_PREFS 참고)에서 사용자가 켜 두면, 받은 메일을 저장한 직후(store.ingestReceived) + * 이 모듈의 autoProcess() 가 불려 다음을 합니다. + * - 메일 전달(forward): 받은 메일을 지정한 주소(최대 5개)로 통째로(제목·본문·첨부·인라인 이미지) 보냅니다. 원본은 메일함에 그대로 남습니다. + * 보내는 사람은 이 메일함 주소(발송 서비스가 우리 도메인만 허용)이고, 답장이 원래 보낸 사람에게 가도록 Reply-To 를 원래 발신자로 둡니다. + * - 자동 응답(reply): 사용자가 정한 메시지로 보낸 사람에게 회신합니다. 기한 없음이거나 설정한 기간(시작~종료) 안일 때만 동작하며, + * 같은 상대에게는 24시간에 한 번만 보냅니다(한 사람이 여러 통을 보내도 한 번만 답함). 보낸 자동 응답은 보낸 메일함에 남깁니다. + * + * 되풀이(loop) 방지 — 자동 발송 메일은 받는 쪽의 자동 처리가 다시 반응하지 않게 표시를 달고, 받을 때는 그 표시를 보고 건너뜁니다. + * - 자동 응답에는 RFC 3834 의 Auto-Submitted: auto-replied (와 Microsoft 용 X-Auto-Response-Suppress) 를 붙입니다. + * - 전달 메일에는 X-Ari-Auto-Forwarded 헤더를 붙입니다. 두 메일함이 서로를 전달 대상으로 두어도 한 번만 전달되고 멈춥니다. + * - 받은 메일에 Auto-Submitted(no 제외)·Precedence bulk/list/junk·List-Id 가 있거나 발신자가 mailer-daemon/no-reply 류이면 자동 응답하지 않습니다. + * - 72시간보다 오래된 메일(동기화가 뒤늦게 가져온 옛 메일)에는 아무것도 하지 않습니다. + * + * 처리 기록은 auto_actions 테이블(message_id + kind 가 기본 키)에 남깁니다. 먼저 'pending' 행을 넣어 자리를 잡기 때문에 + * 웹훅과 동기화가 같은 메일을 거의 동시에 처리해도 전달·응답이 두 번 나가지 않습니다. 결과는 메일 상세의 "자동 처리" 줄에 보입니다. + */ +import { sendMail } from './providers/index.mjs'; +import { mailboxUser, readPrefs } from './auth.mjs'; +import { now, uid, clean, addresses, address, isEmail } from './util.mjs'; +import { sanitizeHtml, textToHtml } from './sanitize.mjs'; +// store.mjs 와는 서로 들여옵니다(store 가 저장 직후 autoProcess 를 부름). ESM 은 함수 선언을 호출 시점에 찾으므로 순환이어도 문제없습니다. +import { buildStoredEml, storeRaw, setFlag } from './store.mjs'; +import { monthlyBlocked } from './limits.mjs'; + +/** 이보다 오래된 수신 메일은 자동 처리하지 않습니다(72시간). 동기화가 옛 메일을 뒤늦게 가져올 때 뜬금없는 전달·응답이 나가는 것을 막습니다. */ +export const STALE_MS = 72 * 3600 * 1000; +/** 같은 상대에게 자동 응답을 다시 보내기까지의 간격(24시간). */ +export const REPLY_INTERVAL_MS = 24 * 3600 * 1000; +/** 전달 메일에 붙이는 표시 헤더. 받은 메일에 이 헤더가 있으면 다시 전달하지 않습니다(전달 고리 방지). 값은 전달한 메일함 주소입니다. */ +export const FORWARD_MARKER = 'X-Ari-Auto-Forwarded'; +/** 전달 한 통에 넣는 첨부 바이트 합계 상한. 발송 서비스 한도(40MB)와 base64 부풀림을 고려해 25MB 로 둡니다. 넘는 첨부는 빼고 기록에 남깁니다. */ +const FORWARD_ATTACH_LIMIT = 25 * 1024 * 1024; + +/** + * 자동 응답이 지금 동작하는 상태인지. 설정이 켜져 있고 메시지가 있어야 하며, 기한 없음(noLimit)이면 항상, + * 아니면 시작(start, 비우면 바로)~종료(end) 사이일 때만 true. 설정 화면의 "작동 중" 표시와 수신 처리가 같은 규칙을 씁니다. + * @param {object} a prefs.autoReply + * @param {number} [at] 판단 시각(ms). 기본은 지금. 테스트에서 기간 경계를 확인하는 데 씁니다. + */ +export function autoReplyActive(a, at = Date.now()) { + if (!a || !a.enabled || !String(a.message || '').trim()) return false; + if (a.noLimit) return true; + const start = a.start ? Date.parse(a.start) : -Infinity, end = a.end ? Date.parse(a.end) : NaN; + if (!Number.isFinite(end)) return false; // 기한 제한인데 종료 시각이 없으면(저장 검사를 거치지 않은 옛 값) 동작하지 않습니다. + return at >= start && at <= end; +} + +/** headers_json 객체에서 이름을 대소문자 구분 없이 찾습니다. 값이 배열이면 첫 값. 없으면 ''. */ +function header(headers, name) { + const key = Object.keys(headers || {}).find((k) => k.toLowerCase() === name.toLowerCase()); + const v = key ? headers[key] : ''; return String(Array.isArray(v) ? v[0] : v ?? '').trim(); +} +/** "홍길동 " 에서 표시 이름(홍길동)만. 이름이 없으면 주소의 @ 앞부분. */ +function displayName(sender) { + const s = String(sender || '').trim(); const m = s.match(/^"?(.*?)"?\s*<[^<>]+>$/); + return (m && m[1] ? m[1] : s.split('@')[0]).replace(/[\r\n"<>]/g, '').trim(); +} +/** + * 자동 발송된 메일(다른 서버의 자동 응답·알림·목록 메일 등)인지 판단해 이유를 돌려줍니다. 사람이 보낸 메일이면 null. + * 'auto-submitted' Auto-Submitted 가 no 가 아님(RFC 3834) / 'forwarded' 우리 전달 표시 / 'bulk' Precedence bulk·list·junk·auto_reply + * 'list' 메일링 리스트 헤더 / 'suppressed' X-Auto-Response-Suppress(Microsoft) / 'noreply' 발신 주소가 mailer-daemon·no-reply 류 + * @param {object} mail messages 행 + * @param {object} headers headers_json 을 푼 객체 + */ +export function automatedReason(mail, headers) { + const auto = header(headers, 'Auto-Submitted').toLowerCase(); + if (auto && auto !== 'no') return 'auto-submitted'; + if (header(headers, FORWARD_MARKER)) return 'forwarded'; + if (/^(bulk|list|junk|auto_reply)$/i.test(header(headers, 'Precedence'))) return 'bulk'; + if (header(headers, 'List-Id') || header(headers, 'List-Unsubscribe')) return 'list'; + if (/\b(all|autoreply|oof)\b/i.test(header(headers, 'X-Auto-Response-Suppress'))) return 'suppressed'; + const from = address(mail.sender); + if (!isEmail(from) || /^(mailer-daemon|postmaster|no-?reply|do-?not-?reply|bounces?|notifications?)([+@.-]|$)/i.test(from)) return 'noreply'; + return null; +} + +/** auto_actions 에 처리 중(pending) 행을 넣어 자리를 잡습니다. 이미 행이 있으면(다른 경로가 먼저 처리) false. */ +async function claim(env, mail, kind) { + const r = await env.DB.prepare("INSERT OR IGNORE INTO auto_actions(message_id,kind,mailbox,target,status,created_at) VALUES(?,?,?,'','pending',?)").bind(mail.id, kind, mail.mailbox, now()).run(); + return r.meta.changes > 0; +} +async function finish(env, mail, kind, { status, target = '', detail = null, sentMessageId = null }) { + await env.DB.prepare('UPDATE auto_actions SET status=?,target=?,detail=?,sent_message_id=?,created_at=? WHERE message_id=? AND kind=?').bind(status, target, detail ? String(detail).slice(0, 300) : null, sentMessageId, now(), mail.id, kind).run(); + return { kind, status, target, detail }; +} + +/** + * 받은 메일 한 통에 메일함 설정에 따라 전달·자동 응답을 수행합니다. store.ingestReceived 가 저장 직후에 부릅니다. + * 예외는 밖으로 던지지 않고 기록(failed)만 남깁니다 — 자동 처리 실패가 수신 저장 자체를 실패로 만들면 안 됩니다. + * @param {string} messageId 저장한 메일 id + * @returns {Promise<{forward?:object, reply?:object}|null>} 작업별 결과(테스트·로그용). 설정이 모두 꺼져 있으면 null. + */ +export async function autoProcess(env, messageId) { + const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(messageId).first(); + if (!mail || mail.direction !== 'inbound') return null; + const user = await mailboxUser(env, mail.mailbox); if (!user) return null; + const prefs = await readPrefs(env, user); + const wantForward = prefs.forward.enabled && addresses(prefs.forward.to).length > 0, wantReply = autoReplyActive(prefs.autoReply); + if (!wantForward && !wantReply) return null; + let headers = {}; try { headers = JSON.parse(mail.headers_json || '{}'); } catch { headers = {}; } + const stale = Date.now() - Date.parse(mail.received_at) > STALE_MS; + const out = {}; + if (wantForward) out.forward = await runForward(env, mail, user, prefs.forward, headers, stale); + if (wantReply) out.reply = await runReply(env, mail, user, prefs.autoReply, headers, stale); + return out; +} + +/** + * 메일 전달. 저장된 원본 HTML(정제 전, 인라인 이미지 cid 포함)·텍스트·첨부를 그대로 실어 지정 주소로 보냅니다. + * 건너뛰는 경우: 우리 전달 표시가 있는 메일(loop), 전달 대상이 보낸 메일(self: 다시 돌려보내지 않음), 72시간 지난 메일(stale). + */ +async function runForward(env, mail, user, forward, headers, stale) { + const kind = 'forward'; + const targets = addresses(forward.to).filter((a) => isEmail(a) && a !== mail.mailbox); + if (!targets.length) return { kind, status: 'skipped', detail: 'no_target' }; + if (!(await claim(env, mail, kind))) return { kind, status: 'skipped', detail: 'claimed' }; + const sender = address(mail.sender); + if (stale) return finish(env, mail, kind, { status: 'skipped', detail: 'stale' }); + if (header(headers, FORWARD_MARKER)) return finish(env, mail, kind, { status: 'skipped', detail: 'loop' }); + if (targets.includes(sender)) return finish(env, mail, kind, { status: 'skipped', detail: 'self' }); + // 월간 수발신 통수 제한(0.9.0, 시스템 설정)에 이르렀으면 전달도 보내지 않습니다(전달 한 통도 발신으로 셈). + if (await monthlyBlocked(env)) return finish(env, mail, kind, { status: 'skipped', detail: 'limit' }); + try { + const rows = (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,bytes FROM attachments WHERE message_id=? ORDER BY rowid').bind(mail.id).all()).results; + // 첨부는 합계 상한까지만 싣습니다. 상한을 넘겨 뺀 첨부 이름은 기록에 남겨 상세 화면에서 알 수 있게 합니다. + let total = 0; const files = [], dropped = []; + for (const a of rows) { if (!a.bytes) { dropped.push(a.filename); continue; } if (total + a.bytes.length > FORWARD_ATTACH_LIMIT) { dropped.push(a.filename); continue; } total += a.bytes.length; files.push(a); } + const html = mail.original_html || mail.html || ''; + const payload = { + // 표시 이름에 괄호와 @ 가 들어가므로 따옴표로 묶습니다(RFC 5322: 괄호는 주석, @ 는 따옴표 없는 이름에 쓸 수 없음). + from: `"${displayName(mail.sender) || sender} (via ${mail.mailbox})" <${mail.mailbox}>`, to: targets, + subject: mail.subject || '(no subject)', text: mail.text || '', ...(html ? { html } : {}), + // 전달받은 사람이 "답장" 을 누르면 원래 보낸 사람에게 가도록 합니다(우리 메일함이 아니라). + ...(isEmail(sender) ? { reply_to: [mail.sender] } : {}), + headers: { [FORWARD_MARKER]: mail.mailbox }, + }; + if (files.length) payload.attachments = files.map((a) => ({ filename: a.filename, content: Buffer.from(a.bytes).toString('base64'), content_type: a.content_type, ...(a.content_id ? { content_id: a.content_id } : {}) })); + // 멱등 키: 같은 메일을 다시 처리하게 되더라도 발송 서비스가 한 번만 보냅니다. + const result = await sendMail(env, payload, 'auto-forward-' + mail.id); + return finish(env, mail, kind, { status: 'sent', target: targets.join(', '), detail: (result.id || '') + (dropped.length ? ' dropped:' + dropped.join(',') : '') }); + } catch (e) { + console.error('메일 전달 실패', mail.id, mail.mailbox, '→', targets.join(','), e.message, e.providerMessage || ''); + return finish(env, mail, kind, { status: 'failed', target: targets.join(', '), detail: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') }); + } +} + +/** + * 자동 응답. Reply-To(없으면 From)에게 설정한 제목·메시지로 회신하고 보낸 메일함에 저장합니다. + * 건너뛰는 경우: 자동 발송 메일(automatedReason), 72시간 지난 메일(stale), 같은 상대에게 24시간 안에 이미 보냄(recent), 회신 주소 없음(no_address). + */ +async function runReply(env, mail, user, autoReply, headers, stale) { + const kind = 'reply'; + let replyTo = []; try { replyTo = JSON.parse(mail.reply_to_json || '[]'); } catch { replyTo = []; } + const target = addresses(replyTo)[0] || address(mail.sender); + if (!(await claim(env, mail, kind))) return { kind, status: 'skipped', detail: 'claimed' }; + if (stale) return finish(env, mail, kind, { status: 'skipped', detail: 'stale' }); + if (!isEmail(target)) return finish(env, mail, kind, { status: 'skipped', detail: 'no_address' }); + const reason = automatedReason(mail, headers); + if (reason) return finish(env, mail, kind, { status: 'skipped', target, detail: reason }); + if (target === mail.mailbox) return finish(env, mail, kind, { status: 'skipped', target, detail: 'self' }); + const recent = await env.DB.prepare("SELECT created_at FROM auto_actions WHERE mailbox=? AND kind='reply' AND target=? AND status='sent' AND created_at>? AND message_id<>? LIMIT 1").bind(mail.mailbox, target, new Date(Date.now() - REPLY_INTERVAL_MS).toISOString(), mail.id).first(); + if (recent) return finish(env, mail, kind, { status: 'skipped', target, detail: 'recent' }); + if (await monthlyBlocked(env)) return finish(env, mail, kind, { status: 'skipped', target, detail: 'limit' }); + try { + const original = String(mail.subject || '').trim(); + const subject = clean(autoReply.subject, 200) || (/^re:/i.test(original) ? original : 'Re: ' + original).trim() || 'Re:'; + const text = String(autoReply.message || '').trim(), content = sanitizeHtml(textToHtml(text)); + const fromName = (user.name || env.MAIL_FROM_NAME || 'Ari Hak Dang').replace(/[\r\n"<>]/g, '').trim(); + const threadHeaders = mail.message_id ? { 'In-Reply-To': mail.message_id, References: mail.message_id } : {}; + const payload = { from: `${fromName} <${mail.mailbox}>`, to: [target], subject, text, html: content.html, headers: { ...threadHeaders, 'Auto-Submitted': 'auto-replied', 'X-Auto-Response-Suppress': 'All' } }; + const result = await sendMail(env, payload, 'auto-reply-' + mail.id); + // 보낸 메일함에 남깁니다(사용자가 무엇이 나갔는지 볼 수 있게). 원본(.eml)은 저장한 부품으로 재구성합니다. + const id = uid(), stamp = now(); + await env.DB.prepare(`INSERT INTO messages(id,provider_id,direction,folder,sender,recipients_json,subject,html,text,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,'outbound','sent',?,?,?,?,?,?,'web','sent',?,?,?,?,?,?)`) + .bind(id, result.id || null, payload.from, JSON.stringify([target]), subject, content.html, text, JSON.stringify(payload.headers), stamp, stamp, stamp, stamp, stamp, mail.mailbox).run(); + await storeRaw(env, id, await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first()), 'built'); + await setFlag(env, mail.id, '\\Answered', true); // 원래 메일에 "답장함" 표시(IMAP \Answered 와 공유) + return finish(env, mail, kind, { status: 'sent', target, detail: result.id || null, sentMessageId: id }); + } catch (e) { + console.error('자동 응답 실패', mail.id, mail.mailbox, '→', target, e.message, e.providerMessage || ''); + return finish(env, mail, kind, { status: 'failed', target, detail: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') }); + } +} + +/** 메일 상세 화면용: 이 메일에 한 자동 처리 기록. */ +export async function autoActionsOf(env, messageId) { + return (await env.DB.prepare('SELECT kind,target,status,detail,sent_message_id,created_at FROM auto_actions WHERE message_id=? ORDER BY kind').bind(messageId).all()).results; +} \ No newline at end of file diff --git a/modules/arimail/server/config.mjs b/modules/arimail/server/config.mjs new file mode 100644 index 0000000..8efd823 --- /dev/null +++ b/modules/arimail/server/config.mjs @@ -0,0 +1,274 @@ +/** + * 설정 파일(config.json) 모듈 — 1.0.0 에서 환경 파일(gateway.env)과 분리한 "운영 설정". + * + * 왜 분리했나 + * 예전에는 Resend API 키·웹훅 비밀키·발신 표시 이름 같은 값이 모두 systemd 환경 파일(gateway.env)에 있었습니다. + * 그 값들은 처음 배포할 때는 알 수 없거나(키를 아직 만들지 않음) 배포 자체에는 필요하지 않은데, 비어 있으면 운영이 안 되고 + * 채우려면 root 로 파일을 고치고 서비스를 재시작해야 했습니다. 이제 "배포에 꼭 필요한 값"(DB 경로·포트·메일함·로그인 방식·시스템 제한)만 + * 환경 파일에 남기고, 나머지는 데이터 디렉터리의 config.json 에 두어 설치가 끝난 뒤 웹(초기 설정 페이지 / 관리자 페이지)에서 채웁니다. + * + * 우선순위와 호환(요구사항 5: 기존 운영 유지) + * - 설정 파일에 있는 값이 환경 변수보다 우선합니다. 설정 파일이 없으면(처음 1.0.0 으로 올린 서버) 환경 변수에 있던 값을 그대로 + * 읽어 설정 파일을 만듭니다(가져오기). 그래서 두 운영 서버의 환경 파일을 바꾸지 않아도 동작이 그대로이고, 값은 설정 파일에 보관됩니다. + * - 가져오기 뒤에는 설정 파일이 기준입니다(파일에 빈 값으로 저장하면 환경 변수 값이 되살아나지 않음). 파일에 아예 없는 키만 환경 변수를 봅니다. + * - 코드의 나머지 부분은 예전처럼 env.RESEND_API_KEY 같은 환경 변수 이름으로 값을 읽습니다. applyConfig() 가 설정 파일 값을 env 객체에 + * 덮어쓰므로 모듈들이 바뀌지 않았고, 저장 즉시(재시작 없이) 새 값이 쓰입니다. + * + * 파일 위치: CONFIG_FILE 환경 변수, 없으면 DB 파일 옆의 config.json(예: /srv/<사이트>/data/config.json). 소유자만 읽습니다(0600, 비밀키 포함). + * 초기 설정 페이지의 접속 토큰도 같은 디렉터리의 setup-token 파일입니다. + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { generateKeyPairSync } from 'node:crypto'; +import { secret } from './util.mjs'; + +/** 설정 파일 형식 버전. 구조가 바뀌면 올리고 loadConfig 에서 변환합니다. */ +export const CONFIG_FORMAT = 1; + +/** + * 설정 항목 정의. + * key : config.json 의 키이자(env 가 있으면) 코드가 읽는 환경 변수 이름 + * env : 이 항목이 대신하는 환경 변수(가져오기와 applyConfig 대상). 없으면 설정 파일 전용 항목 + * group : 화면의 묶음 — mail(수·발신 방식) resend smtp haraka site setup + * type : text | secret(화면에 가린 채 보임) | select | number | bool + * hidden : 화면 폼에 직접 보이지 않는 항목(DKIM 키·내부 토큰처럼 단추로 만들거나 자동 생성) + * default: 값이 없을 때의 기본값(applyConfig 는 기본값을 env 에 쓰지 않고, 읽는 쪽 코드의 기본값에 맡깁니다) + */ +export const CONFIG_SCHEMA = [ + // 수·발신 방식(요구사항 1·2): inbound = 받는 길, outbound = 보내는 길. 조합은 자유입니다(예: 수신 Haraka + 발신 Resend). + { key: 'inbound', group: 'mail', type: 'select', options: ['resend', 'haraka'], default: 'resend' }, + { key: 'outbound', group: 'mail', type: 'select', options: ['resend', 'haraka', 'smtp'], default: 'resend' }, + // Resend + { key: 'RESEND_API_KEY', env: 'RESEND_API_KEY', group: 'resend', type: 'secret' }, + { key: 'RESEND_WEBHOOK_SECRET', env: 'RESEND_WEBHOOK_SECRET', group: 'resend', type: 'secret' }, + // Resend 도메인 자동 설정(1.0.5): 발송 지역(수신 MX 값도 지역에 따라 다름). RESEND_DOMAIN_ID / RESEND_WEBHOOK_ID 는 자동 설정이 찾거나 만든 id(화면에는 숨김). + { key: 'RESEND_REGION', env: 'RESEND_REGION', group: 'resend', type: 'select', options: ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'], default: 'ap-northeast-1' }, + { key: 'RESEND_DOMAIN_ID', env: 'RESEND_DOMAIN_ID', group: 'resend', type: 'text', hidden: true }, + { key: 'RESEND_WEBHOOK_ID', env: 'RESEND_WEBHOOK_ID', group: 'resend', type: 'text', hidden: true }, + // DNS 모듈(1.0.5, 요구사항 1): none 이면 레코드를 보여 주기만 하고, google / route53 이면 자동으로 적용합니다. 자격 증명은 비밀값. + { key: 'DNS_PROVIDER', env: 'DNS_PROVIDER', group: 'dns', type: 'select', options: ['none', 'google', 'route53'], default: 'none' }, + { key: 'DNS_GOOGLE_CREDENTIALS', env: 'DNS_GOOGLE_CREDENTIALS', group: 'dns', type: 'secret', multiline: true }, + { key: 'DNS_GOOGLE_PROJECT', env: 'DNS_GOOGLE_PROJECT', group: 'dns', type: 'text' }, + { key: 'DNS_GOOGLE_ZONE', env: 'DNS_GOOGLE_ZONE', group: 'dns', type: 'text' }, + { key: 'DNS_AWS_ACCESS_KEY_ID', env: 'DNS_AWS_ACCESS_KEY_ID', group: 'dns', type: 'text' }, + { key: 'DNS_AWS_SECRET_ACCESS_KEY', env: 'DNS_AWS_SECRET_ACCESS_KEY', group: 'dns', type: 'secret' }, + { key: 'DNS_AWS_HOSTED_ZONE_ID', env: 'DNS_AWS_HOSTED_ZONE_ID', group: 'dns', type: 'text' }, + // 외부 SMTP 중계 서버(outbound=smtp) + { key: 'SMTP_RELAY_HOST', env: 'SMTP_RELAY_HOST', group: 'smtp', type: 'text' }, + { key: 'SMTP_RELAY_PORT', env: 'SMTP_RELAY_PORT', group: 'smtp', type: 'number', default: 587 }, + { key: 'SMTP_RELAY_SECURITY', env: 'SMTP_RELAY_SECURITY', group: 'smtp', type: 'select', options: ['starttls', 'tls', 'none'], default: 'starttls' }, + { key: 'SMTP_RELAY_USER', env: 'SMTP_RELAY_USER', group: 'smtp', type: 'text' }, + { key: 'SMTP_RELAY_PASS', env: 'SMTP_RELAY_PASS', group: 'smtp', type: 'secret' }, + // 내장 Haraka(inbound=haraka 또는 outbound=haraka 일 때 haraka/run.mjs 가 이 값으로 Haraka 설정을 만듭니다) + { key: 'HARAKA_PORT', env: 'HARAKA_PORT', group: 'haraka', type: 'number', default: 25 }, + { key: 'HARAKA_RELAY_PORT', env: 'HARAKA_RELAY_PORT', group: 'haraka', type: 'number', default: 2525 }, + { key: 'HARAKA_HOSTNAME', env: 'HARAKA_HOSTNAME', group: 'haraka', type: 'text' }, + { key: 'HARAKA_DKIM_SELECTOR', env: 'HARAKA_DKIM_SELECTOR', group: 'haraka', type: 'text', default: 'arimail' }, + { key: 'HARAKA_DKIM_PRIVATE', env: 'HARAKA_DKIM_PRIVATE', group: 'haraka', type: 'secret', hidden: true }, + { key: 'HARAKA_DKIM_PUBLIC', env: 'HARAKA_DKIM_PUBLIC', group: 'haraka', type: 'text', hidden: true }, + // Haraka 플러그인 ↔ 게이트웨이 사이의 공유 비밀(자동 생성). 로컬 HTTP 로 넘어오는 수신 메일이 정말 Haraka 에서 온 것인지 확인합니다. + { key: 'HARAKA_TOKEN', env: 'HARAKA_TOKEN', group: 'haraka', type: 'secret', hidden: true }, + // 사이트 정보(배포에는 필요 없고 운영 중 바꿀 수 있는 값) + { key: 'MAIL_FROM_NAME', env: 'MAIL_FROM_NAME', group: 'site', type: 'text' }, + { key: 'SITE_TITLE', env: 'SITE_TITLE', group: 'site', type: 'text' }, + { key: 'MAILBOX_OWNER_LOGIN', env: 'MAILBOX_OWNER_LOGIN', group: 'site', type: 'text' }, + { key: 'STATS_TIMEZONE', env: 'STATS_TIMEZONE', group: 'site', type: 'text', default: 'Asia/Seoul' }, + { key: 'SYNC_INTERVAL_MS', env: 'SYNC_INTERVAL_MS', group: 'site', type: 'number', default: 3600000 }, + // 사용량 API(1.0.9, 요구사항 1): 외부 관리 시스템(웹메이커)이 /api/usage 를 부를 때 쓰는 비밀 키(기본 도메인 설정에만, 화면의 단추로 생성·교체·삭제)와 접속 허용 IP/CIDR 목록(비우면 제한 없음). + { key: 'USAGE_API_KEY', env: 'USAGE_API_KEY', group: 'usage', type: 'secret', hidden: true }, + { key: 'USAGE_API_ALLOW', env: 'USAGE_API_ALLOW', group: 'usage', type: 'text' }, + // 초기 설정을 마쳤다는 표시(초기 설정 페이지의 "완료" 단추). 화면 안내용이며 동작에는 영향이 없습니다. + { key: 'SETUP_DONE', group: 'setup', type: 'bool', default: false }, +]; +const BY_KEY = new Map(CONFIG_SCHEMA.map((item) => [item.key, item])); + +/** 설정 파일 경로: CONFIG_FILE, 없으면 DB 파일 옆의 config.json. */ +export function configFile(env) { + return env.CONFIG_FILE || join(dirname(env.DB_FILE || join(process.cwd(), 'data', 'mail-gateway.sqlite')), 'config.json'); +} +/** 설정 파일과 같은 디렉터리의 보조 파일(setup-token, haraka-status.json). */ +export const configDir = (env) => dirname(configFile(env)); + +function readJsonFile(file) { + try { return JSON.parse(readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } +} +/** 임시 파일에 쓰고 이름을 바꿔 중간에 끊겨도 반쪽 파일이 남지 않게 합니다. 비밀키가 들어 있으므로 0600. */ +function writeJsonFile(file, data) { + mkdirSync(dirname(file), { recursive: true }); + const tmp = file + '.tmp'; + writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + try { chmodSync(tmp, 0o600); } catch { /* 권한을 못 바꾸는 파일 시스템이면 그대로 둡니다 */ } + renameSync(tmp, file); +} + +/** + * 값 하나를 항목 형식에 맞게 다듬습니다. 잘못된 값은 Error('validation') 를 던지며 field 에 키를 넣습니다. + * number 는 0 이상의 정수, select 는 목록 안의 값, bool 은 true/false/'1'/'0'. 빈 문자열은 "지움" 으로 보고 '' 로 둡니다. + */ +export function coerceValue(item, value) { + const fail = () => { const e = new Error('validation'); e.field = item.key; throw e; }; + if (value === null || value === undefined) return ''; + if (item.type === 'bool') { if (typeof value === 'boolean') return value; if (['1', 'true', 'on'].includes(String(value))) return true; if (['', '0', 'false', 'off'].includes(String(value))) return false; fail(); } + const s = String(value).trim(); + if (s === '') return ''; + if (item.type === 'number') { if (!/^\d{1,12}$/.test(s)) fail(); return Number(s); } + if (item.type === 'select') { if (!item.options.includes(s)) fail(); return s; } + if (s.length > 20000 || /[\0]/.test(s)) fail(); + return s; +} + +/** + * 환경 변수에서 설정 파일을 처음 만듭니다(1.0.0 으로 올린 기존 서버). env 에 있는 항목은 모두 그대로 복사하고, + * 수·발신 방식은 예전 동작과 같은 resend 로 둡니다. 가져온 사실은 _meta 에 남겨 화면이 "환경 파일에서 가져옴" 을 보여 줄 수 있습니다. + */ +function importFromEnv(env) { + const cfg = { _meta: { format: CONFIG_FORMAT, createdAt: new Date().toISOString(), importedFromEnv: [] } }; + for (const item of CONFIG_SCHEMA) { + if (!item.env || env[item.env] === undefined || env[item.env] === '') continue; + try { cfg[item.key] = coerceValue(item, env[item.env]); cfg._meta.importedFromEnv.push(item.key); } catch { /* 형식이 안 맞는 옛 값은 건너뜁니다 */ } + } + cfg.inbound = 'resend'; cfg.outbound = 'resend'; + return cfg; +} + +/** + * 설정 파일을 읽어(없으면 환경 변수에서 만들어) env 에 적용합니다. 서버 시작 때 한 번 부릅니다. + * 내부 토큰(HARAKA_TOKEN)이 없으면 만들어 저장합니다. 파일을 쓸 수 없는 환경(읽기 전용)이면 경고만 남기고 메모리에서만 동작합니다. + * @returns {object} 적용된 설정(env.CONFIG 와 같은 객체) + */ +export function loadConfig(env) { + const file = configFile(env); + let cfg = readJsonFile(file), created = false; + if (!cfg || typeof cfg !== 'object') { cfg = importFromEnv(env); created = true; } + if (!cfg._meta) cfg._meta = { format: CONFIG_FORMAT }; + if (!cfg.HARAKA_TOKEN) { cfg.HARAKA_TOKEN = secret(32); created = true; } + if (created) { try { writeJsonFile(file, cfg); } catch (e) { console.warn(` ⚠️ 설정 파일을 쓸 수 없습니다(${file}): ${e.message} — 환경 변수 값으로 동작합니다`); } } + applyConfig(env, cfg); + env.CONFIG_CREATED = created; + return cfg; +} + +/** + * 설정 값을 env 에 적용합니다. env 를 쓰는 다른 모듈(Resend 호출·발신 표시 이름·시간대 등)은 바뀌지 않았으므로 + * 저장 즉시 새 값이 쓰입니다. 파일에 있는 키만 덮어쓰고(빈 값 포함), 파일에 없는 키는 환경 변수 값을 그대로 둡니다. + * MAIL_INBOUND / MAIL_OUTBOUND 는 providers/index.mjs 가 읽는 수·발신 방식입니다. + */ +export function applyConfig(env, cfg) { + for (const item of CONFIG_SCHEMA) { + if (!item.env || !(item.key in cfg)) continue; + const v = cfg[item.key]; + env[item.env] = v === null || v === undefined ? '' : String(v); + } + env.MAIL_INBOUND = BY_KEY.get('inbound').options.includes(cfg.inbound) ? cfg.inbound : 'resend'; + env.MAIL_OUTBOUND = BY_KEY.get('outbound').options.includes(cfg.outbound) ? cfg.outbound : 'resend'; + env.CONFIG = cfg; + env.CONFIG_VERSION = (env.CONFIG_VERSION || 0) + 1; +} + +/** + * 설정 일부를 바꿔 저장하고 바로 적용합니다(초기 설정 페이지·관리자 페이지의 저장 단추). + * @param {object} patch { 키: 값 } — 알 수 없는 키는 무시, hidden 항목은 allowHidden 일 때만(내부 동작용) + * @returns {object} publicConfig(env) + */ +export function saveConfig(env, patch, { allowHidden = false } = {}) { + const cfg = { ...(env.CONFIG || readJsonFile(configFile(env)) || {}) }; + for (const [key, value] of Object.entries(patch || {})) { + const item = BY_KEY.get(key); if (!item || (item.hidden && !allowHidden)) continue; + cfg[key] = coerceValue(item, value); + } + cfg._meta = { ...(cfg._meta || {}), format: CONFIG_FORMAT, updatedAt: new Date().toISOString() }; + writeJsonFile(configFile(env), cfg); + applyConfig(env, cfg); + if (typeof env.ON_CONFIG_CHANGE === 'function') { try { env.ON_CONFIG_CHANGE(cfg); } catch (e) { console.error('설정 적용 알림 실패', e.message); } } + return publicConfig(env); +} + +/** 비밀값은 앞뒤 일부만 보여 줍니다(있는지·어느 키인지만 확인할 수 있게). 짝수 길이 보장 없이 짧은 값은 전부 가립니다. */ +export function maskSecret(value) { + const s = String(value || ''); if (!s) return ''; + if (s.length <= 8) return '••••••'; + return s.slice(0, 3) + '••••' + s.slice(-4); +} + +/** + * 화면에 주는 설정: 비밀값은 가리고, 각 키의 출처(config = 설정 파일, env = 환경 변수 폴백, default = 기본값)를 함께 줍니다. + * hidden 항목은 값 대신 있음/없음만 알립니다. + */ +export function publicConfig(env) { + const cfg = env.CONFIG || {}, values = {}, sources = {}, present = {}; + for (const item of CONFIG_SCHEMA) { + const inFile = item.key in cfg && cfg[item.key] !== '' && cfg[item.key] !== undefined && cfg[item.key] !== null; + const raw = inFile ? cfg[item.key] : item.env && env[item.env] ? env[item.env] : item.default ?? ''; + sources[item.key] = inFile ? 'config' : item.env && env[item.env] ? 'env' : 'default'; + if (item.hidden) { present[item.key] = !!raw; continue; } + values[item.key] = item.type === 'secret' ? maskSecret(raw) : raw; + } + return { + values, sources, present, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend', + file: configFile(env), importedFromEnv: cfg._meta?.importedFromEnv || [], updatedAt: cfg._meta?.updatedAt || cfg._meta?.createdAt || null, + schema: CONFIG_SCHEMA.filter((i) => !i.hidden).map(({ key, group, type, options, default: d, multiline }) => ({ key, group, type, ...(options ? { options } : {}), ...(d !== undefined ? { default: d } : {}), ...(multiline ? { multiline: true } : {}) })), + }; +} + +/** + * 초기 설정 페이지 접속 토큰. 설정 파일 옆 setup-token 에 두고 없으면 만듭니다(0600). + * 배포 스크립트가 이 파일을 읽어 접속 주소(…/?token=…)를 출력합니다. + */ +export function setupToken(env) { + const file = join(configDir(env), 'setup-token'); + try { const t = readFileSync(file, 'utf8').trim(); if (t) return t; } catch { /* 없으면 만듭니다 */ } + const token = secret(24); + try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, token + '\n', { mode: 0o600 }); } catch (e) { console.warn(' ⚠️ setup-token 을 쓸 수 없습니다:', e.message); } + return token; +} + +/** + * Haraka 설치 디렉터리(node_modules·config·plugins·queue): HARAKA_DIR, 없으면 데이터 디렉터리 옆의 haraka/. haraka/run.mjs 와 같은 규칙. + * 1.0.7: 추가 도메인의 env 는 DATA_DIR(기본 도메인의 데이터 디렉터리)을 가지므로 그 옆을 봅니다 — Haraka 는 서버에 하나뿐이고 모든 도메인이 함께 씁니다. + */ +export const harakaDir = (env) => env.HARAKA_DIR || join(env.DATA_DIR || configDir(env), '..', 'haraka'); +/** + * Haraka 상태: haraka/run.mjs(별도 systemd 서비스)가 데이터 디렉터리의 haraka-status.json 에 주기적으로 쓰는 값 + 설치 여부. + * 서비스가 아예 없으면(파일 없음) status 는 null 이고 화면은 "서비스 미설치(deploy/haraka-apply.sh)" 를 안내합니다. 1.0.5 에서 setup.mjs 에서 이곳으로 옮김(provision.mjs 도 씀). + */ +export function harakaStatus(env) { + const dir = harakaDir(env); let status = null; + // 상태 파일은 기본 도메인의 데이터 디렉터리(DATA_DIR)에 하나만 있습니다(1.0.7: 추가 도메인도 같은 파일을 봄). + try { status = JSON.parse(readFileSync(join(env.DATA_DIR || configDir(env), 'haraka-status.json'), 'utf8')); } catch { status = null; } + const stale = status && Date.now() - Date.parse(status.updatedAt || 0) > 90000; // 30초마다 쓰므로 90초 넘게 조용하면 서비스가 멈춘 것 + const wanted = env.MAIL_INBOUND === 'haraka' || env.MAIL_OUTBOUND === 'haraka'; + return { dir, installed: existsSync(join(dir, 'node_modules', 'Haraka', 'package.json')), status, stale: !!stale, wanted }; +} +/** 메일 도메인(MAILBOX 의 @ 뒤)과 Haraka 호스트 이름(HARAKA_HOSTNAME → PUBLIC_HOST). */ +export const mailDomain = (env) => String(env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || ''; +export const harakaHostname = (env) => String(env.HARAKA_HOSTNAME || env.PUBLIC_HOST || 'mail.' + mailDomain(env)).trim(); + +/** + * DKIM 키 쌍을 만들어 설정에 저장합니다(RSA 2048). 공개키는 DNS TXT 레코드에 넣고 비밀키는 Haraka 가 서명에 씁니다. + * 이미 키가 있으면 force 일 때만 바꿉니다(바꾸면 DNS 도 다시 올려야 하므로). + */ +export function generateDkim(env, { force = false } = {}) { + if (env.CONFIG?.HARAKA_DKIM_PRIVATE && !force) return dnsRecords(env); + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs1', format: 'pem' } }); + saveConfig(env, { HARAKA_DKIM_PRIVATE: privateKey, HARAKA_DKIM_PUBLIC: Buffer.from(publicKey).toString('base64') }, { allowHidden: true }); + return dnsRecords(env); +} + +/** + * Haraka 로 직접 수·발신할 때 도메인 DNS 에 넣어야 하는 레코드(화면 안내용). + * MX : 받는 메일이 이 서버로 오게(수신 Haraka) + * SPF : 이 서버가 보낸 메일을 받는 쪽이 신뢰하게(발신 Haraka). Resend 를 함께 쓰면 include 를 더합니다. + * DKIM : 서명 공개키(키를 만든 뒤) + * DMARC: 권장 정책(선택) + */ +export function dnsRecords(env) { + const domain = mailDomain(env), host = harakaHostname(env), selector = env.HARAKA_DKIM_SELECTOR || 'arimail', pub = env.HARAKA_DKIM_PUBLIC || ''; + const includeResend = env.MAIL_OUTBOUND === 'resend' ? ' include:amazonses.com' : ''; + const records = [ + { kind: 'MX', name: domain, value: `10 ${host}.`, for: 'inbound' }, + { kind: 'TXT', name: domain, value: `v=spf1 mx a:${host}${includeResend} ~all`, for: 'outbound' }, + ...(pub ? [{ kind: 'TXT', name: `${selector}._domainkey.${domain}`, value: `v=DKIM1; k=rsa; p=${pub}`, for: 'outbound' }] : []), + { kind: 'TXT', name: `_dmarc.${domain}`, value: 'v=DMARC1; p=none; adkim=r; aspf=r', for: 'outbound', optional: true }, + ]; + return { domain, host, selector, hasDkim: !!pub, records }; +} diff --git a/modules/arimail/server/db-migrate.mjs b/modules/arimail/server/db-migrate.mjs new file mode 100644 index 0000000..c461a1a --- /dev/null +++ b/modules/arimail/server/db-migrate.mjs @@ -0,0 +1,9 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { openDb, migrate } from './db.mjs'; +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const file = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite'); +const db = openDb(file); +console.log(` DB: ${file}`); +migrate(db, join(ROOT, 'migrations')); +db.close(); diff --git a/modules/arimail/server/db.mjs b/modules/arimail/server/db.mjs new file mode 100644 index 0000000..d5776c1 --- /dev/null +++ b/modules/arimail/server/db.mjs @@ -0,0 +1,62 @@ +/** + * SQLite 어댑터. www 사이트(server/db.mjs)와 같은 모양(prepare/bind/first/all/run/batch)을 유지해 + * 두 저장소의 코드를 서로 옮겨 쓸 수 있게 합니다. node:sqlite 는 Node 22.13+ 에서 플래그 없이 동작합니다. + */ +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync, readdirSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +class Stmt { + constructor(db, sql, params = []) { this.db = db; this.sql = sql; this.params = params; } + bind(...params) { return new Stmt(this.db, this.sql, this.params.length ? this.params : params); } + #prep() { return this.db.prepare(this.sql); } + async first() { const row = this.#prep().get(...this.params); return row === undefined ? null : row; } + async all() { return { results: this.#prep().all(...this.params) }; } + async run() { const r = this.#prep().run(...this.params); return { success: true, meta: { changes: Number(r.changes) } }; } +} + +export function openDb(file, { readOnly = false } = {}) { + if (file !== ':memory:' && !readOnly) mkdirSync(dirname(file), { recursive: true }); + const db = new DatabaseSync(file, readOnly ? { readOnly: true } : {}); + if (!readOnly) { + db.exec('PRAGMA foreign_keys = ON;'); + if (file !== ':memory:') { db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA busy_timeout = 5000;'); } + } else if (file !== ':memory:') db.exec('PRAGMA busy_timeout = 5000;'); + return db; +} + +export function d1(db) { + let queue = Promise.resolve(); + return { + prepare: (sql) => new Stmt(db, sql), + batch: (stmts) => { + const execute = async () => { + db.exec('BEGIN'); + try { const out = []; for (const s of stmts) out.push(await s.run()); db.exec('COMMIT'); return out; } + catch (e) { db.exec('ROLLBACK'); throw e; } + }; + const result = queue.then(execute, execute); + queue = result.catch(() => undefined); + return result; + }, + _raw: db, + }; +} + +export function migrate(db, dir, { quiet = false } = {}) { + db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)'); + const done = new Set(db.prepare('SELECT name FROM schema_migrations').all().map((r) => r.name)); + let applied = 0; + for (const f of readdirSync(dir).filter((x) => x.endsWith('.sql')).sort()) { + if (done.has(f)) continue; + db.exec('BEGIN'); + try { + db.exec(readFileSync(join(dir, f), 'utf8')); + db.prepare('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)').run(f, new Date().toISOString()); + db.exec('COMMIT'); applied++; + if (!quiet) console.log(` 적용: ${f}`); + } catch (e) { db.exec('ROLLBACK'); throw new Error(`마이그레이션 실패 ${f}: ${e.message}`); } + } + if (!quiet && !applied) console.log(' 적용할 마이그레이션 없음'); + return applied; +} diff --git a/modules/arimail/server/dns/common.mjs b/modules/arimail/server/dns/common.mjs new file mode 100644 index 0000000..8a37b81 --- /dev/null +++ b/modules/arimail/server/dns/common.mjs @@ -0,0 +1,52 @@ +/** + * DNS 모듈 공통 도구(1.0.5): 이름 정규화, TXT 분할, 오류 객체, 공개 DNS 조회(반영 확인). + */ +import { Resolver } from 'node:dns/promises'; + +/** FQDN(끝에 점). 빈 값은 그대로. */ +export const fqdn = (name) => { const s = String(name || '').trim().toLowerCase(); return !s ? s : s.endsWith('.') ? s : s + '.'; }; +/** 끝 점 없는 소문자 이름(비교용). */ +export const bare = (name) => fqdn(name).replace(/\.$/, ''); +/** TXT 값을 255자 이하 조각으로 나눕니다(RFC 1035 문자열 한도). */ +export const txtChunks = (value) => { const s = String(value ?? ''); const out = []; for (let i = 0; i < Math.max(1, s.length); i += 255) out.push(s.slice(i, i + 255)); return out; }; +/** dns_* 코드를 가진 Error. detail 은 화면에 그대로 보입니다. */ +export function dnsError(code, detail) { const e = new Error(code); e.detail = String(detail || '').slice(0, 300); return e; } + +/** + * 공개 리졸버(Google 8.8.8.8, Cloudflare 1.1.1.1)로 레코드가 실제로 보이는지 확인합니다. 권위 서버가 아니라 "세상이 보는 값" 기준이라 + * DNS 업체에 적용한 직후에는 아직 안 보일 수 있습니다(캐시·전파). 그 경우 state 'wait' 로 알립니다. + * @param {object} record { name, type, values[], priority? } + * @param {object} [o] resolver: 시험용 대체 객체({ resolveTxt, resolveMx, resolveCname, resolve4 }) + * @returns {Promise<{ok:boolean, found:string[], error?:string}>} + */ +export async function lookup(record, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) { + const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })(); + const name = bare(record.name); + try { + if (record.type === 'TXT') { + const found = (await r.resolveTxt(name)).map((chunks) => chunks.join('')); + const ok = record.values.every((v) => found.some((f) => f.replace(/\s+/g, ' ').trim() === String(v).replace(/\s+/g, ' ').trim())); + return { ok, found }; + } + if (record.type === 'MX') { + const found = (await r.resolveMx(name)).map((m) => `${m.priority} ${bare(m.exchange)}`); + const want = record.values.map((v) => `${record.priority ?? 10} ${bare(v)}`); + const ok = want.every((w) => found.includes(w)); + return { ok, found }; + } + if (record.type === 'CNAME') { const found = (await r.resolveCname(name)).map(bare); return { ok: record.values.every((v) => found.includes(bare(v))), found }; } + if (record.type === 'A') { const found = await r.resolve4(name); return { ok: record.values.every((v) => found.includes(v)), found }; } + return { ok: false, found: [], error: 'unsupported_type' }; + } catch (e) { + // ENOTFOUND/ENODATA = 아직 없음(전파 대기), 그 밖은 조회 실패 + return { ok: false, found: [], error: e.code === 'ENOTFOUND' || e.code === 'ENODATA' ? 'not_found' : e.code || e.message }; + } +} + +/** 호스트 이름의 A 레코드와 그 IP 의 PTR(역방향)을 함께 봅니다(Haraka 발신 준비 점검용). */ +export async function reverseCheck(hostname, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) { + const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })(); + let ips = []; try { ips = await r.resolve4(bare(hostname)); } catch (e) { return { ok: false, ips: [], ptr: [], error: 'a_' + (e.code || 'failed') }; } + const ptr = []; for (const ip of ips) { try { ptr.push(...(await r.reverse(ip)).map(bare)); } catch { /* PTR 없음 */ } } + return { ok: ips.length > 0 && ptr.includes(bare(hostname)), ips, ptr }; +} diff --git a/modules/arimail/server/dns/google.mjs b/modules/arimail/server/dns/google.mjs new file mode 100644 index 0000000..b9fdd3f --- /dev/null +++ b/modules/arimail/server/dns/google.mjs @@ -0,0 +1,99 @@ +/** + * Google Cloud DNS 모듈(1.0.5, 요구사항 1). 외부 의존성 없이 서비스 계정(JSON 키)으로 인증해 Cloud DNS REST API(v1)를 부릅니다. + * + * 인증: 서비스 계정 JSON 의 client_email / private_key 로 RS256 JWT 를 만들어 OAuth2 토큰(https://oauth2.googleapis.com/token)과 교환합니다. + * 범위는 ndev.clouddns.readwrite. 토큰은 만료 1분 전까지 재사용합니다. 서비스 계정에는 프로젝트의 "DNS 관리자" 역할이 필요합니다. + * 존 찾기: 프로젝트의 managedZones 목록에서 dnsName 이 도메인(또는 그 상위 도메인)과 같은 공개 존을 고릅니다(DNS_GOOGLE_ZONE 으로 고정 가능). + * 적용: changes API 에 additions/deletions 를 한 번에 넘깁니다(같은 이름·형식의 기존 rrset 은 deletions 로 빼고 새 값으로 넣음 = UPSERT). + * TXT 값은 따옴표로 감싸고 255자 단위로 나눕니다(DKIM 공개키처럼 긴 값). MX 는 "우선순위 호스트." 형식, CNAME/MX 의 호스트는 끝에 점을 붙입니다. + * + * 설정(config.json): DNS_GOOGLE_CREDENTIALS(서비스 계정 JSON 전체, 비밀), DNS_GOOGLE_PROJECT(비우면 JSON 의 project_id), DNS_GOOGLE_ZONE(비우면 자동). + * 시험: env.DNS_FETCH 로 fetch 를 바꿔 끼울 수 있습니다. + */ +import { createSign } from 'node:crypto'; +import { fqdn, txtChunks, dnsError } from './common.mjs'; + +const SCOPE = 'https://www.googleapis.com/auth/ndev.clouddns.readwrite'; +const tokenCache = new WeakMap(); // env.DB → { token, exp } + +/** 서비스 계정 JSON 해석. 비어 있거나 모양이 틀리면 dns_not_configured. */ +export function credentials(env) { + const raw = String(env.DNS_GOOGLE_CREDENTIALS || '').trim(); + if (!raw) throw dnsError('dns_not_configured', 'Google 서비스 계정 JSON 이 없습니다'); + let json; try { json = JSON.parse(raw); } catch { throw dnsError('dns_bad_credentials', '서비스 계정 JSON 을 해석할 수 없습니다'); } + if (!json.client_email || !json.private_key) throw dnsError('dns_bad_credentials', '서비스 계정 JSON 에 client_email / private_key 가 없습니다'); + const project = String(env.DNS_GOOGLE_PROJECT || json.project_id || '').trim(); + if (!project) throw dnsError('dns_not_configured', '프로젝트 id 가 없습니다(DNS_GOOGLE_PROJECT)'); + return { email: json.client_email, key: json.private_key, tokenUri: json.token_uri || 'https://oauth2.googleapis.com/token', project }; +} + +const b64url = (v) => Buffer.from(typeof v === 'string' ? v : JSON.stringify(v)).toString('base64url'); +/** RS256 JWT 를 만들어 액세스 토큰을 받습니다(캐시). */ +async function accessToken(env) { + const cached = tokenCache.get(env.DB || env); if (cached && cached.exp > Date.now() + 60000) return cached.token; + const c = credentials(env), now = Math.floor(Date.now() / 1000); + const unsigned = b64url({ alg: 'RS256', typ: 'JWT' }) + '.' + b64url({ iss: c.email, scope: SCOPE, aud: c.tokenUri, iat: now, exp: now + 3600 }); + let signature; try { signature = createSign('RSA-SHA256').update(unsigned).sign(c.key, 'base64url'); } catch (e) { throw dnsError('dns_bad_credentials', '개인키로 서명하지 못했습니다: ' + e.message); } + const r = await (env.DNS_FETCH || fetch)(c.tokenUri, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: unsigned + '.' + signature }), signal: AbortSignal.timeout(20000) }).catch((e) => { throw dnsError('dns_unavailable', 'Google 인증 서버 연결 실패: ' + e.message); }); + const data = await r.json().catch(() => ({})); + if (!r.ok || !data.access_token) throw dnsError(r.status === 400 || r.status === 401 ? 'dns_auth_failed' : 'dns_unavailable', `토큰 발급 실패 ${r.status} ${data.error_description || data.error || ''}`.trim()); + tokenCache.set(env.DB || env, { token: data.access_token, exp: Date.now() + Number(data.expires_in || 3600) * 1000 }); + return data.access_token; +} + +/** Cloud DNS API 호출. 오류는 dns_* 코드로. */ +async function api(env, path, method = 'GET', body) { + const token = await accessToken(env), c = credentials(env); + const r = await (env.DNS_FETCH || fetch)(`https://dns.googleapis.com/dns/v1/projects/${encodeURIComponent(c.project)}${path}`, { method, headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) }).catch((e) => { throw dnsError('dns_unavailable', 'Cloud DNS 연결 실패: ' + e.message); }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw dnsError(r.status === 401 ? 'dns_auth_failed' : r.status === 403 ? 'dns_forbidden' : r.status === 404 ? 'dns_zone_not_found' : 'dns_unavailable', `Cloud DNS ${r.status} ${data.error?.message || ''}`.trim()); + return data; +} + +/** + * 도메인을 담당하는 존을 찾습니다. DNS_GOOGLE_ZONE 이 있으면 그 존을, 없으면 dnsName 이 도메인과 같거나 상위인 공개 존 중 가장 긴 것. + * @returns {Promise<{id:string, name:string, dnsName:string}>} + */ +export async function findZone(env, domain) { + const want = fqdn(domain); + if (env.DNS_GOOGLE_ZONE) { const z = await api(env, '/managedZones/' + encodeURIComponent(env.DNS_GOOGLE_ZONE)); return { id: z.name, name: z.name, dnsName: z.dnsName }; } + const list = (await api(env, '/managedZones')).managedZones || []; + const candidates = list.filter((z) => z.visibility !== 'private' && (want === z.dnsName || want.endsWith('.' + z.dnsName))).sort((a, b) => b.dnsName.length - a.dnsName.length); + if (!candidates.length) throw dnsError('dns_zone_not_found', `${domain} 을 담당하는 존이 프로젝트에 없습니다`); + return { id: candidates[0].name, name: candidates[0].name, dnsName: candidates[0].dnsName }; +} + +/** 레코드 값 → Cloud DNS rrdatas 문자열. */ +export function rrdata(record) { + if (record.type === 'TXT') return record.values.map((v) => txtChunks(v).map((c) => `"${c.replace(/(["\\])/g, '\\$1')}"`).join(' ')); + if (record.type === 'MX') return record.values.map((v) => `${record.priority ?? 10} ${fqdn(v)}`); + if (record.type === 'CNAME' || record.type === 'NS') return record.values.map((v) => fqdn(v)); + return record.values.slice(); +} + +/** 존의 특정 이름·형식 rrset(없으면 null). */ +async function existing(env, zone, name, type) { + const data = await api(env, `/managedZones/${encodeURIComponent(zone.id)}/rrsets?name=${encodeURIComponent(fqdn(name))}&type=${encodeURIComponent(type)}`); + return (data.rrsets || [])[0] || null; +} + +/** + * 레코드들을 UPSERT 합니다(같은 이름·형식은 새 값으로 교체). 바뀐 것이 없으면 변경을 보내지 않습니다. + * @param {object[]} records { name, type, ttl, values[], priority? } + * @returns {Promise<{zone:string, changed:string[], unchanged:string[]}>} + */ +export async function upsert(env, zone, records) { + const additions = [], deletions = [], changed = [], unchanged = []; + for (const r of records) { + const want = { name: fqdn(r.name), type: r.type, ttl: Number(r.ttl || 300), rrdatas: rrdata(r) }; + const cur = await existing(env, zone, r.name, r.type); + if (cur && JSON.stringify([...cur.rrdatas].sort()) === JSON.stringify([...want.rrdatas].sort())) { unchanged.push(`${r.type} ${r.name}`); continue; } + if (cur) deletions.push({ name: cur.name, type: cur.type, ttl: cur.ttl, rrdatas: cur.rrdatas }); + additions.push(want); changed.push(`${r.type} ${r.name}`); + } + if (additions.length) await api(env, `/managedZones/${encodeURIComponent(zone.id)}/changes`, 'POST', { additions, deletions }); + return { zone: zone.name, changed, unchanged }; +} + +/** 연결 확인: 토큰을 받고 존을 찾아 봅니다. */ +export async function probe(env, domain) { const zone = await findZone(env, domain); return { ok: true, provider: 'google', zone: zone.name, dnsName: zone.dnsName, project: credentials(env).project }; } diff --git a/modules/arimail/server/dns/index.mjs b/modules/arimail/server/dns/index.mjs new file mode 100644 index 0000000..bda5895 --- /dev/null +++ b/modules/arimail/server/dns/index.mjs @@ -0,0 +1,99 @@ +/** + * DNS 모듈 선택과 "필요한 레코드" 계산(1.0.5, 요구사항 1·2). + * + * 역할 + * - dnsProvider(env): 설정 DNS_PROVIDER(none | google | route53)에 따라 Google Cloud DNS / AWS Route 53 모듈을 고릅니다. none 이면 자동 적용 없이 + * 레코드를 사용자에게 보여 주기만 합니다(요구사항 1: 인증 정보가 없으면 DNS 설정 정보를 제공). + * - requiredRecords(env, { resendDomain, existing }): 지금 설정(수·발신 방식, 도메인, Haraka 호스트 이름, DKIM, Resend 도메인의 요구 레코드)으로 + * 도메인에 있어야 하는 레코드 목록을 만듭니다. 기존 SPF 가 있으면 우리 메커니즘만 끼워 넣어(mergeSpf) 다른 발송 서비스 설정을 깨지 않습니다. + * - applyRecords(env, records): 고른 모듈로 존을 찾고 UPSERT 합니다. + * - checkRecords(records): 공개 리졸버로 반영 여부를 확인합니다(common.mjs lookup). + * + * 레코드 모양: { name(FQDN, 끝 점 없음), type, ttl, values[], priority?, purpose(화면 설명), source('haraka'|'resend'), optional?(권장 사항 — 준비 완료 판정에서 제외, 자동 적용도 하지 않음) } + */ +import * as google from './google.mjs'; +import * as route53 from './route53.mjs'; +import { lookup, bare, dnsError } from './common.mjs'; +import { inboundMode, outboundMode } from '../providers/index.mjs'; +import { mailDomain, harakaHostname } from '../config.mjs'; +import { resendDnsRecords } from '../providers/resend.mjs'; + +export const DNS_PROVIDERS = ['none', 'google', 'route53']; +const MODULES = { google, route53 }; + +/** 설정된 DNS 모듈 이름(모르는 값은 none). */ +export const dnsProviderName = (env) => (DNS_PROVIDERS.includes(env.DNS_PROVIDER) ? env.DNS_PROVIDER : 'none'); +/** 모듈 객체 또는 null(none). */ +export const dnsProvider = (env) => MODULES[dnsProviderName(env)] || null; +/** 모듈이 쓸 인증 정보가 설정에 있는지(값의 유효성은 probe 로 확인). */ +export function dnsConfigured(env) { + const p = dnsProviderName(env); + if (p === 'google') return !!String(env.DNS_GOOGLE_CREDENTIALS || '').trim(); + if (p === 'route53') return !!(String(env.DNS_AWS_ACCESS_KEY_ID || '').trim() && String(env.DNS_AWS_SECRET_ACCESS_KEY || '').trim()); + return false; +} + +/** + * 기존 SPF 레코드에 우리 메커니즘을 끼워 넣습니다. 예: "v=spf1 include:_spf.google.com ~all" + ["mx","a:mail.x.com"] → "v=spf1 include:_spf.google.com mx a:mail.x.com ~all". + * 이미 있는 메커니즘은 다시 넣지 않고, 끝의 all 지시(~all/-all/?all/+all)는 그대로 둡니다. 기존 값이 없으면 새로 만듭니다(기본 ~all). + */ +export function mergeSpf(existing, mechanisms) { + const cur = String(existing || '').trim(); + if (!/^v=spf1\b/i.test(cur)) return `v=spf1 ${mechanisms.join(' ')} ~all`; + const parts = cur.split(/\s+/); const all = parts.findLast((p) => /^[-~?+]?all$/i.test(p)); + const body = parts.filter((p) => !/^v=spf1$/i.test(p) && !/^[-~?+]?all$/i.test(p)); + for (const m of mechanisms) if (!body.some((p) => p.toLowerCase() === m.toLowerCase())) body.push(m); + return ['v=spf1', ...body, all || '~all'].join(' '); +} + +/** + * 도메인에 있어야 하는 레코드 목록. + * @param {object} [o] + * @param {object|null} [o.resendDomain] Resend 도메인 상세(records 포함). 수신 방식이 resend 가 아니면 그 안의 MX 는 뺍니다(루트 MX 는 한 가지만). + * @param {string[]} [o.existingSpf] 루트 TXT 중 기존 SPF 값(있으면 병합) + */ +export function requiredRecords(env, { resendDomain = null, existingSpf = null } = {}) { + const domain = mailDomain(env), host = bare(harakaHostname(env)), inbound = inboundMode(env), outbound = outboundMode(env), out = []; + if (inbound === 'haraka') out.push({ name: domain, type: 'MX', ttl: 300, values: [host], priority: 10, purpose: '받는 메일을 이 서버(Haraka)로', source: 'haraka' }); + if (outbound === 'haraka') { + out.push({ name: domain, type: 'TXT', ttl: 300, values: [mergeSpf(existingSpf, ['mx', `a:${host}`])], purpose: 'SPF — 이 서버가 보낸 메일을 신뢰', source: 'haraka' }); + if (env.HARAKA_DKIM_PUBLIC) out.push({ name: `${env.HARAKA_DKIM_SELECTOR || 'arimail'}._domainkey.${domain}`, type: 'TXT', ttl: 300, values: [`v=DKIM1; k=rsa; p=${env.HARAKA_DKIM_PUBLIC}`], purpose: 'DKIM 서명 공개키', source: 'haraka' }); + out.push({ name: `_dmarc.${domain}`, type: 'TXT', ttl: 300, values: ['v=DMARC1; p=none; adkim=r; aspf=r'], purpose: 'DMARC 정책(권장, 자동 적용하지 않음)', source: 'haraka', optional: true }); + } + if (resendDomain) for (const r of resendDnsRecords(resendDomain)) { if (r.type === 'MX' && inbound !== 'resend') continue; out.push(r); } + return out; +} + +/** + * 모든(선택 아닌) 레코드의 반영 여부. 각 레코드에 check { ok, found[], error?, byProvider?, problem? } 를 붙여 돌려줍니다. + * 1.0.11: Resend 가 자기 레코드를 verified 라고 하면 우리 조회 값이 달라도 ok 로 봅니다(byProvider). Resend 가 받아들인 값(예: 예전 수신 MX + * inbound-smtp.<지역>.amazonaws.com)을 "틀렸다" 고 표시해 사용자가 멀쩡한 레코드를 바꾸게 하는 일을 막습니다. 반대로 Resend 가 failed 라고 해도 + * 우리 조회가 맞으면 ok — Resend 의 레코드 상태는 마지막 검증 시점의 것이라 DNS 를 고친 뒤에는 오래된 값일 수 있습니다(재검증이 갱신). + * problem: 'wrong'(다른 값이 보임) / 'missing'(없음) — 화면이 "값을 바꾸세요" 와 "반영을 기다리세요" 를 구분하는 근거. + */ +export async function checkRecords(records, { resolver = null } = {}) { + const out = []; + for (const r of records) { + const check = await lookup(r, { resolver }); + const byProvider = !check.ok && String(r.status || '') === 'verified'; + out.push({ ...r, check: { ...check, ok: check.ok || byProvider, ...(byProvider ? { byProvider: true } : {}), problem: check.ok || byProvider ? null : check.found && check.found.length ? 'wrong' : 'missing' } }); + } + return out; +} + +/** + * 고른 DNS 모듈로 레코드를 적용합니다(optional 레코드는 뺌). 모듈이 없으면 dns_not_configured. + * @returns {Promise<{provider:string, zone:string, changed:string[], unchanged:string[]}>} + */ +export async function applyRecords(env, records) { + const mod = dnsProvider(env); if (!mod) throw dnsError('dns_not_configured', 'DNS 모듈이 설정되지 않았습니다(DNS_PROVIDER)'); + const zone = await mod.findZone(env, mailDomain(env)); + const result = await mod.upsert(env, zone, records.filter((r) => !r.optional)); + return { provider: dnsProviderName(env), ...result }; +} + +/** 연결 확인(설정 화면 단추): 인증과 존 찾기. */ +export async function probeDns(env) { + const mod = dnsProvider(env); if (!mod) return { ok: false, provider: 'none', error: 'dns_not_configured' }; + try { return await mod.probe(env, mailDomain(env)); } catch (e) { return { ok: false, provider: dnsProviderName(env), error: e.message, detail: e.detail }; } +} diff --git a/modules/arimail/server/dns/route53.mjs b/modules/arimail/server/dns/route53.mjs new file mode 100644 index 0000000..53cfae0 --- /dev/null +++ b/modules/arimail/server/dns/route53.mjs @@ -0,0 +1,107 @@ +/** + * AWS Route 53 모듈(1.0.5, 요구사항 1). 외부 의존성 없이 액세스 키로 SigV4 서명을 만들어 Route 53 REST API(2013-04-01, XML)를 부릅니다. + * + * 인증: DNS_AWS_ACCESS_KEY_ID / DNS_AWS_SECRET_ACCESS_KEY (IAM 사용자 또는 역할 키). 필요한 권한: route53:ListHostedZonesByName, GetHostedZone, + * ListResourceRecordSets, ChangeResourceRecordSets, GetChange. Route 53 은 글로벌 서비스라 서명 지역은 us-east-1 입니다. + * 존 찾기: ListHostedZonesByName 으로 도메인(또는 상위 도메인)과 이름이 같은 공개 호스팅 존을 고릅니다(DNS_AWS_HOSTED_ZONE_ID 로 고정 가능). + * 적용: ChangeResourceRecordSets 의 UPSERT 로 같은 이름·형식을 새 값으로 바꿉니다. TXT 는 따옴표 + 255자 분할, MX 는 "우선순위 호스트". + * XML 은 이 API 가 쓰는 단순한 모양만 다루므로 정규식으로 만들고 읽습니다(외부 XML 라이브러리 없음). + * 시험: env.DNS_FETCH 로 fetch 를 바꿔 끼울 수 있습니다. + */ +import { createHash, createHmac } from 'node:crypto'; +import { fqdn, txtChunks, dnsError } from './common.mjs'; + +const HOST = 'route53.amazonaws.com', REGION = 'us-east-1', SERVICE = 'route53'; +const sha256 = (v) => createHash('sha256').update(v).digest('hex'); +const hmac = (key, v, enc) => createHmac('sha256', key).update(v).digest(enc); +const esc = (v) => String(v).replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c])); +const unesc = (v) => String(v).replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&'); + +export function credentials(env) { + const id = String(env.DNS_AWS_ACCESS_KEY_ID || '').trim(), secret = String(env.DNS_AWS_SECRET_ACCESS_KEY || '').trim(); + if (!id || !secret) throw dnsError('dns_not_configured', 'AWS 액세스 키가 없습니다'); + return { id, secret }; +} + +/** + * SigV4 서명 요청. 쿼리 문자열은 미리 정규화(키 정렬·인코딩)해서 넘깁니다. + * @returns {Promise<{status:number, text:string}>} + */ +async function request(env, method, path, query = '', body = '') { + const c = credentials(env), now = new Date(), amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''), date = amzDate.slice(0, 8); + const payloadHash = sha256(body); + const headers = { host: HOST, 'x-amz-date': amzDate, 'x-amz-content-sha256': payloadHash, ...(body ? { 'content-type': 'application/xml' } : {}) }; + const signedHeaders = Object.keys(headers).sort().join(';'); + const canonical = [method, path.split('/').map((seg) => encodeURIComponent(decodeURIComponent(seg))).join('/'), query, ...Object.keys(headers).sort().map((k) => `${k}:${String(headers[k]).trim()}`), '', signedHeaders, payloadHash].join('\n'); + const scope = `${date}/${REGION}/${SERVICE}/aws4_request`; + const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonical)].join('\n'); + const kSigning = hmac(hmac(hmac(hmac('AWS4' + c.secret, date), REGION), SERVICE), 'aws4_request'); + const signature = hmac(kSigning, toSign, 'hex'); + const authorization = `AWS4-HMAC-SHA256 Credential=${c.id}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + const r = await (env.DNS_FETCH || fetch)(`https://${HOST}${path}${query ? '?' + query : ''}`, { method, headers: { ...headers, Authorization: authorization }, body: body || undefined, signal: AbortSignal.timeout(30000) }).catch((e) => { throw dnsError('dns_unavailable', 'Route 53 연결 실패: ' + e.message); }); + const text = await r.text().catch(() => ''); + if (!r.ok) { + const code = (text.match(/([^<]+)<\/Code>/) || [])[1] || String(r.status), message = unesc((text.match(/([^<]*)<\/Message>/) || [])[1] || ''); + throw dnsError(/InvalidClientTokenId|SignatureDoesNotMatch|InvalidSignature|AuthFailure|UnrecognizedClient/.test(code) ? 'dns_auth_failed' : /AccessDenied|NotAuthorized/.test(code) ? 'dns_forbidden' : /NoSuchHostedZone/.test(code) ? 'dns_zone_not_found' : 'dns_unavailable', `Route 53 ${code} ${message}`.trim()); + } + return { status: r.status, text }; +} + +/** 호스팅 존 찾기(DNS_AWS_HOSTED_ZONE_ID 가 있으면 그 존). 사설 존은 뺍니다. */ +export async function findZone(env, domain) { + if (env.DNS_AWS_HOSTED_ZONE_ID) { + const id = String(env.DNS_AWS_HOSTED_ZONE_ID).replace(/^\/hostedzone\//, ''); + const { text } = await request(env, 'GET', `/2013-04-01/hostedzone/${encodeURIComponent(id)}`); + return { id, name: unesc((text.match(/([^<]+)<\/Name>/) || [])[1] || ''), dnsName: unesc((text.match(/([^<]+)<\/Name>/) || [])[1] || '') }; + } + const want = fqdn(domain); const labels = want.split('.').filter(Boolean); + // 상위 도메인까지 차례로: mail.example.com. → example.com. → com. + for (let i = 0; i < labels.length - 1; i++) { + const candidate = labels.slice(i).join('.') + '.'; + const { text } = await request(env, 'GET', '/2013-04-01/hostedzonesbyname', `dnsname=${encodeURIComponent(candidate)}&maxitems=5`); + for (const block of text.match(/[\s\S]*?<\/HostedZone>/g) || []) { + const name = unesc((block.match(/([^<]+)<\/Name>/) || [])[1] || ''), id = ((block.match(/([^<]+)<\/Id>/) || [])[1] || '').replace(/^\/hostedzone\//, ''); + const priv = /true<\/PrivateZone>/.test(block); + if (name === candidate && !priv) return { id, name, dnsName: name }; + } + } + throw dnsError('dns_zone_not_found', `${domain} 을 담당하는 호스팅 존이 계정에 없습니다`); +} + +/** 레코드 값 → Route 53 ResourceRecord Value 목록. */ +export function values(record) { + if (record.type === 'TXT') return record.values.map((v) => txtChunks(v).map((c) => `"${c.replace(/(["\\])/g, '\\$1')}"`).join(' ')); + if (record.type === 'MX') return record.values.map((v) => `${record.priority ?? 10} ${fqdn(v)}`); + if (record.type === 'CNAME' || record.type === 'NS') return record.values.map((v) => fqdn(v)); + return record.values.slice(); +} + +/** 존의 이름·형식 rrset(없으면 null). */ +async function existing(env, zone, name, type) { + const { text } = await request(env, 'GET', `/2013-04-01/hostedzone/${encodeURIComponent(zone.id)}/rrset`, `maxitems=1&name=${encodeURIComponent(fqdn(name))}&type=${encodeURIComponent(type)}`); + const block = (text.match(/[\s\S]*?<\/ResourceRecordSet>/g) || [])[0]; if (!block) return null; + const n = unesc((block.match(/([^<]+)<\/Name>/) || [])[1] || ''), t = (block.match(/([^<]+)<\/Type>/) || [])[1]; + if (n.toLowerCase() !== fqdn(name).toLowerCase() || t !== type) return null; // maxitems=1 은 "그 이름부터" 돌려주므로 다른 레코드일 수 있음 + return { name: n, type: t, ttl: Number((block.match(/(\d+)<\/TTL>/) || [])[1] || 300), values: [...block.matchAll(/([^<]*)<\/Value>/g)].map((m) => unesc(m[1])) }; +} + +/** + * UPSERT. 바뀐 것이 없으면 요청을 보내지 않습니다. + * @returns {Promise<{zone:string, changed:string[], unchanged:string[], changeId?:string}>} + */ +export async function upsert(env, zone, records) { + const changes = [], changed = [], unchanged = []; + for (const r of records) { + const want = values(r), cur = await existing(env, zone, r.name, r.type); + if (cur && JSON.stringify([...cur.values].sort()) === JSON.stringify([...want].sort())) { unchanged.push(`${r.type} ${r.name}`); continue; } + changes.push(`UPSERT${esc(fqdn(r.name))}${esc(r.type)}${Number(r.ttl || 300)}${want.map((v) => `${esc(v)}`).join('')}`); + changed.push(`${r.type} ${r.name}`); + } + if (!changes.length) return { zone: zone.name, changed, unchanged }; + const body = `AriMail mail records${changes.join('')}`; + const { text } = await request(env, 'POST', `/2013-04-01/hostedzone/${encodeURIComponent(zone.id)}/rrset`, '', body); + return { zone: zone.name, changed, unchanged, changeId: ((text.match(/([^<]+)<\/Id>/) || [])[1] || '').replace(/^\/change\//, '') }; +} + +/** 연결 확인: 서명이 맞는지와 존이 있는지. */ +export async function probe(env, domain) { const zone = await findZone(env, domain); return { ok: true, provider: 'route53', zone: zone.name, zoneId: zone.id }; } diff --git a/modules/arimail/server/domains.mjs b/modules/arimail/server/domains.mjs new file mode 100644 index 0000000..6594b17 --- /dev/null +++ b/modules/arimail/server/domains.mjs @@ -0,0 +1,356 @@ +/** + * 다중 도메인 등록부와 "테넌트"(1.0.7, 요구사항 1·2). + * + * 생각의 틀 + * AriMail 의 모든 모듈은 `env` 객체 하나로 자기 세계(DB·설정 파일·메일 도메인·공개 호스트·로그인 방식)를 봅니다. 그래서 도메인을 하나 더 운영한다는 것은 + * "env 를 하나 더 만든다" 는 뜻입니다. 이 모듈은 도메인마다 그런 env(= 테넌트)를 만들어 보관하고, 들어온 요청(HTTP Host, 로그인 ID 의 @ 뒤, + * 받는 사람 주소)으로 어느 테넌트의 일인지 고릅니다. 각 도메인은 자기 DB(계정·세션·메일), 자기 설정 파일(Resend 키·DNS·수·발신 방식), 자기 파일 저장소를 + * 가지므로 로그인과 계정 관리가 도메인 단위로 완전히 나뉩니다. + * + * 기본 도메인(primary) + * 환경 파일(gateway.env)의 MAILBOX / PUBLIC_HOST / LOGIN_MODE / WWW_DB_FILE / DB_FILE 로 정해지는 기존 운영 그대로의 도메인입니다. 1.0.7 로 올린 서버는 + * 첫 시작에 등록부(domains.json)가 없으므로 이 도메인 하나로 등록부를 만들고(데이터는 전혀 옮기지 않음) 예전처럼 동작합니다. + * 기본 도메인의 DB·설정 파일 위치는 계속 환경 파일이 정하고, 등록부에는 추가 호스트 이름 같은 보조 정보만 둡니다. + * + * 추가 도메인 + * 데이터 디렉터리 아래 domains/<도메인>/ 에 mail.sqlite(같은 마이그레이션) · config.json · files/ 를 둡니다. 전체 설정 화면(전용 포트)에서 추가하면 + * 재시작 없이 바로 만들어지고, 첫 관리자(admin / 지정한 비밀번호)를 자동으로 만들어 바로 로그인할 수 있습니다. 로그인 방식은 도메인마다 + * local(자체 계정) · www(연결된 웹 서비스 DB 의 관리자 계정으로 인증) · webmaker(1.0.8: 웹메이커의 사용자 파일을 읽기 전용으로 참조, server/webmaker.mjs)입니다. + * + * 등록부 파일(domains.json, 기본 도메인의 데이터 디렉터리) + * { _meta:{format}, primary:'arihakdang.com', domains:{ 'example.com': { domain, enabled, hosts:[…], mailbox, auth:{mode, wwwDbFile, ownerLogin, webmakerDir, webmakerUser, siteId}, note, createdAt, updatedAt } } } + * + * 호스트 이름과 경로(1.0.8) + * hosts 의 항목은 "호스트" 또는 "호스트/경로"(예: site.ariweb.net/mail)입니다. 경로가 있으면 그 호스트의 그 경로 아래가 이 도메인의 웹 메일입니다 — 서브 도메인 사이트처럼 + * 메일용 호스트 이름을 따로 만들기 어려울 때 nginx 가 /mail/ 을 게이트웨이로 넘기고(X-Forwarded-Prefix: /mail, deploy/site-mail-path.sh) 화면은 그 경로를 기준()으로 동작합니다. + * + * 요청 → 테넌트 고르기(resolveHost) + * 1) 호스트가 없거나 localhost / IP 주소이면 기본 도메인(nginx 뒤의 상태 확인, 로컬 도구, 서버 IP 로 직접 접속). + * 2) 어떤 도메인의 hosts 에 정확히 있으면 그 도메인. 3) 호스트가 도메인과 같거나 그 하위(…​.example.com)면 그 도메인(가장 긴 도메인 우선). + * 4) 아무 데도 없으면 null → 화면은 "메일 서비스가 존재하지 않습니다" 를 보여 줍니다. 꺼 둔(enabled=false) 도메인은 disabled 로 알려 "활성화되지 않았습니다" 를 보여 줍니다. + */ +import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, accessSync, constants } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { openDb, d1, migrate } from './db.mjs'; +import { loadConfig, configFile, configDir, harakaDir, CONFIG_SCHEMA } from './config.mjs'; +import { ensureInitialAdmin, externalUsers, INITIAL_ADMIN_PASSWORD, PASSWORD_MIN_FLOOR } from './auth.mjs'; +import { memoryRateStore } from './util.mjs'; + +export const REGISTRY_FORMAT = 1; +export const AUTH_MODES = ['local', 'www', 'webmaker']; +/** 웹메이커 사이트 id(32자리 16진수)와 sudo 로 파일을 읽을 계정 이름의 형식. */ +const SITE_ID_RE = /^[a-f0-9]{32}$/, USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/; +/** 도메인 이름 형식: 소문자 영숫자·하이픈 이름표를 점으로 이은 것(국제화 도메인은 퓨니코드로). */ +const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const HOST_RE = DOMAIN_RE; +const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/; +/** + * 새 도메인을 만들 때 기본 도메인의 설정에서 복사해 오는 키(요구사항 2: 전체 설정에서 API 키·DNS 설정을 함께 관리). + * 도메인마다 다른 값(수·발신 방식, Resend 도메인/웹훅 id, DKIM 키, 사이트 이름, 완료 표시)은 복사하지 않습니다. DNS 존 id(DNS_GOOGLE_ZONE·DNS_AWS_HOSTED_ZONE_ID)도 + * 복사하지 않습니다 — 기본 도메인의 존에 새 도메인의 레코드를 넣으려다 실패하므로, 새 도메인은 도메인 이름으로 존을 자동 검색합니다. + */ +export const COPY_KEYS = ['RESEND_API_KEY', 'RESEND_REGION', 'DNS_PROVIDER', 'DNS_GOOGLE_CREDENTIALS', 'DNS_GOOGLE_PROJECT', 'DNS_AWS_ACCESS_KEY_ID', 'DNS_AWS_SECRET_ACCESS_KEY', 'SMTP_RELAY_HOST', 'SMTP_RELAY_PORT', 'SMTP_RELAY_SECURITY', 'SMTP_RELAY_USER', 'SMTP_RELAY_PASS', 'HARAKA_PORT', 'HARAKA_RELAY_PORT', 'HARAKA_HOSTNAME', 'STATS_TIMEZONE']; + +/** 호스트 이름 정규화: 소문자, 끝 점·포트 제거. "호스트/경로" 가 들어와도 호스트 부분만 돌려줍니다. */ +export const normalizeHost = (host) => String(host || '').trim().toLowerCase().split('/')[0].replace(/^\[([^\]]+)\](:\d+)?$/, '$1').replace(/:\d+$/, '').replace(/\.$/, ''); +/** + * 경로 접두사 정규화(1.0.8): "/mail", "mail/", "/a/b/" → "/mail", "/mail", "/a/b". 비어 있거나 "/" 면 ''(접두사 없음). 허용 글자 밖이거나 ".." 가 있으면 null. + * X-Forwarded-Prefix 헤더(nginx)와 등록부의 "호스트/경로" 둘 다 이 함수로 다듬습니다. + */ +export function normalizePrefix(prefix) { + const raw = String(prefix ?? '').trim(); if (!raw || raw === '/') return ''; + const parts = raw.split('/').filter(Boolean); + if (!parts.length) return ''; + for (const p of parts) if (p === '.' || p === '..' || !/^[A-Za-z0-9._~-]{1,64}$/.test(p)) return null; + return '/' + parts.join('/'); +} +/** 등록부의 호스트 항목("호스트" 또는 "호스트/경로")을 { host, prefix } 로 나눕니다(둘 다 소문자; nginx 설정과 맞추기 쉽게 경로도 소문자로 통일). */ +export function splitHostEntry(entry) { + const raw = String(entry || '').trim().toLowerCase(); const i = raw.indexOf('/'); + return { host: normalizeHost(i < 0 ? raw : raw.slice(0, i)), prefix: i < 0 ? '' : (normalizePrefix(raw.slice(i)) ?? null) }; +} +/** 호스트 항목의 호스트 이름 부분. */ +export const hostNameOf = (entry) => splitHostEntry(entry).host; +/** 도메인 이름 정규화(소문자, 앞뒤 공백·점 제거). */ +export const normalizeDomain = (domain) => String(domain || '').trim().toLowerCase().replace(/^\.+|\.+$/g, ''); +export const isDomainName = (domain) => DOMAIN_RE.test(normalizeDomain(domain)); +/** 로컬 접속으로 보는 호스트: 비어 있음, localhost(.localhost 포함), IPv4/IPv6 주소. 이런 요청은 기본 도메인이 받습니다. */ +export const isLocalHost = (host) => { const h = normalizeHost(host); return !h || h === 'localhost' || h.endsWith('.localhost') || IPV4_RE.test(h) || h.includes(':'); }; + +/** 등록부 파일 경로: 기본 도메인의 설정 파일(config.json) 옆 domains.json. */ +export const registryFile = (baseEnv) => join(configDir(baseEnv), 'domains.json'); + +function readJson(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } } +/** 임시 파일 → 이름 바꾸기(반쪽 파일 방지). 비밀값은 없지만 운영 정보이므로 0600. */ +function writeJson(file, data) { + mkdirSync(dirname(file), { recursive: true }); + const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + try { chmodSync(tmp, 0o600); } catch { /* 권한을 못 바꾸는 파일 시스템 */ } + renameSync(tmp, file); +} + +/** 검증 오류: Error('validation') + field. API 가 422 로 돌려줍니다. */ +const invalid = (field) => { const e = new Error('validation'); e.field = field; return e; }; + +/** + * 등록부 항목 하나를 입력(화면 폼)에서 만듭니다. 호스트 목록은 쉼표·공백으로 나눈 문자열이나 배열을 받고, 비어 있으면 mail.<도메인>. + * @param {object} body { domain, hosts?, mailbox?, auth?:{mode, wwwDbFile, ownerLogin}, note?, enabled? } + * @param {object|null} [current] 수정할 때의 기존 항목(없는 필드는 그대로 둠) + */ +export function normalizeEntry(body, current = null, { keepAuth = false } = {}) { + const domain = normalizeDomain(current?.domain || body.domain); + if (!isDomainName(domain)) throw invalid('domain'); + const rawHosts = body.hosts === undefined ? (current?.hosts || []) : Array.isArray(body.hosts) ? body.hosts : String(body.hosts || '').split(/[\s,]+/); + // 호스트 항목은 "호스트" 또는 "호스트/경로"(1.0.8). 호스트 이름을 정규화하고 경로는 /a/b 꼴로 다듬어 다시 "호스트/경로" 로 저장합니다. + const hosts = []; + for (const raw of rawHosts) { + if (!String(raw || '').trim()) continue; + const { host, prefix } = splitHostEntry(raw); + if (!host || !HOST_RE.test(host) || prefix === null) throw invalid('hosts'); + const entry = prefix ? host + prefix : host; if (!hosts.includes(entry)) hosts.push(entry); + } + if (!hosts.length) hosts.push('mail.' + domain); + // 같은 호스트 이름이 경로만 다르게 두 번 들어오면(mail.x.com 과 mail.x.com/mail) 어느 경로가 그 호스트의 웹 메일인지 애매하므로 거부합니다. + if (new Set(hosts.map(hostNameOf)).size !== hosts.length) throw invalid('hosts'); + const mailboxLocal = String(body.mailbox === undefined ? (current?.mailbox || 'admin@' + domain) : body.mailbox).trim().toLowerCase().split('@')[0] || 'admin'; + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(mailboxLocal)) throw invalid('mailbox'); + // keepAuth(기본 도메인): 로그인 방식은 환경 파일이 정하므로 입력을 무시하고 검사도 하지 않습니다(시험·www 모드에서 파일 경로가 없을 수 있음). + const authIn = keepAuth ? {} : body.auth && typeof body.auth === 'object' ? body.auth : {}; + const mode = authIn.mode === undefined ? (current?.auth?.mode || 'local') : String(authIn.mode); + if (!AUTH_MODES.includes(mode)) throw invalid('auth.mode'); + const wwwDbFile = authIn.wwwDbFile === undefined ? (current?.auth?.wwwDbFile || '') : String(authIn.wwwDbFile || '').trim(); + const ownerLogin = authIn.ownerLogin === undefined ? (current?.auth?.ownerLogin || '') : String(authIn.ownerLogin || '').trim().slice(0, 200); + if (mode === 'www' && !keepAuth) { if (!wwwDbFile) throw invalid('auth.wwwDbFile'); try { accessSync(wwwDbFile, constants.R_OK); } catch { const e = new Error('www_db_unreadable'); e.field = 'auth.wwwDbFile'; throw e; } } + // webmaker 연동(1.0.8): 데이터 디렉터리(DATA_ROOT, 절대 경로)는 필수. webmakerUser 가 있으면 그 계정의 sudo 도우미로 읽으므로 이 프로세스의 읽기 권한을 검사하지 않고, + // 없으면 users.json 을 지금 읽을 수 있어야 합니다(webmaker_dir_unreadable). siteId 는 도메인 ↔ 사이트 대응을 손으로 고정할 때만 적습니다(비우면 도메인 이름으로 찾음). + const webmakerDir = authIn.webmakerDir === undefined ? (current?.auth?.webmakerDir || '') : String(authIn.webmakerDir || '').trim().replace(/\/+$/, ''); + const webmakerUser = authIn.webmakerUser === undefined ? (current?.auth?.webmakerUser || '') : String(authIn.webmakerUser || '').trim(); + const siteId = authIn.siteId === undefined ? (current?.auth?.siteId || '') : String(authIn.siteId || '').trim().toLowerCase(); + if (mode === 'webmaker' && !keepAuth) { + if (!webmakerDir || !webmakerDir.startsWith('/') || webmakerDir.includes('..')) throw invalid('auth.webmakerDir'); + if (webmakerUser && !USER_RE.test(webmakerUser)) throw invalid('auth.webmakerUser'); + if (siteId && !SITE_ID_RE.test(siteId)) throw invalid('auth.siteId'); + if (!webmakerUser) { try { accessSync(join(webmakerDir, 'users.json'), constants.R_OK); } catch { const e = new Error('webmaker_dir_unreadable'); e.field = 'auth.webmakerDir'; throw e; } } + } + const note = String(body.note === undefined ? (current?.note || '') : body.note || '').trim().slice(0, 200); + const enabled = body.enabled === undefined ? (current ? current.enabled !== false : true) : !(body.enabled === false || body.enabled === 'false' || body.enabled === '0' || body.enabled === 0); + const stamp = new Date().toISOString(); + return { domain, enabled, hosts, mailbox: `${mailboxLocal}@${domain}`, auth: { mode, wwwDbFile: mode === 'www' ? wwwDbFile : '', ownerLogin: mode === 'www' ? ownerLogin : '', webmakerDir: mode === 'webmaker' ? webmakerDir : '', webmakerUser: mode === 'webmaker' ? webmakerUser : '', siteId: mode === 'webmaker' ? siteId : '' }, note, createdAt: current?.createdAt || stamp, updatedAt: stamp }; +} + +/** + * 등록부 항목의 로그인 방식을 env 에 반영합니다(createEnv 와 update 가 공유). LOGIN_MODE 와 그 방식의 값만 두고 다른 방식의 값은 지웁니다. + * www : WWW_DB_FILE, MAILBOX_OWNER_LOGIN webmaker : WEBMAKER_DIR, WEBMAKER_USER(선택), WEBMAKER_SITE(선택) local : standalone(자체 계정만) + */ +export function applyAuth(env, auth) { + for (const k of ['WWW_DB_FILE', 'MAILBOX_OWNER_LOGIN', 'WEBMAKER_DIR', 'WEBMAKER_USER', 'WEBMAKER_SITE']) delete env[k]; + if (auth.mode === 'www') { env.LOGIN_MODE = 'www'; env.WWW_DB_FILE = auth.wwwDbFile; env.MAILBOX_OWNER_LOGIN = auth.ownerLogin || ''; } + else if (auth.mode === 'webmaker') { env.LOGIN_MODE = 'webmaker'; env.WEBMAKER_DIR = auth.webmakerDir; if (auth.webmakerUser) env.WEBMAKER_USER = auth.webmakerUser; if (auth.siteId) env.WEBMAKER_SITE = auth.siteId; } + else env.LOGIN_MODE = 'standalone'; +} +/** 공개용 auth 요약(목록·API). 비밀값은 없지만 모양을 한곳에서 정합니다. */ +const publicAuth = (a) => ({ mode: a.mode, wwwDbFile: a.wwwDbFile || '', ownerLogin: a.ownerLogin || '', webmakerDir: a.webmakerDir || '', webmakerUser: a.webmakerUser || '', siteId: a.siteId || '' }); + +/** 기본 도메인 항목: 환경 파일 값으로 만듭니다(등록부에 보조 정보가 있으면 hosts·note 만 더합니다). */ +function primaryEntry(baseEnv, stored = null) { + const mailbox = String(baseEnv.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase(), domain = mailbox.split('@')[1] || ''; + const host = normalizeHost(baseEnv.PUBLIC_HOST || 'mail.' + domain); + // 기본 호스트의 경로 접두사는 환경 파일의 PUBLIC_PATH(선택, 1.0.8). 등록부에 적힌 나머지 호스트는 "호스트/경로" 그대로 둡니다(이름이 겹치면 앞의 것). + const first = host + (normalizePrefix(baseEnv.PUBLIC_PATH) || ''); + const hosts = []; for (const h of [first, ...(Array.isArray(stored?.hosts) ? stored.hosts : [])]) { const { host: n, prefix } = splitHostEntry(h); if (!n || prefix === null || hosts.some((x) => hostNameOf(x) === n)) continue; hosts.push(prefix ? n + prefix : n); } + // 로그인 방식은 환경 파일이 정합니다: LOGIN_MODE=webmaker(+WEBMAKER_DIR) → webmaker, WWW_DB_FILE(시험에서는 메모리 WWW_DB) → www, 그 밖에는 local. + const mode = String(baseEnv.LOGIN_MODE || '') === 'webmaker' && baseEnv.WEBMAKER_DIR ? 'webmaker' : baseEnv.WWW_DB_FILE || baseEnv.WWW_DB ? 'www' : 'local'; + const auth = { mode, wwwDbFile: mode === 'www' ? String(baseEnv.WWW_DB_FILE || '') : '', ownerLogin: mode === 'www' ? String(baseEnv.MAILBOX_OWNER_LOGIN || '') : '', webmakerDir: mode === 'webmaker' ? String(baseEnv.WEBMAKER_DIR) : '', webmakerUser: mode === 'webmaker' ? String(baseEnv.WEBMAKER_USER || '') : '', siteId: mode === 'webmaker' ? String(baseEnv.WEBMAKER_SITE || '') : '' }; + return { domain, enabled: true, primary: true, hosts, mailbox, auth, note: stored?.note || '', createdAt: stored?.createdAt || new Date().toISOString(), updatedAt: stored?.updatedAt || new Date().toISOString() }; +} + +/** 등록부를 읽습니다. 없으면 기본 도메인만 담아 만듭니다(1.0.7 로 올린 서버의 첫 시작). 파일을 쓸 수 없으면 메모리에서만 동작합니다. */ +export function loadRegistry(baseEnv) { + const file = registryFile(baseEnv); + let reg = readJson(file), created = false; + const primary = primaryEntry(baseEnv, reg?.domains?.[String(reg?.primary || '')] || null); + if (!reg || typeof reg !== 'object' || !reg.domains || typeof reg.domains !== 'object') { reg = { _meta: { format: REGISTRY_FORMAT, createdAt: new Date().toISOString() }, primary: primary.domain, domains: {} }; created = true; } + // 기본 도메인은 환경 파일이 기준입니다. 환경 파일의 도메인이 바뀌었으면(드문 일) 옛 기본 항목은 일반 항목으로 남기지 않고 지웁니다(그 데이터는 기본 DB 그대로). + if (reg.primary !== primary.domain) { delete reg.domains[reg.primary]; reg.primary = primary.domain; created = true; } + reg.domains[primary.domain] = primary; + if (created) { try { writeJson(file, reg); } catch (e) { console.warn(` ⚠️ 도메인 등록부를 쓸 수 없습니다(${file}): ${e.message}`); } } + return reg; +} + +/** 테넌트 env 에서 떼어 내야 하는 키: 설정 파일이 관리하는 값(기본 도메인의 비밀값이 새 도메인에 새지 않게)과 도메인마다 다른 값. */ +const TENANT_OWN_KEYS = new Set(['DB', 'DB_FILE', 'CONFIG_FILE', 'FILES_DIR', 'MAILBOX', 'PUBLIC_HOST', 'PUBLIC_PATH', 'WWW_DB', 'WWW_DB_FILE', 'LOGIN_MODE', 'MAILBOX_OWNER_LOGIN', 'WEBMAKER_DIR', 'WEBMAKER_USER', 'WEBMAKER_SITE', 'WEBMAKER_SOCKET', 'STRINGS', 'PAGE', 'CONFIG', 'CONFIG_VERSION', 'CONFIG_CREATED', 'ON_CONFIG_CHANGE', 'MAIL_INBOUND', 'MAIL_OUTBOUND', 'PLAN_DNS_APPLIED_AT', 'DOMAIN', 'TENANT', 'RATE', 'SYNCING', ...CONFIG_SCHEMA.filter((i) => i.env).map((i) => i.env)]); + +/** + * 테넌트 관리자. 서버 시작 때 한 번 만들고(openTenants) 모든 요청이 이 객체로 env 를 고릅니다. + * @param {object} baseEnv 기본 도메인의 env(main.mjs 가 이미 DB·설정을 연 상태) + * @param {object} o + * @param {string} o.migrationsDir 새 도메인 DB 에 적용할 마이그레이션 디렉터리 + * @param {(env:object)=>void} [o.onCreate] 테넌트 env 가 생길 때(기본 도메인 포함) 부르는 훅 — main.mjs 가 버전·문구·화면 캐시를 붙입니다 + */ +export function openTenants(baseEnv, { migrationsDir, onCreate = null, log = console } = {}) { + const registry = loadRegistry(baseEnv); + const dataDir = configDir(baseEnv); + const tenants = new Map(); // domain → { entry, env, db } + const creating = new Set(); // add() 진행 중인 도메인 + const stripped = Object.fromEntries(Object.entries(baseEnv).filter(([k]) => !TENANT_OWN_KEYS.has(k))); + + /** 기본 도메인: 이미 열린 env 를 그대로 테넌트로 씁니다(데이터 이동 없음). */ + const attachPrimary = () => { + const entry = registry.domains[registry.primary]; + Object.assign(baseEnv, { DOMAIN: entry.domain, TENANT: entry, DATA_DIR: dataDir, MAIL_SERVER_HOST: normalizeHost(baseEnv.PUBLIC_HOST || entry.hosts[0]), PUBLIC_PATH: splitHostEntry(entry.hosts[0]).prefix || '' }); + if (!baseEnv.RATE) baseEnv.RATE = memoryRateStore(); + tenants.set(entry.domain, { entry, env: baseEnv, db: null }); + if (onCreate) onCreate(baseEnv); + }; + /** 추가 도메인의 디렉터리. 도메인 이름은 normalizeEntry 가 검사했으므로 경로에 그대로 써도 안전합니다. */ + const dirOf = (domain) => join(dataDir, 'domains', domain); + /** + * 추가 도메인의 env 를 만듭니다: 자기 DB(마이그레이션 적용)·설정 파일·파일 디렉터리. 설정 파일이 없으면(처음) seed 값으로 만듭니다. + * @param {object} entry 등록부 항목 @param {object|null} seed 처음 만들 때 넣을 설정 값(COPY_KEYS) + */ + const createEnv = (entry, { seed = null, adminPassword = null } = {}) => { + const dir = dirOf(entry.domain); mkdirSync(dir, { recursive: true }); + // PUBLIC_HOST 는 첫 호스트의 이름 부분, PUBLIC_PATH 는 그 경로 접두사(없으면 '')(1.0.8). 링크(대용량 파일·설정 화면 안내)는 둘을 이어 만듭니다. + const { host: publicHost, prefix: publicPath } = splitHostEntry(entry.hosts[0]); + const env = { ...stripped, DOMAIN: entry.domain, TENANT: entry, DATA_DIR: dataDir, DB_FILE: join(dir, 'mail.sqlite'), CONFIG_FILE: join(dir, 'config.json'), FILES_DIR: join(dir, 'files'), MAILBOX: entry.mailbox, PUBLIC_HOST: publicHost, PUBLIC_PATH: publicPath || '', MAIL_SERVER_HOST: baseEnv.MAIL_SERVER_HOST || normalizeHost(baseEnv.PUBLIC_HOST || ''), HARAKA_DIR: harakaDir(baseEnv), RATE: memoryRateStore(), TENANTS: api }; + applyAuth(env, entry.auth); + const db = openDb(env.DB_FILE); migrate(db, migrationsDir, { quiet: true }); env.DB = d1(db); + if (seed && !existsSync(configFile(env))) { const cfg = { _meta: { format: 1, createdAt: new Date().toISOString(), copiedFrom: registry.primary } }; for (const k of COPY_KEYS) if (seed[k] !== undefined && seed[k] !== '') cfg[k] = seed[k]; cfg.inbound = 'resend'; cfg.outbound = 'resend'; writeJson(configFile(env), cfg); } + loadConfig(env); + if (onCreate) onCreate(env); + return { entry, env, db, adminPassword }; + }; + + const api = { + get registry() { return registry; }, + get primary() { return tenants.get(registry.primary); }, + /** 모든 테넌트(기본 도메인 먼저). */ + all() { return [this.primary, ...[...tenants.values()].filter((t) => t.entry.domain !== registry.primary)]; }, + /** 켜진 테넌트의 env 목록(주기 작업용). */ + envs() { return this.all().filter((t) => t.entry.enabled !== false).map((t) => t.env); }, + byDomain(domain) { return tenants.get(normalizeDomain(domain)) || null; }, + /** 주소(user@domain)의 도메인으로 테넌트를 찾습니다. 도메인이 없는 로그인 ID 는 null(호출자가 기본 도메인 등으로 정함). */ + byAddress(address) { const d = String(address || '').trim().toLowerCase().split('@')[1]; return d ? this.byDomain(d) : null; }, + /** + * HTTP Host 로 테넌트를 고릅니다. + * @returns {{tenant:object|null, disabled:boolean, host:string}} tenant 가 null 이면 모르는 도메인, disabled 면 꺼 둔 도메인 + */ + resolveHost(host) { + const h = normalizeHost(host); + let found = null, prefix = ''; + if (isLocalHost(h)) found = this.primary; + else { + // 등록된 호스트 이름(경로가 붙어 있으면 그 경로가 이 호스트의 접두사, 1.0.8) + for (const t of tenants.values()) { const e = t.entry.hosts.find((x) => hostNameOf(x) === h); if (e) { found = t; prefix = splitHostEntry(e).prefix || ''; break; } } + if (!found) { let best = ''; for (const t of tenants.values()) { const d = t.entry.domain; if ((h === d || h.endsWith('.' + d)) && d.length > best.length) { best = d; found = t; } } } + } + return { tenant: found, disabled: !!found && found.entry.enabled === false, host: h, prefix }; + }, + /** + * 메일 프로그램 로그인(IMAP·POP·SMTP)에서 쓰는 env 고르기: 로그인 ID 에 @도메인이 있으면 그 도메인, 없으면 TLS SNI 호스트가 가리키는 도메인, 그래도 없으면 기본 도메인. + * 꺼 둔 도메인은 null → 로그인 실패. 등록되지 않은 @도메인은 기본 도메인(또는 SNI 도메인)의 계정으로 검사합니다(www 관리자 이메일 로그인 호환). + */ + resolveLogin(login, servername = '') { + const key = String(login || '').trim().toLowerCase(); + // @ 뒤가 등록된 도메인이면 그 도메인(꺼 두었으면 null). 등록되지 않은 도메인(예: www 관리자가 자기 이메일 someone@gmail.com 으로 로그인)은 + // 아래 SNI·기본 도메인 규칙으로 넘깁니다 — verifyUser 가 그 env 의 계정(www 관리자의 이메일 포함)으로 검사하므로 안전합니다. + if (key.includes('@')) { const t = this.byAddress(key); if (t) return t.entry.enabled !== false ? t.env : null; } + if (servername) { const r = this.resolveHost(servername); if (r.tenant && !r.disabled) return r.tenant.env; } + return this.primary.env; + }, + /** 화면·API 용 요약 목록(비밀값 없음). */ + async list() { + const out = []; + for (const t of this.all()) { + const e = t.entry, env = t.env; let accounts = 0, messages = 0; + try { accounts = Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first())?.n || 0); messages = Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM messages').first())?.n || 0); } catch { /* DB 오류는 0 으로 */ } + // initialPassword: 도메인을 만들 때 정한 초기 관리자 비밀번호. 관리자가 비밀번호를 바꾸면(initial_password: 표시가 지워짐) 더는 보여 주지 않습니다. + let initialPassword = null; + if (t.adminPassword) { try { if (await env.DB.prepare("SELECT 1 AS x FROM settings WHERE key LIKE 'initial_password:%'").first()) initialPassword = t.adminPassword; else t.adminPassword = null; } catch { /* 무시 */ } } + // external(1.0.8): webmaker 연동 도메인의 웹메이커 계정 수(읽지 못하면 오류 문구). 화면이 "계정 N명(+웹메이커 M명)" 으로 보여 줍니다. + let external = null; + if (e.auth.mode === 'webmaker') { try { const r = await externalUsers(env); external = r.ok ? { count: r.accounts.length, site: r.site } : { count: 0, error: r.error }; } catch (err) { external = { count: 0, error: err.message }; } } + out.push({ domain: e.domain, primary: e.domain === registry.primary, enabled: e.enabled !== false, hosts: e.hosts, mailbox: e.mailbox, auth: publicAuth(e.auth), note: e.note || '', createdAt: e.createdAt, updatedAt: e.updatedAt, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend', setupDone: env.CONFIG?.SETUP_DONE === true, accounts, messages, external, dir: e.domain === registry.primary ? configDir(env) : dirOf(e.domain), initialPassword }); + } + return out; + }, + /** + * 도메인을 추가합니다(전체 설정 화면). 등록부에 쓰고 바로 env 를 만들어 요청을 받기 시작합니다. + * @param {object} body normalizeEntry 입력 + { adminPassword?, copyFromPrimary? (기본 true) } + * @returns {Promise<{entry:object, initialAdmin:{login:string,password:string}|null}>} + */ + async add(body) { + const entry = normalizeEntry(body); + // creating: 같은 도메인을 동시에 두 번 추가하는 요청이 중복 검사를 함께 통과하지 못하게 합니다(DB 핸들 누수 방지). + if (tenants.has(entry.domain) || creating.has(entry.domain)) throw new Error('domain_exists'); + if (entry.domain === registry.primary) throw new Error('domain_exists'); + creating.add(entry.domain); + try { return await this._add(entry, body); } finally { creating.delete(entry.domain); } + }, + async _add(entry, body) { + for (const t of tenants.values()) for (const h of entry.hosts) if (t.entry.hosts.some((x) => hostNameOf(x) === hostNameOf(h))) { const e = new Error('host_taken'); e.field = 'hosts'; e.detail = h; throw e; } + const adminPassword = String(body.adminPassword || INITIAL_ADMIN_PASSWORD); + if ([...adminPassword].length < PASSWORD_MIN_FLOOR) throw invalid('adminPassword'); + const seed = body.copyFromPrimary === false ? null : (this.primary.env.CONFIG || {}); + const t = createEnv(entry, { seed, adminPassword }); + // 첫 관리자: 로그인 방식이 local 이면 admin(메일함 앞부분) 계정을 지정한 비밀번호로 만듭니다(www 연동이면 그쪽 관리자가 로그인). + let initialAdmin = null; + if (entry.auth.mode === 'local') { const made = await ensureInitialAdmin(t.env, { password: adminPassword }); if (made) initialAdmin = made; } + registry.domains[entry.domain] = entry; writeJson(registryFile(baseEnv), registry); + tenants.set(entry.domain, t); + log.log(` 도메인 추가: ${entry.domain} (${entry.hosts.join(', ')}, 로그인 ${entry.auth.mode}${initialAdmin ? `, 초기 관리자 ${initialAdmin.login}` : ''})`); + return { entry, initialAdmin }; + }, + /** 도메인 정보를 바꿉니다(켜기·끄기, 호스트, 로그인 방식, 메모). 기본 도메인은 hosts·note 만 바꿀 수 있습니다(나머지는 환경 파일). */ + update(domain, body) { + const t = this.byDomain(domain); if (!t) throw new Error('not_found'); + const isPrimary = t.entry.domain === registry.primary; + const next = isPrimary ? { ...t.entry, ...normalizeEntry({ hosts: body.hosts, note: body.note }, t.entry, { keepAuth: true }), enabled: true, primary: true, mailbox: t.entry.mailbox, auth: t.entry.auth } : normalizeEntry({ ...body, domain: t.entry.domain, mailbox: undefined }, t.entry); + // 기본 도메인의 첫 호스트는 환경 파일의 PUBLIC_HOST(+PUBLIC_PATH)로 고정합니다(목록에서 빠지거나 뒤로 가면 앞에 되돌림). + if (isPrimary) { const ph = normalizeHost(baseEnv.PUBLIC_HOST || ''), first = ph + (normalizePrefix(baseEnv.PUBLIC_PATH) || ''); next.hosts = [first, ...next.hosts.filter((h) => hostNameOf(h) !== ph)]; } + for (const other of tenants.values()) if (other !== t) for (const h of next.hosts) if (other.entry.hosts.some((x) => hostNameOf(x) === hostNameOf(h))) { const e = new Error('host_taken'); e.field = 'hosts'; e.detail = h; throw e; } + if (!isPrimary) { + // 로그인 방식(www DB·웹메이커 디렉터리)은 env 에 바로 반영합니다(세션·계정 조회가 다음 요청부터 새 방식을 씀). + applyAuth(t.env, next.auth); + const { host: publicHost, prefix } = splitHostEntry(next.hosts[0]); t.env.PUBLIC_HOST = publicHost; t.env.PUBLIC_PATH = prefix || ''; + if (typeof t.env.ON_CONFIG_CHANGE === 'function') t.env.ON_CONFIG_CHANGE(t.env.CONFIG || {}); + } + t.entry = next; t.env.TENANT = next; registry.domains[next.domain] = next; writeJson(registryFile(baseEnv), registry); + log.log(` 도메인 변경: ${next.domain} (${next.enabled ? '사용' : '중지'}, ${next.hosts.join(', ')}, 로그인 ${next.auth.mode})`); + return next; + }, + /** 도메인을 등록부에서 지웁니다. 데이터 디렉터리(domains/<도메인>/)는 남겨 두어 되돌릴 수 있습니다(지우려면 서버에서 직접). 기본 도메인은 지울 수 없습니다. */ + remove(domain) { + const t = this.byDomain(domain); if (!t) throw new Error('not_found'); + if (t.entry.domain === registry.primary) throw new Error('primary_domain'); + tenants.delete(t.entry.domain); delete registry.domains[t.entry.domain]; writeJson(registryFile(baseEnv), registry); + try { t.db?.close(); } catch { /* 이미 닫힘 */ } + log.log(` 도메인 삭제: ${t.entry.domain} (데이터 디렉터리는 남김: ${dirOf(t.entry.domain)})`); + return { domain: t.entry.domain, dir: dirOf(t.entry.domain) }; + }, + /** 종료: 추가 도메인 DB 를 닫습니다(기본 DB 는 main.mjs 가 닫음). */ + close() { for (const t of tenants.values()) if (t.db) { try { t.db.close(); } catch { /* 무시 */ } } }, + }; + attachPrimary(); + baseEnv.TENANTS = api; + // 등록부의 추가 도메인을 모두 엽니다. 하나가 깨져도(파일 권한 등) 나머지와 기본 도메인은 뜨도록 오류는 기록만 합니다. + for (const [domain, entry] of Object.entries(registry.domains)) { + if (domain === registry.primary) continue; + try { tenants.set(domain, createEnv(entry)); log.log(` 도메인: ${domain} (${entry.hosts.join(', ')}, ${entry.enabled === false ? '중지' : '사용'}, 로그인 ${entry.auth.mode})`); } + catch (e) { log.error(` ✖ 도메인 ${domain} 을 열지 못했습니다: ${e.message}`); } + } + return api; +} + +/** + * "메일 서비스가 없습니다" 안내 화면(요구사항 1). 모르는 호스트로 들어온 웹 요청과 꺼 둔 도메인의 요청에 줍니다. 스크립트 없이 HTML 만. + * @param {object} o host: 요청 호스트, disabled: 꺼 둔 도메인인지, strings: lang/ko.json 묶음(없으면 내장 문구), version + */ +export function noServicePage({ host = '', disabled = false, strings = {}, version = '' } = {}) { + const t = (k, d) => strings[k] || d; + const title = disabled ? t('domainDisabledTitle', '메일 서비스가 활성화되지 않았습니다') : t('domainMissingTitle', '메일 서비스가 존재하지 않습니다'); + const body = disabled ? t('domainDisabled', '이 도메인의 메일 서비스는 지금 사용이 중지되어 있습니다.') : t('domainMissing', '이 주소에는 등록된 메일 서비스가 없습니다.'); + const ask = t('domainAskAdmin', '서비스가 필요하면 관리자에게 이 도메인의 메일 서비스 등록(또는 활성화)을 요청하세요.'); + const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + return `${esc(title)}

${disabled ? '⏸' : '✉'} ${esc(title)}

${esc(body)}

${host ? `

요청한 주소: ${esc(host)}

` : ''}

${esc(ask)}

AriMail${version ? ' v' + esc(version) : ''}

`; +} diff --git a/modules/arimail/server/files.mjs b/modules/arimail/server/files.mjs new file mode 100644 index 0000000..7c701a7 --- /dev/null +++ b/modules/arimail/server/files.mjs @@ -0,0 +1,242 @@ +/** + * 대용량 첨부파일(0.9.3~): 큰 파일을 메일에 첨부하지 않고 서버에 보관한 뒤 다운로드 링크를 보냅니다. + * + * 흐름 + * 1) 작성 창에서 "대용량 첨부" 로 올리면 파일은 디스크(filesDir, 기본 /files/)에 흘려 쓰고 large_files 표에 메타데이터만 남깁니다(state='draft'). + * 업로드는 index.mjs 가 요청 본문을 모으지 않고 그대로 넘겨(스트리밍) 1GB 급 파일도 메모리를 쓰지 않습니다. + * 2) 보낼 때(store.sendDraft → finalizeLargeFiles) 보관 기간(일)과 다운로드 횟수를 **반드시** 정합니다. 둘 다 관리자 설정의 상한 이내여야 하며 + * "제한 없음" 은 없습니다(요구사항: 기간 제한이나 횟수 제한 없는 발송 불가). 파일마다 비밀 토큰을 만들어 링크(https://<호스트>/dl/<토큰>)를 본문 끝에 넣습니다. + * 3) 받는 사람이 링크를 열면 랜딩 페이지(파일 이름·크기·남은 횟수·기한)가 보이고, "다운로드" 단추(POST)를 누를 때만 횟수가 1 줄며 파일을 흘려보냅니다. + * GET 으로 세지 않는 이유: 메일 보안 장치(Outlook Safe Links 등)와 미리 보기 봇이 링크를 미리 열어 보므로, 그때마다 횟수가 줄면 받는 사람이 정작 못 받습니다. + * 4) 기한이 지나거나 횟수를 다 쓰면(또는 보낸 사람이 링크를 끊으면) 파일을 지우고 state='expired' 로 둡니다. 정리 작업(cleanupLargeFiles)이 1시간마다 돌며, + * 횟수를 다 쓴 파일은 마지막 다운로드가 끝나는 즉시 지웁니다. + * + * 설정은 두 층입니다(limits.mjs 와 같은 방식, 단 어느 층도 "무제한" 이 없음). + * - 시스템(systemLargeFiles): 환경 변수 LIMIT_LARGE_FILE_BYTES(기본 1G) · LIMIT_LARGE_FILE_DAYS(기본 30) · LIMIT_LARGE_FILE_DOWNLOADS(기본 20). + * 0 이나 잘못된 값은 기본값으로 대체합니다(반드시 값이 있어야 함). + * - 관리자(readLargeFileSettings, settings 표의 large_files): 사용 여부(enabled), 최대 크기·보관 기간·다운로드 횟수. 각각 시스템 값 이내이며 + * 기본값은 시스템 값의 50% 입니다(요구사항). 기본은 사용 안 함(디스크를 쓰는 기능이라 관리자가 켜야 합니다). + */ +import { createWriteStream, createReadStream } from 'node:fs'; +import { mkdir, unlink, stat } from 'node:fs/promises'; +import { once } from 'node:events'; +import { dirname, join } from 'node:path'; +import { now, uid, clean, secret, escapeHtml } from './util.mjs'; +import { parseSize } from './limits.mjs'; + +// ------------------------------------------------------------------ 설정 +/** 시스템 기본값. 환경 변수가 없거나 잘못됐을 때 쓰며, "무제한" 은 없습니다. */ +export const DEFAULT_SYSTEM_LARGE = { bytes: 1024 ** 3, days: 30, downloads: 20 }; +/** 한 메일에 넣을 수 있는 대용량 파일 수. */ +export const LARGE_FILE_COUNT = 10; +const posInt = (v, fallback) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : fallback; }; +/** 시스템 설정(환경 파일). 값이 0·비어 있음·잘못됨이면 기본값(1GB / 30일 / 20회)으로 대체해 항상 유한한 값을 돌려줍니다. */ +export function systemLargeFiles(env) { + const bytes = parseSize(env.LIMIT_LARGE_FILE_BYTES); + return { bytes: bytes > 0 ? bytes : DEFAULT_SYSTEM_LARGE.bytes, days: posInt(env.LIMIT_LARGE_FILE_DAYS, DEFAULT_SYSTEM_LARGE.days), downloads: posInt(env.LIMIT_LARGE_FILE_DOWNLOADS, DEFAULT_SYSTEM_LARGE.downloads) }; +} +/** 관리자 설정의 기본값: 시스템 값의 50%(바이트는 내림, 일·횟수는 올림, 최소 1). */ +export function defaultAdminLarge(sys) { return { enabled: false, maxBytes: Math.max(1, Math.floor(sys.bytes / 2)), maxDays: Math.max(1, Math.ceil(sys.days / 2)), maxDownloads: Math.max(1, Math.ceil(sys.downloads / 2)) }; } +const CAP = { maxBytes: 'bytes', maxDays: 'days', maxDownloads: 'downloads' }; +/** + * 관리자 설정(settings 의 large_files). 저장된 값이 손상됐거나 시스템 값을 넘으면(설치 뒤 시스템 값이 줄어든 경우) 시스템 값으로 깎고, 없으면 기본값(50%)입니다. + * @returns {Promise<{enabled:boolean,maxBytes:number,maxDays:number,maxDownloads:number}>} + */ +export async function readLargeFileSettings(env) { + const sys = systemLargeFiles(env), def = defaultAdminLarge(sys); + const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('large_files').first(); + let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; } + if (!stored || typeof stored !== 'object') stored = {}; + const out = { enabled: !!stored.enabled }; + for (const [k, s] of Object.entries(CAP)) { const v = posInt(stored[k], def[k]); out[k] = Math.min(v, sys[s]); } + return out; +} +/** + * 관리자 설정을 저장합니다. 크기·기간·횟수는 1 이상의 정수이고(0 = 무제한은 허용하지 않음 → limit_invalid) 시스템 값 이내여야 합니다(limit_exceeds_system). + * 오류에는 field 와 상한(limit)을 붙여 화면이 어느 항목인지 알려 줍니다. + */ +export async function writeLargeFileSettings(env, patch) { + const sys = systemLargeFiles(env), next = await readLargeFileSettings(env); + if (patch.enabled !== undefined) next.enabled = !!patch.enabled; + for (const [k, s] of Object.entries(CAP)) { + if (patch[k] === undefined) continue; + const n = Number(patch[k]); + if (!Number.isInteger(n) || n < 1) { const e = new Error('limit_invalid'); e.field = k; throw e; } + if (n > sys[s]) { const e = new Error('limit_exceeds_system'); e.field = k; e.limit = sys[s]; throw e; } + next[k] = n; + } + await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('large_files', JSON.stringify(next), now()).run(); + return next; +} +/** 파일을 둘 디렉터리: FILES_DIR 또는 DB 파일 옆의 files/. 배포 서비스는 /data 에 쓰기 권한이 있으므로 기본값이 바로 동작합니다. */ +export function filesDir(env) { return env.FILES_DIR || join(dirname(env.DB_FILE || 'data/mail-gateway.sqlite'), 'files'); } +const pathOf = (env, id) => join(filesDir(env), id); +async function unlinkQuiet(path) { try { await unlink(path); } catch { /* 이미 없음 */ } } + +// ------------------------------------------------------------------ 업로드(초안에 파일 붙이기) +/** + * 초안에 대용량 파일을 올립니다. 본문 스트림(Node 요청 객체 또는 async iterable)을 디스크에 흘려 쓰며 관리자 상한(maxBytes)을 넘는 순간 중단하고 지웁니다. + * @param {object} user 로그인 사용자(mailbox 로 초안 소유를 확인) + * @param {AsyncIterable} stream 파일 바이트 + * @param {object} meta { draftId, filename, contentType, declaredSize? } — declaredSize(Content-Length)가 상한을 넘으면 받기 전에 거부합니다. + * @returns {Promise} large_files 행 + * @throws large_files_disabled | mail_locked | too_many_files | file_too_large(limit) | bad_request + */ +export async function uploadLargeFile(env, user, stream, { draftId, filename, contentType, declaredSize = 0 }) { + const cfg = await readLargeFileSettings(env); + if (!cfg.enabled) throw new Error('large_files_disabled'); + const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts' AND mailbox=?").bind(String(draftId || ''), user.mailbox).first(); + if (!draft) throw new Error('mail_locked'); + const count = await env.DB.prepare("SELECT COUNT(*) AS n FROM large_files WHERE message_id=? AND state='draft'").bind(draft.id).first(); + if (Number(count?.n || 0) >= LARGE_FILE_COUNT) throw new Error('too_many_files'); + const tooBig = () => { const e = new Error('file_too_large'); e.limit = cfg.maxBytes; return e; }; + if (Number(declaredSize) > cfg.maxBytes) throw tooBig(); + try { await mkdir(filesDir(env), { recursive: true }); } catch (e) { const err = new Error('upload_write_failed'); err.detail = e.code; throw err; } + const id = uid(), path = pathOf(env, id), out = createWriteStream(path, { flags: 'wx', mode: 0o600 }); + // 쓰기 오류(디스크 가득 참 ENOSPC 등)는 다음 네트워크 조각을 기다리는 동안에도 날 수 있습니다. 듣는 쪽이 없으면 'error' 이벤트가 프로세스를 죽이므로 + // 처음부터 받아 두었다가 조각마다 확인해 업로드만 실패시킵니다(0.9.5 수정). + // failed: 쓰기 오류가 나면 거부되는 프라미스 하나. 예전(0.9.5~1.0.0)에는 조각마다 out.once('error', reject) 를 새로 달아 drain 을 기다릴 때마다 + // 리스너가 쌓였고(1GB 업로드면 수천 개), 11개째에 MaxListenersExceededWarning 이 기록됐습니다(mail.heart-m.com 2026-10-13). 리스너는 이 한 개만 둡니다(1.0.5 수정). + let writeError = null; const failed = new Promise((_, reject) => out.on('error', (e) => { writeError = e; reject(e); })); failed.catch(() => {}); + let size = 0; + try { + // 파일이 실제로 열린 뒤에 쓰기 시작합니다. 열리기 전에 중단·삭제하면 삭제가 먼저 돌고 그 뒤 스트림이 파일을 만들어 디스크에 찌꺼기가 남습니다. + await Promise.race([once(out, 'open'), failed]); + for await (const chunk of stream) { + if (writeError) throw writeError; + size += chunk.length; + if (size > cfg.maxBytes) throw tooBig(); + if (!out.write(chunk)) await Promise.race([once(out, 'drain'), failed]); + } + if (writeError) throw writeError; + await Promise.race([new Promise((resolve) => out.end(resolve)), failed]); + } catch (e) { if (!out.destroyed) { out.destroy(); await once(out, 'close').catch(() => {}); } await unlinkQuiet(path); if (e && e.code && !e.limit) { const err = new Error('upload_write_failed'); err.detail = e.code; throw err; } throw e; } + if (size === 0) { await unlinkQuiet(path); throw new Error('bad_request'); } + const name = clean(filename, 255).replace(/[\r\n"\\/]/g, '_') || 'file'; + await env.DB.prepare("INSERT INTO large_files(id,message_id,mailbox,filename,content_type,size,state,created_at) VALUES(?,?,?,?,?,?,'draft',?)") + .bind(id, draft.id, user.mailbox, name, clean(contentType, 120).split(';')[0] || 'application/octet-stream', size, now()).run(); + return (await env.DB.prepare('SELECT * FROM large_files WHERE id=?').bind(id).first()); +} +/** 메일에 딸린 대용량 파일 목록(화면용: 바이트는 빼고 상태·남은 횟수·기한). */ +export async function listLargeFiles(env, messageId) { + const rows = (await env.DB.prepare('SELECT id,filename,content_type,size,state,max_downloads,downloads,expires_at,created_at,sent_at,last_download_at FROM large_files WHERE message_id=? ORDER BY created_at').bind(messageId).all()).results; + return rows.map((r) => ({ ...r, remaining: r.max_downloads == null ? null : Math.max(0, r.max_downloads - r.downloads), expired: r.state === 'expired' || (r.state === 'sent' && isExpired(r)) })); +} +const isExpired = (r) => (r.expires_at && Date.parse(r.expires_at) <= Date.now()) || (r.max_downloads != null && r.downloads >= r.max_downloads); +/** + * 파일을 뗍니다. 초안의 파일은 행과 파일을 지우고, 보낸 메일의 파일은 링크를 끊습니다(파일 삭제 + state='expired', 행은 기록으로 남김). + * 소유 확인은 메일함(mailbox)으로 합니다. 없으면 not_found. + */ +export async function removeLargeFile(env, id, mailbox, messageId = null) { + const row = await env.DB.prepare('SELECT * FROM large_files WHERE id=? AND mailbox=?').bind(id, mailbox).first(); + if (!row || (messageId && row.message_id && row.message_id !== messageId)) throw new Error('not_found'); // 다른 메일의 파일 id 를 끼워 넣어도 그 메일 경로로는 지울 수 없음(0.9.5) + await unlinkQuiet(pathOf(env, id)); + if (row.state === 'draft') await env.DB.prepare('DELETE FROM large_files WHERE id=?').bind(id).run(); + else await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(id).run(); + return row; +} + +// ------------------------------------------------------------------ 발송(링크 만들기) +/** + * 발송 직전에 초안의 대용량 파일에 기한·횟수·토큰을 붙입니다(store.sendDraft 가 부름). + * @param {object} terms { days, downloads } — 둘 다 필수, 1 이상이고 관리자 상한 이내. 어긋나면 large_file_terms(422, field·limit 동봉). + * @param {string} host 링크에 쓸 공개 호스트(PUBLIC_HOST) + * @returns {Promise<{files: object[], html: string, text: string}>} 본문에 덧붙일 HTML/텍스트 블록. 파일이 없으면 files 가 비고 html/text 는 ''. + */ +export async function finalizeLargeFiles(env, draftId, terms, host, strings = {}) { + // 발송에 실패했던 초안을 다시 보낼 때는 지난 시도에서 이미 링크를 만든 파일(state='sent')을 그대로 씁니다(0.9.5). 예전에는 실패 시 링크를 되돌리고 재시도 때 새 토큰을 만들어, + // 같은 멱등 키(send_key)로 다른 본문을 보내게 되어 발송 서비스가 거부했고, 첫 시도가 실제로는 전달됐다면 받는 사람의 링크가 죽었습니다. 같은 토큰이면 재시도 본문이 똑같습니다. + const files = (await env.DB.prepare("SELECT * FROM large_files WHERE message_id=? AND state IN ('draft','sent') ORDER BY created_at").bind(draftId).all()).results; + if (!files.length) return { files: [], html: '', text: '' }; + const pending = files.filter((f) => f.state === 'draft'), kept = files.filter((f) => f.state === 'sent'); + const cfg = await readLargeFileSettings(env); + const check = (v, max, field) => { const n = Number(v); if (!Number.isInteger(n) || n < 1 || n > max) { const e = new Error('large_file_terms'); e.field = field; e.limit = max; throw e; } return n; }; + // 새 파일이 있으면 조건이 필요하고, 모두 지난 시도의 파일이면 그때의 조건을 그대로 씁니다. + const days = pending.length ? check(terms?.days, cfg.maxDays, 'days') : Math.max(1, Math.round((Date.parse(kept[0].expires_at) - Date.parse(kept[0].sent_at)) / 86400000)); + const downloads = pending.length ? check(terms?.downloads, cfg.maxDownloads, 'downloads') : kept[0].max_downloads; + const stamp = now(), expiresAt = pending.length ? new Date(Date.now() + days * 86400000).toISOString() : kept[0].expires_at; + const out = kept.map((f) => ({ ...f, url: `https://${host}/dl/${f.token}` })); + for (const f of pending) { + const token = secret(24); + await env.DB.prepare("UPDATE large_files SET token=?,state='sent',max_downloads=?,expires_at=?,sent_at=? WHERE id=?").bind(token, downloads, expiresAt, stamp, f.id).run(); + out.push({ ...f, token, max_downloads: downloads, expires_at: expiresAt, url: `https://${host}/dl/${token}` }); + } + return { files: out, ...bodyBlock(out, { days, downloads, expiresAt }, env, strings) }; +} +/** 발송 조건만 미리 검사합니다(발송 시작 전에 불러 어긋나면 초안이 '발송 실패' 로 잠기지 않게). 파일이 없으면 아무것도 하지 않습니다. */ +export async function checkLargeFileTerms(env, draftId, terms) { + const n = await env.DB.prepare("SELECT SUM(CASE WHEN state='draft' THEN 1 ELSE 0 END) AS pending, COUNT(*) AS total FROM large_files WHERE message_id=? AND state IN ('draft','sent')").bind(draftId).first(); + if (!Number(n?.total || 0)) return false; + if (!Number(n?.pending || 0)) return true; // 지난 시도에서 링크를 만든 파일뿐이면(재시도) 조건을 다시 묻지 않습니다. + const cfg = await readLargeFileSettings(env); + for (const [field, max] of [['days', cfg.maxDays], ['downloads', cfg.maxDownloads]]) { const v = Number(terms?.[field]); if (!Number.isInteger(v) || v < 1 || v > max) { const e = new Error('large_file_terms'); e.field = field; e.limit = max; throw e; } } + return true; +} +/** 바이트 수를 사람이 읽는 크기로(1.2 GB / 34.5 MB / 120 KB). */ +export const humanSize = (n) => n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB'; +/** 기한을 통계 시간대(기본 Asia/Seoul)의 "YYYY-MM-DD HH:MM" 로. 메일 본문·랜딩 페이지에 씁니다. */ +export function formatExpiry(iso, env = {}) { + const tz = String(env.STATS_TIMEZONE || 'Asia/Seoul'); + try { const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(iso)).map((x) => [x.type, x.value])); return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`; } + catch { return String(iso).slice(0, 16).replace('T', ' '); } +} +/** 본문 끝에 덧붙이는 "대용량 첨부파일" 블록(HTML + 텍스트). 링크·파일 이름·크기·기한·횟수를 담고, 받는 사람이 만료 규칙을 알 수 있게 안내문을 붙입니다. */ +function bodyBlock(files, { downloads, expiresAt }, env, s) { + const t = (k, d) => s[k] || d, until = formatExpiry(expiresAt, env); + const note = t('largeFileNote', '링크는 기한이 지나거나 다운로드 횟수를 다 쓰면 만료되어 파일을 받을 수 없습니다.'); + const terms = t('largeFileTerms', '{until} 까지 · {downloads}회').replace('{until}', until).replace('{downloads}', String(downloads)); + const html = `

${escapeHtml(t('largeFileHeading', '대용량 첨부파일'))}

${escapeHtml(note)}

`; + const text = `\n\n[${t('largeFileHeading', '대용량 첨부파일')}]\n${files.map((f) => `- ${f.filename} (${humanSize(f.size)}): ${f.url} (${terms})`).join('\n')}\n${note}\n`; + return { html, text }; +} + +// ------------------------------------------------------------------ 다운로드(공개 링크) +/** 토큰으로 파일 정보를 찾습니다(랜딩 페이지용, 횟수는 세지 않음). 없거나 만료면 { status: 'missing'|'expired' }. */ +export async function largeFileInfo(env, token) { + const row = await env.DB.prepare('SELECT * FROM large_files WHERE token=?').bind(String(token || '')).first(); + if (!row || row.state === 'draft') return { status: 'missing' }; + if (row.state === 'expired' || isExpired(row)) return { status: 'expired', file: row }; + return { status: 'ok', file: row, remaining: row.max_downloads - row.downloads }; +} +/** + * 다운로드 한 번을 차지합니다: 살아 있고 횟수가 남은 파일에만 downloads 를 1 올리고 그 행을 돌려줍니다(한 문장의 UPDATE 라 동시에 눌러도 횟수를 넘지 않음). + * @returns {Promise<{row:object, path:string, last:boolean}|null>} last 는 이번이 마지막 허용 횟수인지(끝나면 파일을 지움) + */ +export async function claimDownload(env, token) { + const row = await env.DB.prepare("UPDATE large_files SET downloads=downloads+1,last_download_at=? WHERE token=? AND state='sent' AND downloads? RETURNING *").bind(now(), String(token || ''), now()).first(); + if (!row) return null; + try { await stat(pathOf(env, row.id)); } catch { await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(row.id).run(); return null; } + return { row, path: pathOf(env, row.id), last: row.downloads >= row.max_downloads }; +} +/** 다운로드 스트림(index.mjs 가 응답에 파이프). */ +export const openLargeFile = (path) => createReadStream(path); +/** 마지막 허용 횟수의 다운로드가 끝난 뒤: 파일을 지우고 만료 처리합니다. */ +export async function finishDownload(env, claim) { + if (!claim?.last) return; + await unlinkQuiet(claim.path); + await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(claim.row.id).run(); +} + +// ------------------------------------------------------------------ 정리·통계 +/** 보낸 사람이 지운 초안(메일 행이 사라져 message_id 가 NULL)의 파일이 남아 있을 수 있는 유예 시간. 그 뒤 정리합니다. */ +const ORPHAN_GRACE_MS = 24 * 3600 * 1000; +/** + * 주기 정리(1시간마다): 기한이 지났거나 횟수를 다 쓴 보낸 파일, 메일이 사라진 초안 파일, 하루 넘게 보내지 않은 초안의 파일 중 초안이 휴지통에 있는 것을 지웁니다. + * 작성 중인 초안(임시 보관함)의 파일은 그대로 둡니다. + * @returns {Promise<{expired:number, removed:number}>} + */ +export async function cleanupLargeFiles(env) { + const stamp = now(); let expired = 0, removed = 0; + const dead = (await env.DB.prepare("SELECT id FROM large_files WHERE state='sent' AND (expires_at<=? OR downloads>=max_downloads)").bind(stamp).all()).results; + for (const r of dead) { await unlinkQuiet(pathOf(env, r.id)); await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(r.id).run(); expired++; } + const cutoff = new Date(Date.now() - ORPHAN_GRACE_MS).toISOString(); + // 유예는 초안을 버린 시점(휴지통으로 옮긴 updated_at, 메일이 지워졌으면 파일 올린 시점) 기준입니다. 예전에는 올린 시점 기준이라 하루 넘게 쓰던 초안을 버리면 한 시간 안에 파일이 사라졌습니다(0.9.5). + const orphans = (await env.DB.prepare(`SELECT f.id FROM large_files f LEFT JOIN messages m ON m.id=f.message_id WHERE f.state='draft' AND ((m.id IS NULL AND f.created_at Buffer.from(b).toString('latin1'); + +/** + * 인코딩되지 않은 8비트 헤더(RFC 6532 식 UTF-8 제목·이름)를 복원합니다(0.9.0). 원본을 latin1 로 읽으므로 UTF-8 바이트가 글자마다 한 글자로 + * 풀려 "íê¸ ì 목" 처럼 저장됐습니다. 0x80 이상 글자가 있고 UTF-8 로 되돌렸을 때 깨진 글자(U+FFFD)가 없으면 그 결과를 씁니다. + */ +export function fixUtf8(s) { + const str = String(s ?? ''); if (!/[\u0080-\u00ff]/.test(str) || /[\u0100-\uffff]/.test(str)) return str; + const decoded = Buffer.from(str, 'latin1').toString('utf8'); return decoded.includes('\ufffd') ? str : decoded; +} +export function decodeWords(value) { + return fixUtf8(String(value ?? '')) + .replace(/(=\?[^?]+\?[bBqQ]\?[^?]*\?=)\s+(?==\?)/g, '$1') + .replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (m, charset, enc, text) => { + try { + const bytes = enc.toLowerCase() === 'b' ? Buffer.from(text, 'base64') : Buffer.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); + return new TextDecoder(charset.replace(/\*.*$/, '')).decode(bytes); + } catch { return m; } + }); +} +export function decodeBody(bytes, encoding) { + let raw = Buffer.from(bytes); + const enc = String(encoding || '').toLowerCase(); + if (enc === 'base64') raw = Buffer.from(latin1(raw).replace(/[^A-Za-z0-9+/=]/g, ''), 'base64'); + else if (enc === 'quoted-printable') raw = Buffer.from(latin1(raw).replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); + return raw; +} +export function decodeText(bytes, encoding, charset) { + try { return new TextDecoder(charset || 'utf-8').decode(decodeBody(bytes, encoding)); } catch { return decodeBody(bytes, encoding).toString('utf8'); } +} +/** 헤더 블록을 [name, value] 목록으로. 접힌 줄은 펼칩니다. */ +export function parseHeaders(text) { + const out = []; + for (const line of String(text).replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) { + const i = line.indexOf(':'); if (i <= 0) continue; + out.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]); + } + return out; +} +export const header = (headers, name) => headers.find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1] ?? null; +export function parseContentType(value) { + const [type, ...rest] = String(value || 'text/plain').split(';'); + const [main = 'text', sub = 'plain'] = type.trim().toLowerCase().split('/'); + return { type: main, subtype: sub || 'plain', params: parseParams(rest.join(';')) }; +} +/** + * 헤더 파라미터(name="값"; filename*=UTF-8''%ED%95%9C.pdf …)를 객체로 바꿉니다. RFC 2231 을 지원합니다. + * - name*=charset'언어'%XX… : 퍼센트 인코딩을 풀고 지정한 문자 집합으로 해독합니다. + * - name*0=…; name*1*=… : 이어 쓰기 조각을 번호 순으로 합칩니다(별표가 붙은 조각만 퍼센트 인코딩). + * 예전에는 퍼센트 인코딩을 풀지 않아 한글 첨부 이름이 "%ED%95%9C.pdf" 로 보였고, 이어 쓰기 조각은 합치지 않았습니다. + */ +export function parseParams(text) { + const raw = {}; const re = /([A-Za-z0-9*_.-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/g; let m; + while ((m = re.exec(String(text || '')))) raw[m[1].toLowerCase()] = m[2] !== undefined ? m[2].replace(/\\(.)/g, '$1') : m[3]; + const groups = {}; // 이름 → [{ index, extended, value }] + for (const [key, value] of Object.entries(raw)) { + const k = key.match(/^(.+?)(?:\*(\d+))?(\*)?$/); + (groups[k[1]] ||= []).push({ index: k[2] === undefined ? -1 : Number(k[2]), extended: !!k[3], value }); + } + const params = {}; + for (const [name, pieces] of Object.entries(groups)) { + // 이어 쓰기 조각(*0, *1 …)이 있으면 그것만 순서대로, 없으면 하나뿐인 값을 씁니다. + const parts = pieces.some((p) => p.index >= 0) ? pieces.filter((p) => p.index >= 0).sort((a, b) => a.index - b.index) : pieces.slice(0, 1); + if (!parts.some((p) => p.extended)) { params[name] = parts.map((p) => p.value).join(''); continue; } + // 첫 확장 조각은 charset'language'값 형식입니다. + let charset = 'utf-8'; const bytes = []; + parts.forEach((p, i) => { + let v = p.value; + if (p.extended && i === 0) { const q = v.match(/^([^']*)'[^']*'(.*)$/); if (q) { charset = q[1] || 'utf-8'; v = q[2]; } } + bytes.push(p.extended ? Buffer.from(v.replace(/%([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1') : Buffer.from(v, 'utf8')); + }); + try { params[name] = new TextDecoder(charset).decode(Buffer.concat(bytes)); } catch { params[name] = Buffer.concat(bytes).toString('utf8'); } + } + return params; +} +/** + * 주소 헤더(From/To/Cc …)를 {name, mailbox, host} 목록으로 나눕니다. + * 순서가 중요합니다: 먼저 "부호화된 그대로" 쉼표(따옴표 밖)로 나누고 그 다음에 조각마다 encoded-word 를 풉니다. + * 예전에는 먼저 풀고 나눠서, 이름에 쉼표가 든 "김, 민수" 가 두 주소로 갈라졌습니다. 그룹 구문(name: a, b;)의 이름 제거도 + * 조각 단위로, 따옴표 밖에 있고 뒤에 숫자가 오지 않을 때만 합니다("Meeting 10:30", "Re: invoice" 같은 이름이 잘리던 문제, 0.8.16 수정). + */ +export function parseAddresses(value) { + const out = []; let raw = String(value || ''); + if (!raw.trim()) return out; + // 그룹 구문은 "이름: 구성원, 구성원;" 처럼 세미콜론으로 끝납니다. 끝나는 세미콜론이 있을 때만 이름을 버리고 구성원만 남깁니다. + // 세미콜론 없이 콜론만 있는 "Re: invoice " 같은 (규격에는 어긋나지만 흔한) 이름은 그대로 둡니다. + raw = raw.replace(/(^|,)(\s*)[^"<>@:;,]+:\s*([^;:]*);/g, '$1$2$3'); + for (const part of raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)) { + const p = decodeWords(part).trim(); if (!p) continue; + const m = p.match(/^(.*?)\s*<([^<>]*)>\s*$/); + let name = '', addr = p; + if (m) { name = m[1].trim().replace(/^"(.*)"$/, '$1').replace(/\\(.)/g, '$1'); addr = m[2].trim(); } + const at = addr.lastIndexOf('@'); + out.push({ name: name || null, mailbox: at > 0 ? addr.slice(0, at) : addr, host: at > 0 ? addr.slice(at + 1) : null }); + } + return out; +} +/** + * 원본 바이트를 파트 트리로. 각 파트: {headers, type, subtype, params, encoding, id, description, disposition, dispositionParams, + * start, headerEnd, bodyStart, end, parts(children), size, lines} + */ +export function parseMime(eml) { + const buf = Buffer.from(eml); const s = latin1(buf); + function parsePart(start, end) { + let headerEnd, bodyStart; + // 헤더가 없는 파트(RFC 2046 5.1: 경계 바로 뒤에 빈 줄 → text/plain us-ascii)는 CRLF 로 시작합니다. 예전에는 CRLFCRLF 를 찾다 + // 본문을 통째로 잃거나(크기 0) 본문의 첫 단락을 헤더로 잘못 읽었습니다(0.8.25 수정). + if (s.startsWith('\r\n', start)) { headerEnd = start; bodyStart = Math.min(start + 2, end); } + else { const sep = s.indexOf('\r\n\r\n', start); if (sep < 0 || sep >= end) { headerEnd = end; bodyStart = end; } else { headerEnd = sep + 2; bodyStart = sep + 4; } } + const headers = parseHeaders(s.slice(start, headerEnd)); + const ct = parseContentType(header(headers, 'content-type')); + const cd = parseContentType(header(headers, 'content-disposition') || ''); + const body = s.slice(bodyStart, end); + const part = { + headers, type: ct.type, subtype: ct.subtype, params: ct.params, + encoding: (header(headers, 'content-transfer-encoding') || '7bit').trim().toLowerCase(), + id: header(headers, 'content-id'), description: header(headers, 'content-description'), + disposition: header(headers, 'content-disposition') ? cd.type : null, dispositionParams: cd.params, + start, headerEnd, bodyStart, end, parts: [], + size: end - bodyStart, lines: body ? body.split('\n').length - (body.endsWith('\n') ? 1 : 0) : 0, + }; + if (ct.type === 'multipart' && ct.params.boundary) { + // 경계 줄은 "--경계" 바로 뒤가 "--"(끝), 공백 또는 줄바꿈이어야 합니다. 예전에는 접두사만 비교해서 바깥 경계 "b1" 이 + // 안쪽 경계 "--b1x" 줄과도 맞아 파트가 잘못 나뉘었습니다(BODYSTRUCTURE·섹션 번호 오류). + const b = '--' + ct.params.boundary; + const isBoundary = (at) => { const c = s.charAt(at + b.length); return c === '' || c === '-' || c === '\r' || c === '\n' || c === ' ' || c === '\t'; }; + const find = (from, prefix) => { let at = s.indexOf(prefix + b, from); while (at >= 0 && !isBoundary(at + prefix.length)) at = s.indexOf(prefix + b, at + 1); return at; }; + let pos = s.startsWith(b, bodyStart) && isBoundary(bodyStart) ? bodyStart : find(bodyStart, '\n'); if (pos >= 0 && s[pos] === '\n') pos += 1; + while (pos >= 0 && pos < end) { + if (s.startsWith(b + '--', pos)) break; + const lineEnd = s.indexOf('\n', pos); if (lineEnd < 0) break; + const childStart = lineEnd + 1; + let next = find(childStart, '\r\n'); if (next < 0 || next > end) next = -1; + part.parts.push(parsePart(childStart, next < 0 ? end : next)); + if (next < 0) break; pos = next + 2; + } + } else if (ct.type === 'message' && ct.subtype === 'rfc822') { + part.parts.push(parsePart(bodyStart, end)); + } + return part; + } + return { buffer: buf, text: s, root: parsePart(0, s.length) }; +} +// ------------------------------------------------------------------ IMAP 문자열 표현 +export const nstring = (v) => v === null || v === undefined ? 'NIL' : imapString(String(v)); +export function imapString(v) { + const s = String(v); + // 헤더 값은 원본 바이트를 latin1 로 읽은 "바이트 문자열"(문자 하나 = 바이트 하나)입니다. 리터럴 길이는 그 바이트 수이고, + // 세션(imap.mjs send)은 이런 문자열을 latin1 로 써서 바이트를 그대로 내보냅니다. 예전에는 길이를 UTF-8 로 다시 재고 UTF-8 로 써서 + // 한글 제목이 두 번 인코딩돼 깨졌습니다. 진짜 유니코드(0xFF 초과 문자)가 들어오면 UTF-8 바이트 문자열로 바꿔 같은 규칙을 적용합니다. + if (/[\r\n]/.test(s) || /[^\x01-\x7f]/.test(s)) { const raw = /[^\u0000-\u00ff]/.test(s) ? Buffer.from(s, 'utf8').toString('latin1') : s; return `{${raw.length}}${CRLF}${raw}`; } + return '"' + s.replace(/[\\"]/g, (c) => '\\' + c) + '"'; +} +const addressList = (list) => list.length ? '(' + list.map((a) => `(${nstring(a.name)} NIL ${nstring(a.mailbox)} ${nstring(a.host)})`).join('') + ')' : 'NIL'; +export function envelope(headers) { + const from = parseAddresses(header(headers, 'from')), sender = parseAddresses(header(headers, 'sender')), replyTo = parseAddresses(header(headers, 'reply-to')); + return '(' + [ + nstring(header(headers, 'date')), nstring(header(headers, 'subject')), + addressList(from), addressList(sender.length ? sender : from), addressList(replyTo.length ? replyTo : from), + addressList(parseAddresses(header(headers, 'to'))), addressList(parseAddresses(header(headers, 'cc'))), addressList(parseAddresses(header(headers, 'bcc'))), + nstring(header(headers, 'in-reply-to')), nstring(header(headers, 'message-id')), + ].join(' ') + ')'; +} +const paramList = (params) => { const e = Object.entries(params || {}); return e.length ? '(' + e.map(([k, v]) => `${imapString(k.toUpperCase())} ${imapString(v)}`).join(' ') + ')' : 'NIL'; }; +const dispositionField = (part) => part.disposition ? `(${imapString(part.disposition.toUpperCase())} ${paramList(part.dispositionParams)})` : 'NIL'; +export function bodyStructure(part, extended = true) { + if (part.type === 'multipart') { + const children = part.parts.map((p) => bodyStructure(p, extended)).join(''); + return `(${children || '("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 0 0)'} ${imapString(part.subtype.toUpperCase())}${extended ? ` ${paramList(part.params)} ${dispositionField(part)} NIL NIL` : ''})`; + } + const basic = [imapString(part.type.toUpperCase()), imapString(part.subtype.toUpperCase()), paramList(part.params), nstring(part.id), nstring(part.description), imapString(part.encoding.toUpperCase()), String(part.size)]; + if (part.type === 'text') basic.push(String(part.lines)); + else if (part.type === 'message' && part.subtype === 'rfc822' && part.parts[0]) basic.push(envelope(part.parts[0].headers), bodyStructure(part.parts[0], extended), String(part.lines)); + if (extended) basic.push('NIL', dispositionField(part), 'NIL', 'NIL'); + return '(' + basic.join(' ') + ')'; +} +/** 섹션 지정자(BODY[...] 의 대괄호 안)를 바이트로 돌려줍니다. */ +export function section(parsed, spec) { + const { text, root } = parsed; const s = String(spec || '').trim(); + const slice = (a, b) => Buffer.from(text.slice(a, b), 'latin1'); + if (s === '') return parsed.buffer; + const m = s.match(/^((?:\d+\.)*\d+)(?:\.(MIME|HEADER|TEXT|HEADER\.FIELDS(?:\.NOT)?\s*\([^)]*\)))?$/i) || s.match(/^()(HEADER|TEXT|HEADER\.FIELDS(?:\.NOT)?\s*\([^)]*\))$/i); + if (!m) return null; + let part = root; + if (m[1]) { + for (const n of m[1].split('.').map(Number)) { + if (part.type === 'multipart') part = part.parts[n - 1]; + else if (part.type === 'message' && part.subtype === 'rfc822') part = part.parts[0] && (part.parts[0].type === 'multipart' ? part.parts[0].parts[n - 1] : n === 1 ? part.parts[0] : null); + else part = n === 1 ? part : null; + if (!part) return null; + } + } + const what = (m[2] || '').toUpperCase(); + if (!what) return m[1] ? slice(part.bodyStart, part.end) : parsed.buffer; + const target = m[1] && what !== 'MIME' && part.type === 'message' && part.parts[0] ? part.parts[0] : part; + if (what === 'MIME') return slice(part.start, part.bodyStart); + if (what === 'HEADER') return slice(target.start, target.bodyStart); + if (what === 'TEXT') return slice(target.bodyStart, target.end); + const fm = what.match(/^HEADER\.FIELDS(\.NOT)?\s*\(([^)]*)\)$/); if (!fm) return null; + const wanted = new Set(fm[2].split(/\s+/).filter(Boolean).map((x) => x.replace(/^"|"$/g, '').toLowerCase())), not = !!fm[1]; + const lines = text.slice(target.start, target.headerEnd).split(/\r\n(?![ \t])/).filter(Boolean); + const picked = lines.filter((line) => { const name = line.slice(0, line.indexOf(':')).trim().toLowerCase(); return wanted.has(name) !== not; }); + return Buffer.from(picked.join(CRLF) + (picked.length ? CRLF : '') + CRLF, 'latin1'); +} +/** 웹 저장용 요약: 제목·발신·수신·본문(text/html)·첨부. */ +export function summarize(parsed) { + const h = parsed.root.headers; const addr = (a) => a.host ? `${a.mailbox}@${a.host}` : a.mailbox; + const out = { subject: decodeWords(header(h, 'subject') || ''), from: decodeWords(header(h, 'from') || ''), to: parseAddresses(header(h, 'to')).map(addr), cc: parseAddresses(header(h, 'cc')).map(addr), bcc: parseAddresses(header(h, 'bcc')).map(addr), replyTo: parseAddresses(header(h, 'reply-to')).map(addr), messageId: header(h, 'message-id'), date: header(h, 'date'), text: '', html: '', attachments: [], headers: Object.fromEntries(h) }; + const walk = (p) => { + if (p.type === 'multipart') { p.parts.forEach(walk); return; } + const body = Buffer.from(parsed.text.slice(p.bodyStart, p.end), 'latin1'); + // 텍스트가 아닌 파트는 모두 첨부입니다. message/rfc822(메일을 "첨부로 전달")도 첨부로 둡니다. + // 예전에는 message 형식을 본문도 첨부도 아닌 것으로 건너뛰어, 첨부로 전달한 메일이 웹 화면과 SMTP 발송에서 사라졌습니다. + // 파일 이름이 붙은 텍스트(notes.txt, text/csv, Apple Mail 의 inline; filename=…)도 첨부입니다. 예전에는 본문도 첨부도 아닌 것으로 사라졌습니다. + const named = !!(p.dispositionParams.filename || p.params.name); + const isAttachment = p.disposition === 'attachment' || p.type !== 'text' || named || !['plain', 'html'].includes(p.subtype); + if (isAttachment) { out.attachments.push({ filename: decodeWords(p.dispositionParams.filename || p.params.name || (p.type === 'message' ? 'message.eml' : 'attachment')), contentType: `${p.type}/${p.subtype}`, contentId: p.id ? p.id.replace(/^<|>$/g, '') : null, disposition: p.disposition === 'inline' && p.id ? 'inline' : p.disposition === 'inline' ? 'attachment' : p.disposition || 'attachment', bytes: decodeBody(body, p.encoding) }); return; } + if (p.type === 'text' && p.subtype === 'html' && !out.html) out.html = decodeText(body, p.encoding, p.params.charset); + else if (p.type === 'text' && !out.text) out.text = decodeText(body, p.encoding, p.params.charset); + }; + walk(parsed.root); + return out; +} diff --git a/modules/arimail/server/imap.mjs b/modules/arimail/server/imap.mjs new file mode 100644 index 0000000..0c308f5 --- /dev/null +++ b/modules/arimail/server/imap.mjs @@ -0,0 +1,476 @@ +/** + * IMAP4rev1 서버 (RFC 3501) + LITERAL+, IDLE, UIDPLUS, MOVE, SPECIAL-USE, NAMESPACE, ID, UNSELECT, ENABLE. + * + * - 계정: www.arihakdang.com 관리자 계정(login_id 또는 email + 비밀번호). 별도 비밀번호 없음. + * - 메일함: INBOX(받은), Sent(보낸), Drafts(초안), Trash(휴지통) — 웹 메일함의 folder 와 같은 저장소. + * - \Seen 은 read_at, \Draft 는 초안 상태, 나머지 플래그는 imap_flags 에 저장. UID 는 imap_uids(전역 단조 증가). + * - EXPUNGE 는 휴지통이 아니면 휴지통으로 이동, 휴지통에서는 영구 삭제(재수신 차단). MOVE/COPY 도 같은 저장소를 씁니다. + * - 평문 포트(143): 993 은 즉시 TLS 이고 143 은 STARTTLS 뒤 로그인이 원칙이지만, 관리자 설정 "평문 로그인 허용"(services.plainLogin, 기본 켬)이 + * 켜져 있으면 TLS 없이도 LOGIN/AUTHENTICATE 를 받습니다(0.9.7, POP3 110 과 같은 스위치). 꺼져 있으면 LOGINDISABLED 를 광고하고 거절합니다. + * 접속마다 설정을 한 번 읽어(plainOk) 인사말의 CAPABILITY 와 로그인 허용에 같은 값을 씁니다. 그래서 인사말은 설정을 읽은 뒤에 보냅니다. + */ +import net from 'node:net'; +import tls from 'node:tls'; +import { verifyAdmin, serviceAllowed } from './auth.mjs'; +import { serviceEnabled, plainLoginAllowed } from './limits.mjs'; +import { normalizeCrlf } from './mime.mjs'; +import { buildStoredEml, appendMessage, copyMessage } from './store.mjs'; +import { parseMime, envelope, bodyStructure, section, header, decodeWords, decodeText, parseAddresses, summarize } from './imap-mime.mjs'; +import { now, memoryRateStore } from './util.mjs'; + +const CRLF = '\r\n'; +const rate = memoryRateStore(); +const MAILBOXES = [['INBOX', 'inbox', []], ['Sent', 'sent', ['\\Sent']], ['Drafts', 'drafts', ['\\Drafts']], ['Trash', 'trash', ['\\Trash']]]; +const FOLDER_NAME = Object.fromEntries(MAILBOXES.map(([n, f]) => [f, n])); +const PERSISTED = new Set(['\\Flagged', '\\Answered', '\\Deleted']); +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +export function folderOf(name) { const n = String(name ?? '').replace(/^"|"$/g, ''); if (/^inbox$/i.test(n)) return 'inbox'; return MAILBOXES.find(([m]) => m === n)?.[1] || null; } +export function internalDate(iso) { const d = new Date(iso || Date.now()); const p = (n) => String(n).padStart(2, '0'); return `${p(d.getUTCDate())}-${MONTHS[d.getUTCMonth()]}-${d.getUTCFullYear()} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} +0000`; } +/** + * APPEND 의 date-time("05-Oct-2026 23:30:00 +0900", 앞에 공백이 붙은 " 7-Oct-2026 …" 포함)을 ms 로. 시각과 시간대까지 반영합니다. + * 예전에는 SEARCH 용 parseImapDate(날짜만)를 써서 시각·시간대가 버려지고, 공백이 붙은 날은 아예 무시돼 보낸 메일함 정렬이 어긋났습니다. + */ +export function parseImapDateTime(s) { + const m = String(s).replace(/^"|"$/g, '').match(/^\s?(\d{1,2})-([A-Za-z]{3})-(\d{4}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/); if (!m) return null; + const mon = MONTHS.findIndex((x) => x.toLowerCase() === m[2].toLowerCase()); if (mon < 0) return null; + const offset = (m[7] === '-' ? -1 : 1) * (Number(m[8]) * 60 + Number(m[9])) * 60000; + return Date.UTC(Number(m[3]), mon, Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6])) - offset; +} +function parseImapDate(s) { const m = String(s).replace(/^"|"$/g, '').match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})/); if (!m) return null; const mon = MONTHS.findIndex((x) => x.toLowerCase() === m[2].toLowerCase()); return mon < 0 ? null : Date.UTC(Number(m[3]), mon, Number(m[1])); } +function wildcard(pattern) { return new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/%/g, '[^/]*') + '$', 'i'); } + +// ------------------------------------------------------------------ 명령 토큰화 +export function tokenize(raw, literals = []) { + const out = []; const stack = [out]; let i = 0; const s = raw; + // 회선에서는 latin1 로 읽으므로 따옴표 문자열·원자는 UTF-8 로 되돌립니다(검색어·메일함 이름의 한글). + const utf8 = (v) => Buffer.from(v, 'latin1').toString('utf8'); + const push = (v) => stack[stack.length - 1].push(typeof v === 'string' ? utf8(v) : v); + while (i < s.length) { + const c = s[i]; + if (c === ' ' || c === '\t') { i++; continue; } + if (c === '(') { const arr = []; push(arr); stack.push(arr); i++; continue; } + if (c === ')') { if (stack.length > 1) stack.pop(); i++; continue; } + if (c === '"') { let j = i + 1, v = ''; while (j < s.length && s[j] !== '"') { if (s[j] === '\\' && j + 1 < s.length) { v += s[j + 1]; j += 2; } else v += s[j++]; } push(v); i = j + 1; continue; } + // NUL 쌍(\0번호\0)은 리터럴 자리표시자입니다. 짝이 없는 NUL(클라이언트가 보낸 날 바이트)은 indexOf 가 -1 을 돌려주고 i 가 0 으로 되돌아가 + // 무한 루프(인증 전 한 줄로 프로세스 전체가 멈춤)가 됐으므로, 짝이 없으면 남은 글자를 버리고 끝냅니다(0.8.24). + if (c === '\0') { const j = s.indexOf('\0', i + 1); if (j < 0) break; const lit = literals[Number(s.slice(i + 1, j))]; stack[stack.length - 1].push(lit ? lit.toString('utf8') : ''); i = j + 1; continue; } + let j = i, depth = 0; + while (j < s.length) { const d = s[j]; if (d === '[') depth++; else if (d === ']') depth--; else if (depth === 0 && (d === ' ' || d === ')' || d === '(')) break; j++; } + push(s.slice(i, j)); i = j; + } + return out; +} +/** 시퀀스/UID 집합 → 스냅샷 인덱스 배열 */ +export function resolveSet(spec, messages, byUid) { + const out = new Set(); if (!messages.length) return []; + const last = byUid ? messages[messages.length - 1].uid : messages.length; + for (const piece of String(spec).split(',')) { + let [a, b] = piece.split(':'); if (b === undefined) b = a; + const lo = a === '*' ? last : Number(a), hi = b === '*' ? last : Number(b); + if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue; + const [from, to] = lo <= hi ? [lo, hi] : [hi, lo]; + messages.forEach((m, idx) => { const key = byUid ? m.uid : idx + 1; if (key >= from && key <= to) out.add(idx); }); + } + return [...out].sort((x, y) => x - y); +} + +class Session { + constructor(server, socket, { secure }) { + this.server = server; this.env = server.env; this.secure = secure; this.state = 'notauth'; this.user = null; + this.buf = Buffer.alloc(0); this.raw = ''; this.literals = []; this.needLiteral = 0; this.closed = false; + this.mailbox = null; this.messages = []; this.cache = new Map(); this.idle = null; this.continuation = null; this.failures = 0; + // plainOk: 이 접속에서 TLS 없이 로그인을 받을지(pop3.mjs 와 같은 규칙). 설정 읽기가 끝난 뒤 인사말을 보내고, 그 뒤에야 명령 큐가 돌기 시작합니다. + this.plainOk = secure || server.allowPlaintext === true; + this.ip = socket.remoteAddress || ''; this.attach(socket); + console.log(`IMAP 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`); + this.queue = this.loadPlainLogin().then(() => this.send(`* OK [CAPABILITY ${this.capabilities()}] ${server.hostname} IMAP4rev1 ready`)); + } + async loadPlainLogin() { + if (this.secure || typeof this.server.allowPlaintext === 'boolean') return; + try { this.plainOk = await plainLoginAllowed(this.env); } catch (e) { console.error('IMAP 평문 로그인 설정 읽기 실패', e?.message); this.plainOk = false; } + } + attach(socket) { this.socket = socket; socket.setTimeout(35 * 60 * 1000); socket.on('data', (c) => this.onData(c)); socket.on('timeout', () => this.close('* BYE timeout')); socket.on('error', () => { this.closed = true; }); socket.on('close', () => { this.closed = true; this.stopIdle(); }); } + // 응답 문자열은 latin1(문자 = 바이트)로 씁니다. ENVELOPE/BODYSTRUCTURE 의 리터럴은 원본 헤더 바이트를 담은 바이트 문자열이라 + // UTF-8 로 쓰면 두 번 인코딩됩니다. 0xFF 를 넘는 문자가 섞인(진짜 유니코드) 줄만 UTF-8 로 씁니다. + send(line) { if (!this.closed) { const text = line + CRLF; this.socket.write(Buffer.from(text, /[^\u0000-\u00ff]/.test(text) ? 'utf8' : 'latin1')); } } + write(buffer) { if (!this.closed) this.socket.write(buffer); } + close(line) { if (line) this.send(line); this.closed = true; this.stopIdle(); try { this.socket.end(); } catch {} } + capabilities() { + const caps = ['IMAP4rev1', 'LITERAL+', 'IDLE', 'UIDPLUS', 'MOVE', 'SPECIAL-USE', 'NAMESPACE', 'ID', 'UNSELECT', 'ENABLE', 'CHILDREN']; + if (this.plainOk) caps.push('AUTH=PLAIN'); else caps.push('LOGINDISABLED'); // 평문 접속에서 로그인을 안 받으면 LOGINDISABLED(RFC 3501 7.2.1) + if (!this.secure && this.server.secureContext) caps.push('STARTTLS'); + return caps.join(' '); + } + onData(chunk) { + // STARTTLS 를 받은 뒤 TLS 전환 전까지 평문으로 오는 바이트는 버립니다(명령 주입 방지, 아래 설명). + if (this.upgrading) return; + this.buf = Buffer.concat([this.buf, chunk]); + if (this.buf.length > 64 * 1024 * 1024) { this.close('* BYE too much data'); return; } + for (;;) { + if (this.needLiteral > 0) { + if (this.buf.length < this.needLiteral) return; + this.literals.push(this.buf.subarray(0, this.needLiteral)); this.raw += `\0${this.literals.length - 1}\0`; + this.buf = this.buf.subarray(this.needLiteral); this.needLiteral = 0; continue; + } + const idx = this.buf.indexOf('\r\n'); if (idx < 0) { if (this.buf.length > 65536) this.close('* BYE line too long'); return; } + const line = this.buf.subarray(0, idx).toString('latin1'); this.buf = this.buf.subarray(idx + 2); + const lit = line.match(/\{(\d+)(\+?)\}$/); + if (lit && !this.continuation && !this.idle) { + const n = Number(lit[1]); if (n > 40 * 1024 * 1024) { this.raw = ''; this.literals = []; this.send('* BAD literal too large'); continue; } + this.raw += line.slice(0, -lit[0].length); this.needLiteral = n; if (n === 0) { this.literals.push(Buffer.alloc(0)); this.raw += `\0${this.literals.length - 1}\0`; this.needLiteral = 0; } + if (!lit[2]) this.send('+ Ready for literal data'); + continue; + } + const raw = this.raw + line, literals = this.literals; this.raw = ''; this.literals = []; + // STARTTLS 명령 주입 방지(CVE-2011-0411 유형): 같은 평문 묶음에서 STARTTLS 뒤에 붙어 온 명령은 TLS 전환 뒤에 + // 암호화된 명령처럼 실행될 수 있으므로, STARTTLS 를 큐에 넣은 뒤 남은 입력을 버리고 전환이 끝날 때까지 받지 않습니다. + const isStartTls = !this.idle && !this.continuation && /^\S+\s+STARTTLS\s*$/i.test(raw); + if (isStartTls) { this.upgrading = true; this.buf = Buffer.alloc(0); } + this.queue = this.queue.then(() => this.dispatch(raw, literals)).catch((e) => { console.error('IMAP 오류', e?.stack || e?.message); this.send('* BAD internal error'); }); + if (isStartTls) return; + } + } + async dispatch(raw, literals) { + if (this.closed) return; + if (this.continuation) { const fn = this.continuation; this.continuation = null; return fn(raw); } + if (this.idle) { if (raw.trim().toUpperCase() === 'DONE') { const tag = this.idle.tag; this.stopIdle(); await this.refresh({ expunge: true }); this.send(`${tag} OK IDLE terminated`); } return; } + const tokens = tokenize(raw, literals); + const tag = tokens[0]; if (!tag) return; + let name = String(tokens[1] || '').toUpperCase(), args = tokens.slice(2); + if (name === 'UID') { name = 'UID ' + String(args[0] || '').toUpperCase(); args = args.slice(1); } + try { await this.command(tag, name, args, raw, literals); } + catch (e) { if (e?.imap) this.send(`${tag} ${e.imap} ${e.message}`); else { console.error('IMAP 명령 오류', name, e?.stack || e?.message); this.send(`${tag} NO internal error`); } } + } + fail(kind, message) { const e = new Error(message); e.imap = kind; throw e; } + async command(tag, name, args, raw, literals = []) { + const needAuth = () => { if (this.state === 'notauth') this.fail('NO', 'login first'); }; + const needSelected = () => { needAuth(); if (!this.mailbox) this.fail('NO', 'no mailbox selected'); }; + switch (name) { + case 'CAPABILITY': this.send(`* CAPABILITY ${this.capabilities()}`); return this.send(`${tag} OK CAPABILITY completed`); + case 'NOOP': if (this.mailbox) await this.refresh({ expunge: true }); return this.send(`${tag} OK NOOP completed`); + case 'LOGOUT': this.send('* BYE logging out'); this.send(`${tag} OK LOGOUT completed`); return this.close(); + case 'ID': this.send('* ID ("name" "arihakdang-mail-gateway" "vendor" "Ari Hakdang")'); return this.send(`${tag} OK ID completed`); + case 'ENABLE': this.send('* ENABLED'); return this.send(`${tag} OK ENABLE completed`); + case 'NAMESPACE': needAuth(); this.send('* NAMESPACE (("" "/")) NIL NIL'); return this.send(`${tag} OK NAMESPACE completed`); + case 'STARTTLS': return this.startTls(tag); + case 'LOGIN': return this.login(tag, args[0], args[1]); + case 'AUTHENTICATE': return this.authenticate(tag, args); + case 'LIST': case 'LSUB': case 'XLIST': needAuth(); return this.list(tag, name, args); + case 'SUBSCRIBE': case 'UNSUBSCRIBE': needAuth(); return this.send(`${tag} OK ${name} completed`); + case 'CREATE': case 'DELETE': case 'RENAME': needAuth(); return this.send(`${tag} NO [CANNOT] mailboxes are fixed (INBOX, Sent, Drafts, Trash)`); + case 'STATUS': needAuth(); return this.status(tag, args); + case 'SELECT': case 'EXAMINE': needAuth(); return this.select(tag, args[0], name === 'EXAMINE'); + case 'APPEND': needAuth(); return this.append(tag, args, literals); + case 'CHECK': needSelected(); await this.refresh({ expunge: true }); return this.send(`${tag} OK CHECK completed`); + case 'CLOSE': needSelected(); if (!this.readOnly) await this.expunge(false); this.mailbox = null; this.messages = []; return this.send(`${tag} OK CLOSE completed`); + case 'UNSELECT': needSelected(); this.mailbox = null; this.messages = []; return this.send(`${tag} OK UNSELECT completed`); + case 'EXPUNGE': needSelected(); if (this.readOnly) this.fail('NO', 'mailbox is read-only'); await this.expunge(true); return this.send(`${tag} OK EXPUNGE completed`); + // EXAMINE(읽기 전용) 세션에서는 EXPUNGE 와 마찬가지로 UID EXPUNGE 도 거부합니다(예전에는 검사가 빠져 읽기 전용 세션이 메일을 지웠습니다, 0.8.24). + case 'UID EXPUNGE': needSelected(); if (this.readOnly) this.fail('NO', 'mailbox is read-only'); await this.expunge(true, resolveSet(args[0], this.messages, true)); return this.send(`${tag} OK EXPUNGE completed`); + case 'SEARCH': case 'UID SEARCH': needSelected(); return this.search(tag, args, name.startsWith('UID')); + case 'FETCH': case 'UID FETCH': needSelected(); return this.fetch(tag, args, name.startsWith('UID')); + case 'STORE': case 'UID STORE': needSelected(); return this.store(tag, args, name.startsWith('UID')); + case 'COPY': case 'UID COPY': case 'MOVE': case 'UID MOVE': needSelected(); return this.copy(tag, args, name.startsWith('UID'), name.endsWith('MOVE')); + case 'IDLE': needAuth(); return this.startIdle(tag); + default: return this.send(`${tag} BAD unknown command`); + } + } + // ---------------------------------------------------------------- 인증 + async startTls(tag) { + this.upgrading = false; // 거절하면 평문 입력을 다시 받고, 전환하면 새 TLS 소켓으로 받습니다. + if (this.secure) return this.send(`${tag} NO already using TLS`); + if (!this.server.secureContext) return this.send(`${tag} NO TLS not available`); + this.send(`${tag} OK Begin TLS negotiation now`); + const plain = this.socket; ['data', 'timeout', 'error', 'close'].forEach((ev) => plain.removeAllListeners(ev)); + const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext, SNICallback: (n, cb) => cb(null, this.server.secureContext) }); + this.secure = true; this.plainOk = true; this.buf = Buffer.alloc(0); this.attach(secureSocket); + } + async login(tag, login, password) { + if (!this.plainOk) { console.log(`IMAP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send(`${tag} NO [PRIVACY] use STARTTLS or port 993 before login`); } + if (this.state !== 'notauth') return this.send(`${tag} BAD already logged in`); + if (rate.blocked('imap:' + this.ip, { limit: 20 })) return this.close(`${tag} NO [AUTHENTICATIONFAILED] too many attempts`); + // 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 모르는·꺼 둔 도메인이면 인증 실패와 같게 처리합니다. + const env = this.server.resolveEnv ? this.server.resolveEnv(String(login || ''), sniName(this.socket)) : this.env; + const user = env ? await verifyAdmin(env, String(login || ''), String(password || '')) : null; + if (user) this.env = env; + if (!user) rate.hit('imap:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다 + console.log(`IMAP 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login || '').slice(0, 80)}`); + if (!user) { this.failures++; await new Promise((r) => setTimeout(r, this.env.POP_AUTH_DELAY_MS ?? 1500)); if (this.failures >= 3) return this.close(`${tag} NO [AUTHENTICATIONFAILED] authentication failed`); return this.send(`${tag} NO [AUTHENTICATIONFAILED] authentication failed`); } + // 관리자가 IMAP 서비스 자체를 꺼 두었으면(0.9.0, 관리자 페이지 설정) 모든 계정의 접속을 거부합니다. + if (!(await serviceEnabled(this.env, 'imap'))) { console.log(`IMAP 접속 거부 ${this.ip} user=${user.login} (관리자가 IMAP 서비스를 껐음)`); return this.send(`${tag} NO [UNAVAILABLE] IMAP service is disabled by the administrator`); } + // 계정 설정에서 IMAP 접속을 꺼 두었으면 비밀번호가 맞아도 거부합니다(0.8.4~). + if (!(await serviceAllowed(this.env, user, 'imap'))) { console.log(`IMAP 접속 거부 ${this.ip} user=${user.login} (계정 설정에서 IMAP 사용 안 함)`); return this.send(`${tag} NO [UNAVAILABLE] IMAP access is disabled for this account`); } + this.user = user; this.state = 'auth'; + this.send(`${tag} OK [CAPABILITY ${this.capabilities()}] LOGIN completed`); + } + async authenticate(tag, args) { + if (String(args[0] || '').toUpperCase() !== 'PLAIN') return this.send(`${tag} NO unsupported mechanism`); + if (!this.plainOk) { console.log(`IMAP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send(`${tag} NO [PRIVACY] use STARTTLS or port 993 before login`); } + const finish = async (payload) => { + if (payload.trim() === '*') return this.send(`${tag} BAD cancelled`); + let parts; try { parts = Buffer.from(payload.trim(), 'base64').toString('utf8').split('\0'); } catch { return this.send(`${tag} BAD invalid response`); } + if (parts.length < 3) return this.send(`${tag} BAD invalid response`); + return this.login(tag, parts[1], parts[2]); + }; + if (args[1]) return finish(args[1]); + this.send('+ '); this.continuation = finish; + } + // ---------------------------------------------------------------- 메일함 + async list(tag, name, args) { + const reference = String(args[0] ?? ''), pattern = String(args[1] ?? ''); + if (pattern === '') { this.send(`* ${name} (\\Noselect) "/" ""`); return this.send(`${tag} OK ${name} completed`); } + const re = wildcard((reference + pattern).replace(/^\/+/, '')); + for (const [mailbox, , attrs] of MAILBOXES) if (re.test(mailbox)) this.send(`* ${name} (\\HasNoChildren${attrs.length ? ' ' + attrs.join(' ') : ''}) "/" "${mailbox}"`); + this.send(`${tag} OK ${name} completed`); + } + async uidValidity() { const row = await this.env.DB.prepare("SELECT value FROM settings WHERE key='imap_uidvalidity'").first(); return Number(row?.value || 1); } + async snapshot(folder) { + // 새 UID 는 도착 순서대로: 인덱스 순회 방향에 따라 순서가 뒤집히지 않도록 명시적으로 정렬합니다. + await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) SELECT id,folder FROM messages WHERE folder=? AND mailbox=? ORDER BY received_at, id').bind(folder, this.user.mailbox).run(); + const rows = (await this.env.DB.prepare(`SELECT m.id, u.id AS uid, m.read_at, m.state, m.folder, m.raw_size, m.received_at, COALESCE(f.flags,'[]') AS flags FROM messages m JOIN imap_uids u ON u.message_id=m.id AND u.folder=m.folder LEFT JOIN imap_flags f ON f.message_id=m.id WHERE m.folder=? AND m.mailbox=? ORDER BY u.id`).bind(folder, this.user.mailbox).all()).results; + // state: 메일 상태(draft/sending/sent/received/failed). COPY/MOVE 가 Drafts 로 옮길 수 있는 메일인지 판단하는 데 씁니다(0.8.24). + return rows.map((r) => ({ id: r.id, uid: r.uid, seen: !!r.read_at, draft: r.state === 'draft' || r.folder === 'drafts', state: r.state, flags: JSON.parse(r.flags), size: r.raw_size, date: r.received_at })); + } + flagList(m) { const f = []; if (m.seen) f.push('\\Seen'); if (m.draft) f.push('\\Draft'); for (const x of m.flags) if (!f.includes(x)) f.push(x); return f; } + async uidNext() { const row = await this.env.DB.prepare('SELECT COALESCE(MAX(id),0)+1 AS n FROM imap_uids').first(); return row.n; } + async select(tag, name, readOnly) { + const folder = folderOf(name); if (!folder) return this.send(`${tag} NO [NONEXISTENT] no such mailbox`); + this.mailbox = folder; this.readOnly = readOnly; this.messages = await this.snapshot(folder); this.cache.clear(); + const unseen = this.messages.findIndex((m) => !m.seen); + this.send(`* ${this.messages.length} EXISTS`); this.send('* 0 RECENT'); + if (unseen >= 0) this.send(`* OK [UNSEEN ${unseen + 1}] first unseen`); + this.send(`* OK [UIDVALIDITY ${await this.uidValidity()}] UIDs valid`); this.send(`* OK [UIDNEXT ${await this.uidNext()}] predicted next UID`); + this.send('* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)'); this.send(`* OK [PERMANENTFLAGS (${readOnly ? '' : '\\Answered \\Flagged \\Deleted \\Seen'})] flags`); + this.send(`${tag} OK [${readOnly ? 'READ-ONLY' : 'READ-WRITE'}] ${readOnly ? 'EXAMINE' : 'SELECT'} completed`); + } + async status(tag, args) { + const folder = folderOf(args[0]); if (!folder) return this.send(`${tag} NO [NONEXISTENT] no such mailbox`); + const items = Array.isArray(args[1]) ? args[1] : [args[1]].filter(Boolean); + const rows = await this.snapshot(folder); const out = []; + for (const item of items.map((x) => String(x).toUpperCase())) { + if (item === 'MESSAGES') out.push(`MESSAGES ${rows.length}`); else if (item === 'RECENT') out.push('RECENT 0'); + else if (item === 'UNSEEN') out.push(`UNSEEN ${rows.filter((m) => !m.seen).length}`); else if (item === 'UIDNEXT') out.push(`UIDNEXT ${await this.uidNext()}`); + else if (item === 'UIDVALIDITY') out.push(`UIDVALIDITY ${await this.uidValidity()}`); + } + this.send(`* STATUS "${FOLDER_NAME[folder]}" (${out.join(' ')})`); this.send(`${tag} OK STATUS completed`); + } + /** 다른 세션·웹·수신으로 바뀐 내용을 알립니다. */ + async refresh({ expunge = false } = {}) { + if (!this.mailbox) return; + const fresh = await this.snapshot(this.mailbox); const freshByUid = new Map(fresh.map((m) => [m.uid, m])); + if (expunge) { for (let i = this.messages.length - 1; i >= 0; i--) if (!freshByUid.has(this.messages[i].uid)) { this.messages.splice(i, 1); this.send(`* ${i + 1} EXPUNGE`); } } + const known = new Set(this.messages.map((m) => m.uid)); + this.messages.forEach((m, i) => { const f = freshByUid.get(m.uid); if (f && this.flagList(f).join(' ') !== this.flagList(m).join(' ')) { Object.assign(m, f); this.send(`* ${i + 1} FETCH (FLAGS (${this.flagList(m).join(' ')}))`); } }); + const added = fresh.filter((m) => !known.has(m.uid)); if (added.length) { this.messages.push(...added); this.send(`* ${this.messages.length} EXISTS`); } + } + // ---------------------------------------------------------------- 내용 + async loadParsed(m) { + const hit = this.cache.get(m.id); if (hit) return hit; + const row = await this.env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(m.id).first(); + let eml = row ? Buffer.from(row.eml) : null; + if (!eml) { const stored = await this.env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(m.id).first(); eml = stored ? await buildStoredEml(this.env, stored) : Buffer.from('Subject: (missing)\r\n\r\n'); } + const parsed = parseMime(normalizeCrlf(eml)); parsed.size = parsed.buffer.length; + if (this.cache.size > 40) this.cache.delete(this.cache.keys().next().value); + this.cache.set(m.id, parsed); return parsed; + } + parseFetchItems(spec) { + let items = Array.isArray(spec) ? spec : [spec]; + const upper = items.map((x) => String(x).toUpperCase()); + if (upper.length === 1 && upper[0] === 'ALL') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE']; + else if (upper.length === 1 && upper[0] === 'FAST') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE']; + else if (upper.length === 1 && upper[0] === 'FULL') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE', 'BODY']; + return items.map(String); + } + async fetch(tag, args, byUid) { + const indexes = resolveSet(args[0], this.messages, byUid); const items = this.parseFetchItems(args[1] ?? []); + if (byUid && !items.some((x) => x.toUpperCase() === 'UID')) items.unshift('UID'); + for (const idx of indexes) { + const m = this.messages[idx]; const parts = []; let markSeen = false; + for (const item of items) { + const key = item.toUpperCase(); + if (key === 'UID') parts.push(`UID ${m.uid}`); + else if (key === 'FLAGS') parts.push(`FLAGS (${this.flagList(m).join(' ')})`); + else if (key === 'INTERNALDATE') parts.push(`INTERNALDATE "${internalDate(m.date)}"`); + else if (key === 'RFC822.SIZE') parts.push(`RFC822.SIZE ${(await this.loadParsed(m)).size}`); + else if (key === 'ENVELOPE') parts.push(`ENVELOPE ${envelope((await this.loadParsed(m)).root.headers)}`); + else if (key === 'BODYSTRUCTURE') parts.push(`BODYSTRUCTURE ${bodyStructure((await this.loadParsed(m)).root, true)}`); + else if (key === 'BODY') parts.push(`BODY ${bodyStructure((await this.loadParsed(m)).root, false)}`); + else if (key === 'RFC822' || key === 'RFC822.HEADER' || key === 'RFC822.TEXT') { + const parsed = await this.loadParsed(m); const data = section(parsed, key === 'RFC822' ? '' : key === 'RFC822.HEADER' ? 'HEADER' : 'TEXT'); + if (key !== 'RFC822.HEADER') markSeen = true; parts.push({ label: key, data }); + } else { + const bm = item.match(/^(BODY(?:\.PEEK)?)\[([^\]]*)\](?:<(\d+)\.(\d+)>)?$/i); if (!bm) continue; + const parsed = await this.loadParsed(m); let data = section(parsed, bm[2]); if (!data) { parts.push(`BODY[${bm[2]}] NIL`); continue; } + let label = `BODY[${bm[2].toUpperCase().replace(/\s+/g, ' ')}]`; + if (bm[3] !== undefined) { const start = Number(bm[3]), len = Number(bm[4]); data = data.subarray(start, start + len); label += `<${start}>`; } + if (!/PEEK/i.test(bm[1])) markSeen = true; parts.push({ label, data }); + } + } + if (markSeen && !m.seen && !this.readOnly) { await this.env.DB.prepare('UPDATE messages SET read_at=? WHERE id=? AND read_at IS NULL').bind(now(), m.id).run(); m.seen = true; if (!items.some((x) => x.toUpperCase() === 'FLAGS')) parts.push(`FLAGS (${this.flagList(m).join(' ')})`); } + this.write(Buffer.from(`* ${idx + 1} FETCH (`)); + // 문자열 조각(ENVELOPE·BODYSTRUCTURE 등)은 바이트 문자열이므로 latin1 로 씁니다(send 와 같은 규칙). 예전에는 UTF-8 로 써서 한글 제목이 깨졌습니다. + const bytes = (text) => Buffer.from(text, /[^\u0000-\u00ff]/.test(text) ? 'utf8' : 'latin1'); + parts.forEach((p, i) => { if (i) this.write(Buffer.from(' ')); if (typeof p === 'string') this.write(bytes(p)); else { this.write(Buffer.from(`${p.label} {${p.data.length}}${CRLF}`)); this.write(p.data); } }); + this.write(Buffer.from(`)${CRLF}`)); + } + this.send(`${tag} OK FETCH completed`); + } + async store(tag, args, byUid) { + if (this.readOnly) return this.send(`${tag} NO mailbox is read-only`); + const indexes = resolveSet(args[0], this.messages, byUid); const op = String(args[1] || '').toUpperCase(); + const om = op.match(/^([+-]?)FLAGS(\.SILENT)?$/); if (!om) return this.send(`${tag} BAD invalid STORE`); + const flags = (Array.isArray(args[2]) ? args[2] : args.slice(2)).map((f) => String(f)).map((f) => { const l = f.toLowerCase(); return l === '\\seen' ? '\\Seen' : l === '\\flagged' ? '\\Flagged' : l === '\\answered' ? '\\Answered' : l === '\\deleted' ? '\\Deleted' : l === '\\draft' ? '\\Draft' : f; }); + for (const idx of indexes) { + const m = this.messages[idx]; let seen = m.seen; let persisted = new Set(m.flags); + const wantSeen = flags.includes('\\Seen'), persistedWanted = flags.filter((f) => PERSISTED.has(f)); + if (om[1] === '+') { if (wantSeen) seen = true; persistedWanted.forEach((f) => persisted.add(f)); } + else if (om[1] === '-') { if (wantSeen) seen = false; persistedWanted.forEach((f) => persisted.delete(f)); } + else { seen = wantSeen; persisted = new Set(persistedWanted); } + if (seen !== m.seen) await this.env.DB.prepare('UPDATE messages SET read_at=? WHERE id=?').bind(seen ? now() : null, m.id).run(); + const list = [...persisted]; if (list.join() !== m.flags.join()) await this.env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(m.id, JSON.stringify(list), now()).run(); + m.seen = seen; m.flags = list; + if (!om[2]) this.send(`* ${idx + 1} FETCH (FLAGS (${this.flagList(m).join(' ')})${byUid ? ` UID ${m.uid}` : ''})`); + } + this.send(`${tag} OK STORE completed`); + } + async search(tag, args, byUid) { + let keys = args.slice(); if (String(keys[0] || '').toUpperCase() === 'CHARSET') keys = keys.slice(2); + // text(m): 검색용 캐시. body 는 원본 바이트 그대로(7bit 로 읽은 것), decoded 는 base64·quoted-printable 과 + // 문자 집합을 풀어 낸 본문(text + html). 우리가 만드는 원본(buildEml)은 본문을 base64 로 쓰므로, 원본 바이트만 + // 보던 예전 BODY/TEXT 검색은 본문 단어를 찾지 못했습니다. + const parsedCache = new Map(); const text = async (m) => { if (!parsedCache.has(m.id)) { const p = await this.loadParsed(m); let decoded = ''; try { const s = summarize(p); decoded = (s.text + '\n' + s.html).toLowerCase(); } catch { /* 해석 실패 시 원본 바이트 검색만 */ } parsedCache.set(m.id, { p, body: decodeText(p.buffer, '7bit').toLowerCase(), decoded }); } return parsedCache.get(m.id); }; + const hdr = async (m, name) => header((await text(m)).p.root.headers, name) || ''; + const dateOf = async (m, sent) => sent ? Date.parse(await hdr(m, 'date')) || Date.parse(m.date) : Date.parse(m.date); + const day = (t) => Math.floor(t / 86400000); + const evaluate = async (m, idx, list) => { + const next = () => list.shift(); const item = next(); + // 괄호 묶음 "(FROM a SUBJECT b)" 은 토큰화에서 배열이 됩니다. OR/NOT 뒤에 오면 여기로 들어오므로 묶음 전체를 + // AND 로 평가합니다. 예전에는 배열을 문자열 "FROM,a" 로 바꿔 default(참)로 빠져, OR (…) (…) 가 모두 참이 됐습니다. + if (Array.isArray(item)) return evalAll(m, idx, item); + const key = String(item ?? '').toUpperCase(); + const has = (f) => this.flagList(m).includes(f); + switch (key) { + case 'ALL': return true; case 'ANSWERED': return has('\\Answered'); case 'DELETED': return has('\\Deleted'); case 'DRAFT': return has('\\Draft'); case 'FLAGGED': return has('\\Flagged'); case 'SEEN': return m.seen; + case 'UNANSWERED': return !has('\\Answered'); case 'UNDELETED': return !has('\\Deleted'); case 'UNDRAFT': return !has('\\Draft'); case 'UNFLAGGED': return !has('\\Flagged'); case 'UNSEEN': return !m.seen; + case 'NEW': case 'RECENT': return false; case 'OLD': return true; case 'KEYWORD': next(); return false; case 'UNKEYWORD': next(); return true; + case 'UID': return resolveSet(next(), this.messages, true).includes(idx); + case 'LARGER': return (await this.loadParsed(m)).size > Number(next()); case 'SMALLER': return (await this.loadParsed(m)).size < Number(next()); + case 'SINCE': return day(await dateOf(m)) >= day(parseImapDate(next())); case 'BEFORE': return day(await dateOf(m)) < day(parseImapDate(next())); case 'ON': return day(await dateOf(m)) === day(parseImapDate(next())); + case 'SENTSINCE': return day(await dateOf(m, true)) >= day(parseImapDate(next())); case 'SENTBEFORE': return day(await dateOf(m, true)) < day(parseImapDate(next())); case 'SENTON': return day(await dateOf(m, true)) === day(parseImapDate(next())); + case 'FROM': case 'TO': case 'CC': case 'BCC': case 'SUBJECT': { const v = String(next() ?? '').toLowerCase(); return decodeWords(await hdr(m, key)).toLowerCase().includes(v); } + case 'HEADER': { const name = String(next() ?? ''); const v = String(next() ?? '').toLowerCase(); return decodeWords(await hdr(m, name)).toLowerCase().includes(v); } + case 'BODY': case 'TEXT': { const v = String(next() ?? '').toLowerCase(); const t = await text(m); return t.body.includes(v) || t.decoded.includes(v) || decodeWords(t.p.text).toLowerCase().includes(v); } + case 'NOT': return !(await evaluate(m, idx, list)); + case 'OR': { const a = await evaluate(m, idx, list); const b = await evaluate(m, idx, list); return a || b; } + default: + if (/^[\d*:,]+$/.test(key)) return resolveSet(key, this.messages, false).includes(idx); + return true; + } + }; + const evalAll = async (m, idx, list) => { const copy = list.slice(); let ok = true; while (copy.length) { const item = copy[0]; if (Array.isArray(item)) { copy.shift(); if (!(await evalAll(m, idx, item))) ok = false; } else if (!(await evaluate(m, idx, copy))) ok = false; } return ok; }; + const hits = []; + for (let i = 0; i < this.messages.length; i++) if (await evalAll(this.messages[i], i, keys)) hits.push(byUid ? this.messages[i].uid : i + 1); + this.send(`* SEARCH${hits.length ? ' ' + hits.join(' ') : ''}`); this.send(`${tag} OK SEARCH completed`); + } + async copy(tag, args, byUid, move) { + const indexes = resolveSet(args[0], this.messages, byUid); const folder = folderOf(args[1]); + if (!folder) return this.send(`${tag} NO [TRYCREATE] no such mailbox`); + if (move && this.readOnly) return this.send(`${tag} NO mailbox is read-only`); + // Drafts 에는 초안(발신·draft 상태)만 둘 수 있습니다. 받은 메일을 Drafts 로 옮기면 웹 메일이 그것을 초안으로 열어 편집·발송을 시도하다 + // mail_locked 로 막혀 열 수 없는 메일이 됐습니다(0.8.24). 초안이 아닌 메일은 NO [CANNOT] 로 거부합니다. + if (folder === 'drafts' && indexes.some((idx) => !['draft', 'failed'].includes(this.messages[idx].state))) return this.send(`${tag} NO [CANNOT] only drafts can be put in Drafts`); + const validity = await this.uidValidity(); const src = [], dst = []; + for (const idx of indexes) { + const m = this.messages[idx]; + if (move) { + if (folder !== this.mailbox) await this.env.DB.prepare('UPDATE messages SET previous_folder=folder,folder=?,updated_at=? WHERE id=?').bind(folder, now(), m.id).run(); + await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(m.id, folder).run(); + const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(m.id, folder).first(); src.push(m.uid); dst.push(row.id); + } else { + const newId = await copyMessage(this.env, m.id, folder); + await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(newId, folder).run(); + const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(newId, folder).first(); src.push(m.uid); dst.push(row.id); + } + } + if (src.length) this.send(`* OK [COPYUID ${validity} ${src.join(',')} ${dst.join(',')}] ${move ? 'moved' : 'copied'}`); + if (move && folder !== this.mailbox) for (const idx of indexes.slice().reverse()) { this.messages.splice(idx, 1); this.send(`* ${idx + 1} EXPUNGE`); } + this.send(`${tag} OK ${move ? 'MOVE' : 'COPY'} completed`); + } + async expunge(report, onlyIndexes = null) { + const targets = this.messages.map((m, i) => ({ m, i })).filter(({ m, i }) => m.flags.includes('\\Deleted') && (!onlyIndexes || onlyIndexes.includes(i))); + for (const { m } of targets) { + // 휴지통에서 지우면 영구 삭제. 임시 보관함(Drafts)의 초안도 바로 지웁니다: Thunderbird·Apple Mail 은 초안을 자동 저장할 때마다 + // 새 버전을 APPEND 하고 옛 버전에 \Deleted 를 붙여 EXPUNGE 하므로, 예전처럼 휴지통으로 옮기면 저장할 때마다 옛 초안이 휴지통에 쌓였습니다(0.8.16 수정). + // 초안은 발송 서비스에서 온 메일이 아니라 tombstone 이 필요 없습니다. + if (this.mailbox === 'trash' || this.mailbox === 'drafts') { + const row = await this.env.DB.prepare('SELECT provider_id FROM messages WHERE id=?').bind(m.id).first(); + const statements = []; if (row?.provider_id) statements.push(this.env.DB.prepare("INSERT OR IGNORE INTO tombstones(provider_id,deleted_at,reason) VALUES(?,?,'deleted')").bind(row.provider_id, now())); + statements.push(this.env.DB.prepare('DELETE FROM messages WHERE id=?').bind(m.id)); await this.env.DB.batch(statements); + } else { + await this.env.DB.batch([ + this.env.DB.prepare("UPDATE messages SET previous_folder=folder,folder='trash',updated_at=? WHERE id=?").bind(now(), m.id), + this.env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(m.id, JSON.stringify(m.flags.filter((f) => f !== '\\Deleted')), now()), + ]); + } + } + for (const { i } of targets.slice().reverse()) { this.messages.splice(i, 1); if (report) this.send(`* ${i + 1} EXPUNGE`); } + } + /** + * APPEND: 클라이언트가 올린 메일 원본을 메일함에 저장합니다(보낸 메일 사본·초안 등). + * @param {string} tag 명령 태그 + * @param {Array} args 토큰화된 인자(메일함, [플래그 목록], [날짜], 원본 리터럴) + * @param {Buffer[]} literals 회선에서 받은 리터럴 원본 바이트. 메일 원본은 마지막 리터럴입니다. + * 왜 literals 를 따로 받는가: 토큰화는 리터럴을 UTF-8 문자열로 바꾸는데, 8bit EUC-KR 본문이나 이진 부분처럼 + * UTF-8 이 아닌 바이트는 이 과정에서 U+FFFD 로 깨집니다. 원본은 받은 바이트 그대로 저장해야 합니다. + */ + async append(tag, args, literals = []) { + const folder = folderOf(args[0]); if (!folder) return this.send(`${tag} NO [TRYCREATE] no such mailbox`); + let i = 1; let flags = []; let date = null; + if (Array.isArray(args[i])) { flags = args[i].map(String); i++; } + if (typeof args[i] === 'string' && /^\s?\d{1,2}-[A-Za-z]{3}-\d{4}/.test(args[i])) { date = args[i]; i++; } + const literal = args[i]; if (typeof literal !== 'string' || !literal.length) return this.send(`${tag} BAD APPEND needs message literal`); + const bytes = literals.length ? literals[literals.length - 1] : Buffer.from(literal, 'utf8'); + // 메일함 최대 크기(0.9.0)를 넘으면 RFC 9208 의 OVERQUOTA 응답 코드로 거부합니다(메일 프로그램이 "용량 초과" 로 보여 줌). + let result; + try { result = await appendMessage(this.env, folder, bytes, { flags, internalDate: date && parseImapDateTime(date) ? new Date(parseImapDateTime(date)).toISOString() : null, mailbox: this.user.mailbox }); } + catch (e) { if (e.message === 'quota_exceeded') return this.send(`${tag} NO [OVERQUOTA] mailbox quota exceeded`); if (e.message === 'storage_full') return this.send(`${tag} NO [OVERQUOTA] server storage is full`); throw e; } + await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(result.id, folder).run(); + const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(result.id, folder).first(); + if (this.mailbox === folder) await this.refresh(); + this.send(`${tag} OK [APPENDUID ${await this.uidValidity()} ${row.id}] APPEND completed`); + } + startIdle(tag) { + this.send('+ idling'); this.idle = { tag, timer: setInterval(() => { this.queue = this.queue.then(() => this.refresh({ expunge: true })).catch(() => {}); }, Number(this.env.IMAP_IDLE_POLL_MS || 10000)) }; + this.socket.setTimeout(35 * 60 * 1000); + } + stopIdle() { if (this.idle) { clearInterval(this.idle.timer); this.idle = null; } } +} + +/** @param {{key:string|Buffer,cert:string|Buffer}|null} tlsOptions */ +/** allowPlaintext: true 항상 허용 / false 항상 거부 / null(기본) 접속마다 관리자 설정 "평문 로그인 허용" 을 읽음(0.9.7, pop3.mjs 와 같음). */ +/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 1.0.7 다중 도메인에서 도메인 없는 로그인 ID 의 도메인을 짐작하는 데 씁니다. */ +const sniName = (socket) => String(socket?.servername || ''); +/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작. */ +export function createImap(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = null, resolveEnv = null } = {}) { + const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv }; + const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); }); + // secure 서버는 인증서가 없어도 만들어 두고, 인증서가 없으면 포트를 기억만 합니다(pop3.mjs 와 같은 구조, 0.9.7). + const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); }); + secure.on('tlsClientError', () => {}); + let pendingSecure = null; + const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); })); + return { + plain, secure, + setTls(options) { + shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options); + if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'imaps', []).then(() => console.log(` IMAP TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' IMAP TLS 포트 열기 실패:', e.message)); } + }, + async listen({ plainPort, securePort, host = '0.0.0.0' }) { + const out = []; + if (plainPort) await listenOn(plain, plainPort, host, 'imap', out); + if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'imaps', out); else pendingSecure = { port: securePort, host }; } + return out; + }, + close() { plain.close(); if (secure.listening) secure.close(); }, + }; +} diff --git a/modules/arimail/server/index.mjs b/modules/arimail/server/index.mjs new file mode 100644 index 0000000..0839197 --- /dev/null +++ b/modules/arimail/server/index.mjs @@ -0,0 +1,17 @@ +/** + * AriMail 진입점(1.0.5 부터 부트스트랩). systemd 의 ExecStart(`node server/index.mjs`)와 `npm start` 가 이 파일을 실행하고, 실제 서버는 main.mjs 입니다. + * + * 왜 두 파일로 나눴나 + * Node 22 는 node:sqlite 를 처음 쓸 때 "ExperimentalWarning: SQLite is an experimental feature" 두 줄을 stderr 로 내보냅니다. 운영 로그(journal)에는 + * 재시작마다 이 경고가 남아 오류 수집(1.0.5 요구사항 0) 때 잡음이 됐습니다. 경고 필터는 node:sqlite 를 import 하는 모듈이 평가되기 **전에** 걸어야 하는데, + * ESM 의 정적 import 는 모듈 본문보다 먼저 실행되므로 main.mjs 안에서는 늦습니다. 그래서 여기서 먼저 필터를 걸고 main.mjs 를 동적으로 불러옵니다. + * (ExecStart 에 --disable-warning 을 더하는 방법은 systemd 유닛을 다시 설치해야 해서 피했습니다: 운영 서버의 환경·유닛 파일은 바꾸지 않습니다.) + * + * 필터 규칙: ExperimentalWarning 중 SQLite 에 관한 것만 삼키고, 그 밖의 경고(Deprecation, MaxListenersExceeded 등)는 그대로 기록합니다 — 그것들은 진짜 문제의 신호입니다. + */ +process.removeAllListeners('warning'); +process.on('warning', (warning) => { + if (warning?.name === 'ExperimentalWarning' && /sqlite/i.test(String(warning.message))) return; + console.warn(`(node:${process.pid}) ${warning.name}: ${warning.message}${warning.stack && process.env.NODE_DEBUG ? '\n' + warning.stack : ''}`); +}); +await import('./main.mjs'); diff --git a/modules/arimail/server/limits.mjs b/modules/arimail/server/limits.mjs new file mode 100644 index 0000000..f8cab51 --- /dev/null +++ b/modules/arimail/server/limits.mjs @@ -0,0 +1,296 @@ +/** + * 시스템 제한·관리자 제한·사용량·현황 (0.9.0~). + * + * 세 층의 제한이 있습니다. 위 층이 아래 층의 상한이며, 어느 층이든 0 은 "그 층에서는 제한하지 않음" 입니다. + * 1) 시스템 설정(systemLimits): 설치 시 환경 파일(gateway.env)에 적는 값. 메일 시스템 전체의 상위 설정이라 관리자 화면에서 바꿀 수 없습니다. + * LIMIT_ACCOUNTS 허용 가능한 메일 계정 수(mail_users 행 수) + * LIMIT_TOTAL_BYTES 모든 메일함을 합친 최대 저장 용량(MIME 기준) + * LIMIT_MAILBOX_BYTES 메일함 하나의 최대 용량(MIME 기준) + * LIMIT_ATTACHMENT_BYTES 첨부 파일 한 개의 최대 크기(MIME 기준, 아래 mimeSize 참고) + * LIMIT_RECIPIENTS 한 메일의 최대 받는 사람 수(받는 사람 + 참조 + 숨은 참조) + * LIMIT_MONTHLY_MESSAGES 한 달(매월 1일~말일)의 최대 수발신 통수(보낸 메일 + 받은 메일) + * 크기 값은 바이트 수 또는 K / M / G 접미사(예: 500M, 2G)로 적습니다. + * 2) 관리자 설정(readAdminLimits): 관리자 페이지에서 정하는 값(settings 표의 limits 키). 시스템 제한이 있으면 그 값 이내로만 저장됩니다. + * mailboxBytes(계정별 기본 메일함 크기) · attachmentBytes(첨부 최대 크기) · recipients(동시 수신자 수) + * 3) 계정별 설정(readUserQuota): 관리자가 계정마다 정하는 메일함 최대 크기(settings 표의 quota:<계정 id> 키). 역시 시스템 제한 이내. + * + * 왜 "MIME 기준" 인가: 메일은 저장·전송될 때 첨부가 base64 로 바뀌어 약 4/3 배(37%)로 늘어납니다. 발송 서비스(Resend)의 한도와 저장 공간은 + * 이 늘어난 크기에 맞춰 소진되므로, 모든 크기 제한과 사용량을 원본(.eml)의 바이트, 즉 MIME 변환 뒤의 크기로 셉니다. + * 화면은 이 점을 안내하고, 첨부 파일을 올릴 때는 파일 크기를 mimeSize() 로 바꿔 비교합니다. + * + * 그 밖에 이 모듈이 맡는 것: POP/IMAP 서비스 전체 켜기·끄기(services 키), 회원 가입 설정(signup 키), 서버 상태 화면의 메일 현황(stats). + * auth.mjs 가 이 모듈을 들여오므로 여기서는 auth.mjs 를 들여오지 않습니다(순환 방지). 메일함 주소·계정 id 는 인자로 받습니다. + */ +import { now } from './util.mjs'; +import { isWebmaker, webmakerByMailbox } from './webmaker.mjs'; + +// ------------------------------------------------------------------ 상수 +/** 어느 층에서도 제한하지 않았을 때 적용되는 내장 상한. 발송 서비스(Resend)의 한도에서 나온 값이라 넘길 수 없습니다. */ +export const HARD_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 첨부 한 개(MIME 기준). Resend 는 메일 전체 40MB 까지 받습니다. +export const HARD_RECIPIENTS = 50; // Resend 의 받는 사람 한도 +/** base64 로 바뀐 뒤의 크기(줄바꿈 제외). 첨부 파일의 원본 바이트를 MIME 기준 크기로 바꿀 때 씁니다. */ +export const mimeSize = (bytes) => Math.ceil(Number(bytes || 0) / 3) * 4; +/** 0 이 아닌 값들 중 가장 작은 값. 모두 0(제한 없음)이면 0. 여러 층의 제한을 합칠 때 씁니다. */ +export const minNonZero = (...values) => { const positive = values.map((v) => Number(v) || 0).filter((v) => v > 0); return positive.length ? Math.min(...positive) : 0; }; +/** 사용 비율(%). 제한이 0(없음)이면 null. 화면이 "8개 사용 중 / 최대 10개 (80%)" 를 그리는 데 씁니다. */ +export const percentOf = (used, limit) => (limit > 0 ? Math.min(999, Math.round((Number(used || 0) / limit) * 100)) : null); + +/** + * 크기 문자열을 바이트 수로. "500M", "2G", "1024K", "123456" 을 받습니다(소수 허용: 1.5G). 비어 있거나 해석할 수 없으면 0(제한 없음). + * 환경 파일에 사람이 적는 값이므로 대소문자·공백·"B" 접미사(MB, GiB)는 너그럽게 받습니다. + */ +export function parseSize(value) { + const s = String(value ?? '').trim().toUpperCase().replace(/I?B$/, ''); + const m = s.match(/^(\d+(?:\.\d+)?)\s*([KMGT]?)$/); if (!m) return 0; + const unit = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }[m[2]]; + return Math.floor(Number(m[1]) * unit); +} +const nonNegInt = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : 0; }; + +/** 시스템 설정(환경 변수)을 읽습니다. 모든 값은 0 이면 제한 없음. */ +export function systemLimits(env) { + return { + accounts: nonNegInt(env.LIMIT_ACCOUNTS), totalBytes: parseSize(env.LIMIT_TOTAL_BYTES), mailboxBytes: parseSize(env.LIMIT_MAILBOX_BYTES), + attachmentBytes: parseSize(env.LIMIT_ATTACHMENT_BYTES), recipients: nonNegInt(env.LIMIT_RECIPIENTS), monthlyMessages: nonNegInt(env.LIMIT_MONTHLY_MESSAGES), + }; +} + +// ------------------------------------------------------------------ settings 표 읽기·쓰기 +async function readJson(env, key, defaults) { + const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind(key).first(); + let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; } + return { ...defaults, ...(stored && typeof stored === 'object' ? stored : {}) }; +} +async function writeJson(env, key, value) { + await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(key, JSON.stringify(value), now()).run(); +} +/** 제한 값 하나를 검사합니다: 0 이상의 정수여야 하고(아니면 limit_invalid), 시스템 제한이 있으면 그 이내여야 합니다(limit_exceeds_system). 오류에 field 를 붙입니다. */ +function checkAgainstSystem(value, systemValue, field) { + // 숫자(또는 숫자 문자열)만 받습니다. 예전에는 true 가 1 로 저장돼 "받는 사람 1명" 같은 뜻밖의 제한이 생겼습니다(0.9.3). + const n = typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ? Number(value) : NaN; + if (!Number.isInteger(n) || n < 0) { const e = new Error('limit_invalid'); e.field = field; throw e; } + if (systemValue > 0 && n > systemValue) { const e = new Error('limit_exceeds_system'); e.field = field; e.limit = systemValue; throw e; } + return n; +} + +export const DEFAULT_ADMIN_LIMITS = { mailboxBytes: 0, attachmentBytes: 0, recipients: 0 }; +/** 관리자 제한(settings 의 limits). 저장된 값이 손상됐거나 시스템 제한을 넘으면(설치 뒤 시스템 값이 줄어든 경우) 시스템 값으로 깎아 돌려줍니다. */ +export async function readAdminLimits(env) { + const stored = await readJson(env, 'limits', DEFAULT_ADMIN_LIMITS), sys = systemLimits(env), out = {}; + for (const k of Object.keys(DEFAULT_ADMIN_LIMITS)) { const v = nonNegInt(stored[k]); const cap = sys[k]; out[k] = cap > 0 && v > cap ? cap : v; } + return out; +} +/** 관리자 제한을 저장합니다(관리자 페이지). 각 값은 시스템 제한 이내여야 하며 0 은 "시스템 값을 따름" 입니다. */ +export async function writeAdminLimits(env, patch) { + const current = await readAdminLimits(env), sys = systemLimits(env), next = { ...current }; + // 내장 상한(첨부 25MB, 받는 사람 50)도 시스템 값처럼 넘길 수 없습니다. 예전에는 그보다 큰 값이 저장돼 관리자 화면과 실제 적용값이 달랐습니다(0.9.3). + const hard = { attachmentBytes: HARD_ATTACHMENT_BYTES, recipients: HARD_RECIPIENTS }; + for (const k of Object.keys(DEFAULT_ADMIN_LIMITS)) if (patch[k] !== undefined) next[k] = checkAgainstSystem(patch[k], minNonZero(sys[k], hard[k] || 0), k); + await writeJson(env, 'limits', next); + return next; +} +/** 계정별 메일함 최대 크기(바이트, 0 = 관리자 기본값·시스템 값을 따름). */ +export async function readUserQuota(env, userId) { + const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('quota:' + userId).first(); + return row ? nonNegInt(row.value) : 0; +} +/** 계정별 메일함 크기 값만 검사합니다(저장 전 검사용). 돌려주는 값은 정수 바이트. */ +export function checkUserQuota(env, bytes) { return checkAgainstSystem(bytes, systemLimits(env).mailboxBytes, 'mailboxBytes'); } +/** 계정별 메일함 최대 크기를 저장합니다. 시스템의 메일함 제한을 넘을 수 없습니다. 0 이면 설정을 지웁니다. */ +export async function writeUserQuota(env, userId, bytes) { + const n = checkUserQuota(env, bytes); + if (n === 0) await env.DB.prepare('DELETE FROM settings WHERE key=?').bind('quota:' + userId).run(); + else await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('quota:' + userId, String(n), now()).run(); + return n; +} +/** deleteUser 가 부릅니다: 계정의 제한 설정을 지웁니다. */ +export async function clearUserQuota(env, userId) { await env.DB.prepare('DELETE FROM settings WHERE key=?').bind('quota:' + userId).run(); } + +// ------------------------------------------------------------------ 실제로 적용되는 값(세 층 합산) +/** + * 메일함 주소에 적용되는 최대 크기(바이트, 0 = 제한 없음): 계정별 설정 → 관리자 기본값 → 시스템 제한 중 가장 작은 0 아닌 값. + * 기본 메일함(www 관리자가 쓰는 경우)은 계정 행이 없으므로 관리자 기본값·시스템 제한만 적용됩니다. + */ +export async function mailboxQuota(env, mailbox) { + const row = await env.DB.prepare('SELECT id FROM mail_users WHERE mailbox=? ORDER BY created_at LIMIT 1').bind(String(mailbox || '').toLowerCase()).first(); + // 웹메이커 계정(1.0.8)은 mail_users 행이 없으므로 그 주체 id('wm:…')로 계정별 설정을 찾습니다. + const ownerId = row ? row.id : isWebmaker(env) ? (await webmakerByMailbox(env, mailbox))?.id : null; + const user = ownerId ? await readUserQuota(env, ownerId) : 0, admin = await readAdminLimits(env), sys = systemLimits(env); + return minNonZero(user, admin.mailboxBytes, sys.mailboxBytes); +} +/** 첨부 한 개의 최대 크기(MIME 기준 바이트). 관리자·시스템 값이 없어도 내장 상한(25MB)은 항상 적용됩니다. */ +export async function attachmentLimit(env) { const admin = await readAdminLimits(env); return minNonZero(admin.attachmentBytes, systemLimits(env).attachmentBytes, HARD_ATTACHMENT_BYTES); } +/** 한 메일의 최대 받는 사람 수. 내장 상한(50)은 항상 적용됩니다. */ +export async function recipientLimit(env) { const admin = await readAdminLimits(env); return minNonZero(admin.recipients, systemLimits(env).recipients, HARD_RECIPIENTS); } + +// ------------------------------------------------------------------ 사용량 +/** + * 메일함 사용량(바이트). 저장된 원본(.eml, raw_size)이 곧 MIME 기준 크기입니다. 원본이 아직 없는 메일(작성 중인 초안)은 첨부 크기를 대신 더합니다. + * 왜 raw_size 인가: 첨부가 base64 로 늘어난 뒤의 크기가 실제로 저장·전송되는 크기이고, 요구사항도 "MIME 변환시 늘어나는 크기를 감안" 하라고 합니다. + */ +export async function mailboxUsage(env, mailbox) { + const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.mailbox=? AND d.raw_status='none'),0) AS bytes FROM messages WHERE mailbox=?`).bind(mailbox, mailbox).first(); + return Number(row?.bytes || 0); +} +/** 모든 메일함을 합친 사용량(바이트). */ +export async function totalUsage(env) { + const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.raw_status='none'),0) AS bytes FROM messages`).first(); + return Number(row?.bytes || 0); +} +/** 게이트웨이 계정 수(mail_users 행 수, 정지·승인 대기 포함). 계정 수 제한과 비교합니다. */ +export async function accountCount(env) { return Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first())?.n || 0); } + +// ------------------------------------------------------------------ 기간(일·주·월) — 통계 시간대 기준 +/** 통계 시간대. 서버의 시간대가 UTC 여도 "오늘" 은 한국 기준이어야 하므로 기본값은 Asia/Seoul 이고 STATS_TIMEZONE 으로 바꿀 수 있습니다. */ +export function statsTimezone(env) { + const tz = String(env.STATS_TIMEZONE || 'Asia/Seoul').trim(); + try { new Intl.DateTimeFormat('en-CA', { timeZone: tz }); return tz; } catch { return 'Asia/Seoul'; } +} +/** 시각을 주어진 시간대의 연·월·일·시·분·초로 풉니다. */ +function tzParts(date, tz) { + const f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }); + const p = Object.fromEntries(f.formatToParts(date).map((x) => [x.type, x.value])); + return { y: +p.year, m: +p.month, d: +p.day, h: +p.hour % 24, mi: +p.minute, s: +p.second }; +} +/** 그 시각에 적용되는 시간대 오프셋(ms, UTC 보다 앞서면 양수). */ +function tzOffset(date, tz) { const p = tzParts(date, tz); return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s) - Math.floor(date.getTime() / 1000) * 1000; } +/** 시간대 기준 어느 날 0시의 순간(ms). 월·일이 범위를 넘어도(13월, 32일) Date.UTC 가 넘겨 계산합니다. 서머타임 경계는 두 번째 보정으로 맞춥니다. */ +function localMidnight(y, m, d, tz) { const base = Date.UTC(y, m - 1, d); let t = base - tzOffset(new Date(base), tz); const off = tzOffset(new Date(t), tz); if (base - off !== t) t = base - off; return t; } +/** + * 통계 기간의 경계(ISO 문자열): 'day' 오늘 0시~내일 0시, 'week' 이번 주 월요일 0시~다음 월요일 0시, 'month' 이달 1일 0시~다음 달 1일 0시 + * (요구사항: 월간은 매월 첫째 날부터 마지막 날까지). start 이상 end 미만이 그 기간입니다. + * @param {'day'|'week'|'month'} kind + * @param {Date} [at] 기준 시각(테스트용). 기본은 지금. + */ +export function periodBounds(kind, tz = 'Asia/Seoul', at = new Date()) { + const p = tzParts(at, tz); let start, end; + if (kind === 'day') { start = localMidnight(p.y, p.m, p.d, tz); end = localMidnight(p.y, p.m, p.d + 1, tz); } + else if (kind === 'week') { const dow = new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay(); const monday = p.d - ((dow + 6) % 7); start = localMidnight(p.y, p.m, monday, tz); end = localMidnight(p.y, p.m, monday + 7, tz); } + else { start = localMidnight(p.y, p.m, 1, tz); end = localMidnight(p.y, p.m + 1, 1, tz); } + return { start: new Date(start).toISOString(), end: new Date(end).toISOString() }; +} +/** + * 기간 안의 보낸 메일·받은 메일 수. 보낸 메일은 발송이 끝난 메일(state='sent')의 sent_at, 받은 메일은 received_at 기준이며 폴더(휴지통 포함)는 가리지 않습니다. + * 영구 삭제한 메일은 세지 않습니다(행이 없음). mailbox 를 주면 그 메일함만. + */ +export async function countMail(env, { start, end }, mailbox = null) { + const scope = mailbox ? ' AND mailbox=?' : '', extra = mailbox ? [mailbox] : []; + const sent = await env.DB.prepare(`SELECT COUNT(*) AS n FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at=? AND received_at 0 && recipients > maxRcpt) { const e = new Error('too_many_recipients'); e.limit = maxRcpt; throw e; } + const maxAttach = await attachmentLimit(env); + for (const size of attachmentSizes) if (mimeSize(size) > maxAttach) { const e = new Error('attachment_too_large'); e.limit = maxAttach; throw e; } + const sys = systemLimits(env); + if (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages) { const e = new Error('monthly_limit_exceeded'); e.limit = sys.monthlyMessages; throw e; } + const quota = await mailboxQuota(env, mailbox); + if (quota > 0 && (await mailboxUsage(env, mailbox)) + bytes > quota) { const e = new Error('quota_exceeded'); e.limit = quota; throw e; } + if (sys.totalBytes > 0 && (await totalUsage(env)) + bytes > sys.totalBytes) { const e = new Error('storage_full'); e.limit = sys.totalBytes; throw e; } +} +/** + * 수신 저장 전 검사. 받은 메일은 크기를 미리 알 수 없으므로 "이미 가득 찼는지" 만 봅니다. + * @returns {Promise} 저장할 수 없으면 사유. 동기화는 공간이 생기면 같은 메일을 다시 가져옵니다. + */ +export async function checkInbound(env, mailbox) { + const quota = await mailboxQuota(env, mailbox); + if (quota > 0 && (await mailboxUsage(env, mailbox)) >= quota) return 'quota_exceeded'; + const sys = systemLimits(env); + if (sys.totalBytes > 0 && (await totalUsage(env)) >= sys.totalBytes) return 'storage_full'; + return null; +} +/** 자동 전달·자동 응답처럼 메일 한 통당 발송이 생기는 작업 전에 월간 통수 제한만 확인합니다. 넘었으면 'limit'. */ +export async function monthlyBlocked(env) { const sys = systemLimits(env); return sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages; } + +// ------------------------------------------------------------------ POP/IMAP 서비스 전체 켜기·끄기, 회원 가입 설정 +/** + * 서비스 전체 스위치(settings 의 services). + * pop / imap : 끄면 계정 설정과 무관하게 모든 POP(또는 IMAP) 로그인을 거부합니다(0.9.0). + * plainLogin : 평문 포트(POP3 110, IMAP 143)에서 STLS/STARTTLS 없이 비밀번호 로그인을 받을지(0.9.7). 기본 허용. + * Gmail 의 "다른 계정에서 메일 확인" 은 110 포트에서 STLS 를 쓰지 않고 바로 USER/PASS 를 보내므로, + * 이 값이 꺼져 있으면 Gmail 은 "사용자 이름과 비밀번호가 일치하지 않음" 으로 보여 줍니다. 비밀번호가 암호화되지 않고 + * 지나가므로 보안을 우선하면 끄고 995/993(또는 STARTTLS 를 지원하는 메일 프로그램)만 쓰게 합니다. + */ +export const DEFAULT_SERVICES = { pop: true, imap: true, plainLogin: true }; +export async function readServices(env) { const s = await readJson(env, 'services', DEFAULT_SERVICES); return { pop: s.pop !== false, imap: s.imap !== false, plainLogin: s.plainLogin !== false }; } +export async function writeServices(env, patch) { + const next = await readServices(env); + for (const k of ['pop', 'imap', 'plainLogin']) if (patch[k] !== undefined) next[k] = !!patch[k]; + await writeJson(env, 'services', next); return next; +} +/** 메일 서버가 로그인 직후 부릅니다: 관리자가 그 서비스를 꺼 두었으면 false. */ +export async function serviceEnabled(env, service) { return (await readServices(env))[service] !== false; } +/** + * 평문 포트(110/143)에서 TLS 없이 로그인을 받을지(0.9.7). POP3·IMAP 서버가 접속마다 한 번 읽어 CAPA 광고와 USER/PASS 허용에 씁니다. + * 환경 변수 POP_ALLOW_PLAINTEXT=1 은 관리자 설정과 무관하게 항상 허용(진단용, 예전 의미 유지)이고, 그 밖의 값(0, 빈 값)은 관리자 설정을 따릅니다. + * 0.9.5 까지는 이 변수가 0 이면 110 로그인을 항상 거부했는데, 설치 템플릿의 기본값이 0 이라 Gmail 110 접속이 모두 실패했습니다. + * 설정을 읽지 못하면(DB 오류) 안전한 쪽인 거부로 둡니다. + */ +export async function plainLoginAllowed(env) { + if (env.POP_ALLOW_PLAINTEXT === '1') return true; + try { return (await readServices(env)).plainLogin !== false; } catch { return false; } +} + +export const DEFAULT_SIGNUP = { enabled: false, approval: true }; +/** + * 회원 가입 설정(settings 의 signup). + * enabled : 로그인 화면에 "회원 가입" 을 보여 주고 POST /api/signup 을 받을지 + * approval : true 면 가입한 계정을 관리자가 승인해야 쓸 수 있음(승인 대기), false 면 즉시 사용 + */ +export async function readSignup(env) { const s = await readJson(env, 'signup', DEFAULT_SIGNUP); return { enabled: !!s.enabled, approval: s.approval !== false }; } +export async function writeSignup(env, patch) { + const next = await readSignup(env); + for (const k of ['enabled', 'approval']) if (patch[k] !== undefined) next[k] = !!patch[k]; + await writeJson(env, 'signup', next); return next; +} + +// ------------------------------------------------------------------ 서버 상태 화면의 메일 현황 +/** + * 관리자 "서버 상태" 의 메일 현황(요구사항 8). + * periods.day/week/month : 기간별 발신·수신·수발신 통수와 기간 경계 + * mailboxes : 메일함별 사용량(MIME 기준)과 적용 중인 최대 크기·비율 + * usage.total/accounts/monthly : 전체 사용량·계정 수·이달 통수를 시스템 제한과 견준 값({ used, limit, percent }, limit 0 = 제한 없음) + * system / admin : 적용 중인 시스템 제한·관리자 제한 값(화면 안내용) + * @param {string[]} mailboxes 알려진 메일함 주소 목록(기본 메일함 + 계정 메일함). auth.knownMailboxes 결과를 넘깁니다. + * @param {object[]} users listUsers 결과(login·name·mailbox 로 메일함에 이름을 붙임) + */ +export async function mailStats(env, mailboxes, users = []) { + const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env); + const periods = {}; + for (const kind of ['day', 'week', 'month']) periods[kind] = await countMail(env, periodBounds(kind, tz)); + const boxes = []; + for (const mailbox of mailboxes) { + const used = await mailboxUsage(env, mailbox), quota = await mailboxQuota(env, mailbox), owner = users.find((u) => u.mailbox === mailbox); + const counts = await env.DB.prepare('SELECT COUNT(*) AS n FROM messages WHERE mailbox=?').bind(mailbox).first(); + boxes.push({ mailbox, login: owner?.login || null, name: owner?.name || '', messages: Number(counts?.n || 0), bytes: used, limit: quota, percent: percentOf(used, quota) }); + } + const total = await totalUsage(env), accounts = await accountCount(env), monthly = periods.month.total; + return { + timezone: tz, periods, mailboxes: boxes, system: sys, admin, + usage: { + total: { used: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes) }, + accounts: { used: accounts, limit: sys.accounts, percent: percentOf(accounts, sys.accounts) }, + monthly: { used: monthly, limit: sys.monthlyMessages, percent: percentOf(monthly, sys.monthlyMessages) }, + }, + }; +} diff --git a/modules/arimail/server/main.mjs b/modules/arimail/server/main.mjs new file mode 100644 index 0000000..8d09c62 --- /dev/null +++ b/modules/arimail/server/main.mjs @@ -0,0 +1,342 @@ +/** + * AriMail 게이트웨이 본체(1.0.5 부터 index.mjs 는 경고 필터만 거는 부트스트랩이고 이 파일이 서버입니다; 1.0.0 부터 프로젝트 이름 AriMail — 저장소 https://source.ibit.kr/OZ-Group/AriMail). + * - HTTP: 웹 메일함 + API + Resend 웹훅 + Haraka 수신 전달(/api/inbound/haraka) (nginx 뒤, 127.0.0.1:8790) + * - 전체 설정 화면(1.0.7)·초기 설정 페이지: 전용 포트(SETUP_PORT, 기본 8791, 토큰 인증)와 /setup(관리자 세션) — 설정 파일(config.json)을 웹에서 채웁니다(1.0.0) + * - POP3: 110(STLS) / 995(TLS) + * - IMAP: 143(STARTTLS) / 993(TLS) + * - SMTP 제출: 587(STARTTLS) / 465(TLS) — 로그인 비밀번호로 인증하고 설정된 발송 모듈(Resend / 내장 Haraka / 외부 SMTP 중계)로 발송 + * - 작업: 1시간마다 Resend 목록 동기화(Resend 를 쓰는 방향만), 만료 세션 정리 + * (0.8.0 부터 메일 보관 기한이 없어 주기 작업이 메일을 지우지 않습니다.) + * + * 설정의 두 층(1.0.0): 환경 파일(gateway.env, systemd EnvironmentFile)에는 배포에 꼭 필요한 값(DB·포트·메일함·로그인 방식·시스템 제한)만 있고, + * 운영 설정(발송·수신 방식, Resend 키, 중계 서버, Haraka, 사이트 정보)은 config.json(config.mjs)에 있어 재시작 없이 바뀝니다. + * + * 다중 도메인(1.0.7, 요구사항 1): 한 프로세스가 여러 도메인을 섬깁니다(server/domains.mjs). 환경 파일의 도메인이 "기본 도메인" 이고, 전체 설정 화면에서 + * 더한 도메인은 데이터 디렉터리의 domains/<도메인>/ 에 자기 DB·설정·파일을 갖습니다. HTTP 요청은 Host 헤더로, 메일 프로그램 로그인은 로그인 ID 의 @도메인으로 + * 어느 도메인(테넌트 env)의 일인지 고릅니다. 모르는 호스트나 꺼 둔 도메인으로 온 웹 요청에는 "메일 서비스가 존재하지 않습니다/활성화되지 않았습니다" 화면을 줍니다. + */ +import { createServer } from 'node:http'; +import { readFileSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { openDb, d1, migrate } from './db.mjs'; +import { handle } from './api.mjs'; +import { createPop3 } from './pop3.mjs'; +import { createImap } from './imap.mjs'; +import { createSmtp } from './smtp.mjs'; +import { syncMailbox, cleanupSessions } from './store.mjs'; +import { ensureInitialAdmin, currentUser, authMode } from './auth.mjs'; +import { uploadLargeFile, largeFileInfo, claimDownload, openLargeFile, finishDownload, cleanupLargeFiles, humanSize, formatExpiry, filesDir, systemLargeFiles } from './files.mjs'; +import { escapeHtml } from './util.mjs'; +import { memoryRateStore, siteName } from './util.mjs'; +import { systemLimits } from './limits.mjs'; +import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs'; +import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs'; +import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs'; +import { autoVerifyDomain } from './providers/resend.mjs'; +import { openTenants, noServicePage, normalizePrefix } from './domains.mjs'; +import { isWebmaker } from './webmaker.mjs'; +import { usageApi, clientIp } from './usage.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..'); +const PORT = Number(process.env.PORT || 8790), HOST = process.env.HOST || '127.0.0.1'; +const DB_FILE = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite'); +const db = openDb(DB_FILE); console.log(` DB: ${DB_FILE}`); migrate(db, join(ROOT, 'migrations')); +const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() }; +// 운영 설정(1.0.0): config.json 을 읽어 env 에 덮어씁니다. 파일이 없으면(1.0.0 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다. +loadConfig(env); console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`); +// 로그인 방식(0.9.0~, 설치 옵션): LOGIN_MODE=standalone(기본) 은 www 사이트와 연동하지 않는 독립 실행, LOGIN_MODE=www 는 www 사이트 DB 로 관리자를 인증하는 연동 실행, +// LOGIN_MODE=webmaker(1.0.8) 는 웹메이커의 사용자 파일(WEBMAKER_DIR, 선택: WEBMAKER_USER·WEBMAKER_SITE)을 읽기 전용으로 참조하는 연동 실행입니다(추가 도메인은 등록부의 auth 로 정함). +// 값이 없으면 예전처럼 WWW_DB_FILE 유무로 정합니다. standalone 인데 WWW_DB_FILE 이 남아 있으면 무시하고, www·webmaker 인데 경로가 없으면 시작하지 않습니다(설정 실수를 조용히 넘기지 않음). +const LOGIN_MODE = String(env.LOGIN_MODE || (env.WWW_DB_FILE ? 'www' : 'standalone')).trim().toLowerCase(); +env.LOGIN_MODE = LOGIN_MODE; +if (LOGIN_MODE === 'standalone') { delete env.WWW_DB_FILE; delete env.WEBMAKER_DIR; } +else if (LOGIN_MODE === 'www') { delete env.WEBMAKER_DIR; if (!env.WWW_DB_FILE) { console.error(' ✖ LOGIN_MODE=www 인데 WWW_DB_FILE 이 없습니다 — www 사이트 DB 경로를 적거나 LOGIN_MODE=standalone 으로 바꾸세요'); process.exit(1); } } +else if (LOGIN_MODE === 'webmaker') { delete env.WWW_DB_FILE; if (!env.WEBMAKER_DIR) { console.error(' ✖ LOGIN_MODE=webmaker 인데 WEBMAKER_DIR 이 없습니다 — 웹메이커 데이터 디렉터리(DATA_ROOT, 예: /data/webmaker)를 적으세요'); process.exit(1); } } +else { console.error(` ✖ LOGIN_MODE 값을 모릅니다: ${LOGIN_MODE} (standalone | www | webmaker)`); process.exit(1); } +// WWW_DB_FILE 이 없으면 독립 실행(0.8.18~): www 관리자 대신 게이트웨이 계정(mail_users)만 쓰고, 기본 메일함도 게이트웨이 계정이 갖습니다. +if (isWebmaker(env)) console.log(` 웹메이커 연동(LOGIN_MODE=webmaker): ${env.WEBMAKER_DIR}${env.WEBMAKER_USER ? ` (sudo -u ${env.WEBMAKER_USER} 도우미로 읽음)` : ''}${env.WEBMAKER_SITE ? `, 사이트 ${env.WEBMAKER_SITE}` : ''}`); +else if (!env.WWW_DB_FILE) console.warn(' ℹ️ 독립 실행(LOGIN_MODE=standalone): www 관리자 없이 게이트웨이 계정만 씁니다'); else console.log(` www 연동(LOGIN_MODE=www): ${env.WWW_DB_FILE}`); +// 시스템 제한(0.9.0~): 설치 시 환경 파일에 적는 상위 제한. 0 은 제한 없음. 시작 로그에 남겨 운영자가 적용 값을 확인할 수 있게 합니다. +{ const l = systemLimits(env); const show = (n) => (n > 0 ? String(n) : '제한 없음'); console.log(` 시스템 제한: 계정 ${show(l.accounts)} · 전체 ${show(l.totalBytes)}B · 메일함 ${show(l.mailboxBytes)}B · 첨부 ${show(l.attachmentBytes)}B · 수신자 ${show(l.recipients)} · 월간 ${show(l.monthlyMessages)}통`); } +// 초기 관리자(0.8.24~): 독립 실행이고 계정이 하나도 없으면 admin / adminpasswd 관리자 계정을 만들어 설치 직후 바로 로그인할 수 있게 합니다. +// 화면은 이 계정이 초기 비밀번호를 쓰는 동안 경고를 보여 주고, 비밀번호를 바꾸면 경고가 사라집니다. +try { const made = await ensureInitialAdmin(env); if (made) console.warn(` ⚠️ 초기 관리자 계정을 만들었습니다: ${made.login} / ${made.password} — 관리자 페이지(/admin)에서 바로 비밀번호를 바꾸세요`); } +catch (e) { console.error(' 초기 관리자 계정 생성 실패:', e.message); } + +// ------------------------------------------------------------------ 정적 화면 +/** + * JSON 파일 읽기(1.0.5): 잠시 뒤 다시 시도합니다. mail.heart-m.com 의 0.9.7 배포 직후 첫 시작이 package.json 을 읽다 "Unexpected end of JSON input" 으로 + * 죽은 기록이 있습니다(systemd 가 2초 뒤 다시 띄워 정상). 릴리스 복사와 재시작 사이의 짧은 경쟁으로 보이며, 어느 경우든 비어 있는 파일 한 번으로 + * 프로세스가 죽지 않게 3번(200ms 간격) 다시 읽고, 끝내 실패하면 기본값을 쓰며 오류를 기록합니다. + */ +function readJsonSync(file, fallback) { + for (let attempt = 1; ; attempt++) { + try { const text = readFileSync(file, 'utf8'); if (!text.trim()) throw new Error('empty file'); return JSON.parse(text); } + catch (e) { + if (attempt >= 3) { if (fallback === undefined) throw e; console.error(` ✖ ${file} 을 읽지 못해 기본값을 씁니다: ${e.message}`); return fallback; } + const until = Date.now() + 200; while (Date.now() < until) { /* 동기 대기(시작 단계라 이벤트 루프를 막아도 무방) */ } + } + } +} +const baseStrings = readJsonSync(join(ROOT, 'lang', 'ko.json')); +// VERSION: package.json 의 버전(예: 0.8.1). 화면의 "아리학당 메일" 아래 작은 글씨와 /api/health 응답에 씁니다. +// 배포 스크립트가 릴리스마다 package.json 을 함께 복사하므로 지금 돌고 있는 코드의 버전과 항상 같습니다. +const VERSION = readJsonSync(join(ROOT, 'package.json'), { version: '0.0.0' }).version; +env.APP_VERSION = VERSION; +{ const l = systemLargeFiles(env); console.log(` 대용량 첨부파일 시스템 상한: ${humanSize(l.bytes)} · ${l.days}일 · ${l.downloads}회 · 저장 위치 ${filesDir(env)}`); } +const indexHtml = readFileSync(join(ROOT, 'web', 'index.html'), 'utf8'); + +// ------------------------------------------------------------------ TLS(메일 포트·설정 페이지) +function readTls() { + if (!env.POP_TLS_CERT || !env.POP_TLS_KEY) return null; + try { return { key: readFileSync(env.POP_TLS_KEY), cert: readFileSync(env.POP_TLS_CERT), mtime: statSync(env.POP_TLS_CERT).mtimeMs }; } + catch (e) { console.warn(' ⚠️ POP TLS 인증서를 읽지 못했습니다:', e.message); return null; } +} +let tlsFiles = readTls(); env.POP_TLS_ACTIVE = !!tlsFiles; + +/** + * 도메인(테넌트) env 하나를 쓸 준비(1.0.7): 버전, 문구 묶음, 화면 캐시, 설정 변경 훅, TLS 상태. 기본 도메인과 추가 도메인 모두 같은 함수로 준비합니다. + * 지금 설정으로 만든 문구 묶음: 왼쪽 위 서비스 이름(appName, 0.8.25~)은 메일 도메인에서 정합니다(heart-m.com → "HEART-M 메일"). + * 탭 제목(title)은 SITE_TITLE(1.0.0 부터 설정 파일에서 바꿀 수 있음), 없으면 서비스 이름. 설정이 바뀌면(ON_CONFIG_CHANGE) 다시 만듭니다. + * STRINGS: 서버가 만드는 메일 본문 문구(대용량 첨부파일 블록, 0.9.3)에도 같은 묶음을 씁니다. + */ +function prepareTenant(t) { + const stringsFor = () => { const s = { ...baseStrings }; s.appName = siteName(mailDomain(t), s.appName); s.title = t.SITE_TITLE ? String(t.SITE_TITLE).trim() : s.appName; return s; }; + t.APP_VERSION = VERSION; t.POP_TLS_ACTIVE = !!tlsFiles; t.STRINGS = stringsFor(); t.PAGE = null; + // 설정 저장(초기 설정 페이지·관리자) 뒤 바로 반영할 것: 문구(탭 제목)와 화면 캐시. 발송·수신 모듈은 호출마다 env 를 읽으므로 따로 할 일이 없습니다. + t.ON_CONFIG_CHANGE = () => { t.STRINGS = stringsFor(); t.PAGE = null; }; + if (t !== env) { + // 추가 도메인의 시작 안내(기본 도메인은 위에서 이미 출력). 빠진 운영 설정은 경고만 하고 계속 뜹니다. + console.log(` [${t.DOMAIN}] 메일 서비스: 수신 ${inboundMode(t)} · 발신 ${outboundMode(t)} · DB ${t.DB_FILE}`); + if (usesResend(t) && !t.RESEND_API_KEY) console.warn(` [${t.DOMAIN}] ⚠️ RESEND_API_KEY 없음 — Resend 수신 동기화·발송이 동작하지 않습니다(전체 설정 화면에서 채우세요)`); + } +} +// 화면 한 장(index.html)에 문구·버전·호스트·도메인을 심어 둡니다. 로그인 안내 팝업이 "이 도메인의 메일 서비스" 를 설명하는 데 씁니다. +// __STANDALONE__: www 사이트 연동 여부. 화면은 이 값으로 www 관리자 관련 안내(접속 안내 3번 항목 등)를 숨깁니다. __AUTH__(1.0.8): 로그인 방식(local|www|webmaker) — 웹메이커 안내 문구용. +// __BASE__(1.0.8): 화면의 기준 경로. 보통 "/" 이고, 경로 접두사(/mail) 아래로 들어온 요청이면 "/mail/" — 정적 파일·API 주소가 모두 이 값을 앞에 붙입니다. 설정이 바뀌면 다시 만듭니다(PAGE 캐시). +const renderPage = (t, prefix) => indexHtml.replace('__STRINGS__', JSON.stringify(t.STRINGS).replace(/ prefix ? renderPage(t, prefix) : t.PAGE || (t.PAGE = renderPage(t, '')); +/** + * 이 요청의 경로 접두사(1.0.8). nginx 가 /mail/ 아래를 게이트웨이로 넘기며 붙인 X-Forwarded-Prefix 가 우선이고, 없으면 등록부의 "호스트/경로" 가 정한 값입니다. + * 잘못된 값(허용 글자 밖·..)은 무시합니다. 돌려주는 값은 '' 또는 '/mail' 꼴(끝 슬래시 없음). + */ +const prefixOf = (req, resolved) => { + // 등록부에 경로가 없는 호스트(보통의 mail.<도메인>)는 헤더를 믿지 않습니다 — 클라이언트가 X-Forwarded-Prefix 를 직접 보내 화면의 기준 경로를 바꾸지 못하게(nginx 템플릿도 이 헤더를 비웁니다). + if (!resolved?.prefix) return ''; + const h = req.headers['x-forwarded-prefix']; if (h !== undefined) { const p = normalizePrefix(String(h).split(',')[0]); if (p !== null) return p; } + return resolved.prefix; +}; + +// 도메인 등록부(1.0.7): 기본 도메인(이 env)과 추가 도메인의 env 를 엽니다. 등록부가 없으면 기본 도메인만으로 만듭니다(데이터 이동 없음). +const tenants = openTenants(env, { migrationsDir: join(ROOT, 'migrations'), onCreate: prepareTenant }); +console.log(` 도메인 등록부: ${join(env.DATA_DIR, 'domains.json')} — 기본 ${env.DOMAIN} (${env.TENANT.hosts.join(', ')})${tenants.all().length > 1 ? `, 추가 ${tenants.all().length - 1}개` : ''}`); +console.log(` 메일 서비스: 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`); +// 빠진 운영 설정은 경고만 하고 서버는 뜹니다(요구사항 3: 값이 없어도 배포는 성공). 값은 초기 설정 페이지에서 채우면 재시작 없이 적용됩니다. +if (usesResend(env) && !env.RESEND_API_KEY) console.warn(' ⚠️ RESEND_API_KEY 없음 — Resend 수신 동기화·발송이 동작하지 않습니다(초기 설정 페이지에서 채우세요)'); +if (inboundMode(env) === 'resend' && !env.RESEND_WEBHOOK_SECRET) console.warn(' ⚠️ RESEND_WEBHOOK_SECRET 없음 — 웹훅 수신을 거부합니다(1시간 동기화만 동작)'); +if (outboundMode(env) === 'smtp' && !env.SMTP_RELAY_HOST) console.warn(' ⚠️ SMTP_RELAY_HOST 없음 — 발송이 동작하지 않습니다(초기 설정 페이지에서 중계 서버를 적으세요)'); +if (tenants.envs().some(usesHaraka)) console.log(' 내장 Haraka 사용: haraka/run.mjs(별도 서비스)가 설정 파일을 보고 Haraka 를 띄웁니다'); + +// 정적 파일: 경로 → Content-Type. favicon.svg 는 브라우저 탭 아이콘입니다(없으면 매 방문마다 404 가 기록됐습니다). +const STATIC = { '/app.js': 'application/javascript; charset=utf-8', '/style.css': 'text/css; charset=utf-8', '/favicon.svg': 'image/svg+xml', '/setup.js': 'application/javascript; charset=utf-8', '/setup.css': 'text/css; charset=utf-8' }; +// X-App-Version(0.8.25~): 모든 응답에 지금 돌고 있는 버전을 실어, 열어 둔 화면이 배포 뒤 옛 app.js 로 새 API 를 부르지 않도록 화면이 스스로 새로고침합니다. +const SECURITY = { 'X-App-Version': VERSION, 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'X-Robots-Tag': 'noindex, nofollow', 'Cache-Control': 'no-store, no-transform' }; +// img-src 에 https: 를 두는 이유: 읽기 창의 srcdoc iframe 은 이 페이지의 CSP 를 상속하므로, 여기서 막으면 +// "외부 이미지 표시"를 눌러도 보이지 않습니다. 기본 차단은 iframe 쪽 CSP 와 src 제거로 유지합니다. +const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https:; font-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; + +async function readBody(req) { + if (req.method === 'GET' || req.method === 'HEAD') return undefined; + const chunks = []; let size = 0; + for await (const c of req) { size += c.length; if (size > 32 * 1024 * 1024) throw new Error('body_too_large'); chunks.push(c); } + return chunks.length ? Buffer.concat(chunks) : undefined; +} +/** 요청의 호스트 이름(nginx 가 넘긴 X-Forwarded-Host 우선). 테넌트 선택과 표준 Request 의 주소에 씁니다. */ +const hostOf = (req) => String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim(); +const server = createServer(async (req, res) => { + let pathname = '/'; + try { + const url = new URL(req.url, 'http://x'); pathname = url.pathname; const headOnly = req.method === 'HEAD'; + // /favicon.ico 를 직접 찾는 브라우저·크롤러에는 SVG 아이콘으로 안내합니다(404 오류 기록 방지). 정적 파일은 도메인과 무관하므로 먼저 줍니다. + if (pathname === '/favicon.ico') { res.writeHead(301, { Location: prefixOf(req, tenants.resolveHost(hostOf(req))) + '/favicon.svg', ...SECURITY }); return res.end(); } + if (STATIC[pathname]) { const body = readFileSync(join(ROOT, 'web', pathname)); res.writeHead(200, { 'Content-Type': STATIC[pathname], ...SECURITY, 'Cache-Control': 'no-cache' }); return res.end(headOnly ? undefined : body); } + // 상태 확인은 어느 호스트로 와도(배포 스크립트는 127.0.0.1 로 부름) 기본 도메인의 버전을 돌려줍니다. + if (pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); return res.end(JSON.stringify({ ok: true, version: VERSION, domains: tenants.all().length })); } + // 사용량 API(1.0.9): 어느 호스트로 와도 같은 응답(키·IP 로 인증, 읽기 전용). 본문 없는 GET 만 받습니다. + if (pathname === '/api/usage' || pathname.startsWith('/api/usage/')) { + const hdrs = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => hdrs.append(k, x)); else if (v !== undefined) hdrs.set(k, v); } + // 접속 IP 는 클라이언트가 넣을 수 있는 헤더(X-Forwarded-For 첫 값·CF-Connecting-IP)가 아니라 nginx 가 보증하는 값(X-Real-IP, XFF 마지막)으로 — 허용 목록·시도 제한이 이 값에 걸려 있음. + const ip = clientIp(hdrs, req.socket.remoteAddress); + // HEAD 는 GET 으로 처리하고 본문만 빼서 보냅니다(상태 확인용). + const response = await usageApi(tenants, new Request(`https://x${req.url}`, { method: headOnly ? 'GET' : req.method, headers: hdrs }), { path: pathname.split('/').slice(3).filter(Boolean), ip, query: url.searchParams }); + const out = { ...SECURITY }; response.headers.forEach((v, k) => { out[k] = v; }); const buf = Buffer.from(await response.arrayBuffer()); out['Content-Length'] = String(buf.length); + res.writeHead(response.status, out); return res.end(headOnly ? undefined : buf); + } + // 테넌트 선택(1.0.7): Host 로 도메인을 고릅니다. 모르는 호스트·꺼 둔 도메인이면 화면에는 안내 페이지, API 에는 404 JSON 을 줍니다. + const resolved = tenants.resolveHost(hostOf(req)); + const isHaraka = pathname.startsWith('/api/inbound/haraka'); + if ((!resolved.tenant || resolved.disabled) && !isHaraka) { + const disabled = resolved.disabled; + if (pathname.startsWith('/api/')) { res.writeHead(disabled ? 403 : 404, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: disabled ? 'domain_disabled' : 'domain_not_found', host: resolved.host })); } + res.writeHead(disabled ? 403 : 404, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", ...SECURITY }); + return res.end(headOnly ? undefined : noServicePage({ host: resolved.host, disabled, strings: baseStrings, version: VERSION })); + } + // Haraka 수신 전달은 로컬(127.0.0.1)에서 오며 도메인은 받는 사람 주소로 고르므로 기본 도메인의 env 로 들어가 api.mjs 가 나눕니다. + const t = isHaraka ? env : resolved.tenant.env; + // 경로 접두사(1.0.8): nginx 가 /mail/ 을 떼고 넘기므로 여기 경로는 늘 "/…" 이고, 화면이 돌아갈 기준 경로만 prefix 로 압니다. + const prefix = prefixOf(req, resolved); + // "/" 는 웹 메일, "/admin" 은 관리자 페이지입니다(0.8.4~). 같은 화면 파일을 주고, 앱이 주소(location.pathname)로 모드를 정합니다. + // /admin 은 항상 관리자 페이지로 이어지고 별도의 로그인(관리자 세션 쿠키)을 거칩니다. + if (pathname === '/' || pathname === '/index.html' || /^\/admin\/?$/.test(pathname)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(headOnly ? undefined : page(t, prefix)); } + // /setup(1.0.0): 메일 서비스 설정 화면. 관리자 세션으로 /api/setup/* 를 부릅니다(전용 포트의 토큰 방식과 같은 화면, setup.mjs). + if (/^\/setup\/?$/.test(pathname)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(headOnly ? undefined : setupPage(t, join(ROOT, 'web'), 'admin', { base: prefix + '/' })); } + // 대용량 첨부파일 다운로드 링크(0.9.3): /dl/<토큰>. GET 은 랜딩 페이지(횟수를 세지 않음), POST(페이지의 단추)가 실제 다운로드입니다. + // return await: 프라미스를 그냥 돌려주면 그 안의 예외(잘못된 Host 헤더로 new Request 가 던지는 TypeError 등)가 아래 catch 에 잡히지 않고 프로세스를 죽였습니다(0.9.5 수정). + if (pathname.startsWith('/dl/')) return await download(t, req, res, pathname.slice(4).split('/')[0], prefix); + if (!pathname.startsWith('/api/')) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'not_found' })); } + // 대용량 파일 업로드(0.9.3): 본문을 메모리에 모으지 않고 디스크로 흘려 씁니다(일반 API 의 32MB 본문 한도 밖). 로그인·초안 소유 확인은 uploadLargeFile 이 합니다. + if (pathname === '/api/files' && req.method === 'POST') return await upload(t, req, res, url); + const body = await readBody(req); + const proto = req.headers['x-forwarded-proto'] || 'https', host = hostOf(req) || t.PUBLIC_HOST || 'mail.arihakdang.com'; + const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => headers.append(k, x)); else if (v !== undefined) headers.set(k, v); } + const request = new Request(`${proto}://${host}${req.url}`, { method: headOnly ? 'GET' : req.method, headers, body }); + // 웹훅과 Haraka 수신 전달은 브라우저가 아니므로 Origin 검사(CSRF)를 하지 않습니다(각자 서명·토큰으로 인증). + const isWebhook = pathname === '/api/webhooks/resend' || pathname.startsWith('/api/inbound/'); + if (!isWebhook && !['GET', 'HEAD'].includes(req.method) && headers.get('origin') && headers.get('origin') !== new URL(request.url).origin) { res.writeHead(403, SECURITY); return res.end(); } + const ip = headers.get('cf-connecting-ip') || (headers.get('x-forwarded-for') || '').split(',')[0].trim() || req.socket.remoteAddress || ''; + const response = await handle(t, request, { path: pathname.slice(5).split('/').filter(Boolean).map(decodeURIComponent), ip, prefix }); + const out = { ...SECURITY }; response.headers.forEach((v, k) => { if (k.toLowerCase() !== 'set-cookie') out[k] = v; }); + const cookies = response.headers.getSetCookie?.() || []; if (cookies.length) out['Set-Cookie'] = cookies; + const buf = Buffer.from(await response.arrayBuffer()); out['Content-Length'] = String(buf.length); + res.writeHead(response.status, out); res.end(headOnly ? undefined : buf); + } catch (e) { + console.error(`오류 ${req.method} ${pathname}`, e?.message); + if (!res.headersSent) res.writeHead(e.message === 'body_too_large' ? 413 : 500, { 'Content-Type': 'application/json', ...SECURITY }); + res.end(JSON.stringify({ ok: false, error: 'server_error' })); + } +}); +server.listen(PORT, HOST, () => console.log(` HTTP 대기 중: http://${HOST}:${PORT}`)); +const jsonOut = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); res.end(JSON.stringify(obj)); }; +/** 요청 헤더만으로 표준 Request 를 만듭니다(세션 쿠키 확인용). */ +function headerRequest(t, req) { + const proto = req.headers['x-forwarded-proto'] || 'https', host = hostOf(req) || t.PUBLIC_HOST || 'mail.arihakdang.com'; + const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => headers.append(k, x)); else if (v !== undefined) headers.set(k, v); } + return new Request(`${proto}://${host}${req.url}`, { method: 'GET', headers }); +} +/** + * POST /api/files?draft=<초안 id> — 대용량 파일 업로드(0.9.3). 본문이 파일 바이트이고 이름은 X-File-Name(URL 인코딩), 형식은 Content-Type 입니다. + * 웹 메일 세션(쿠키)으로 인증하고 같은 출처(Origin)만 받습니다. 상한을 넘기면 413 과 limit 을 돌려주고 남은 본문은 버립니다. + * @param {object} t 이 요청의 도메인 env(1.0.7) + */ +async function upload(t, req, res, url) { + const request = headerRequest(t, req); + if (req.headers.origin && req.headers.origin !== new URL(request.url).origin) { res.writeHead(403, SECURITY); return res.end(); } + const user = await currentUser(t, request, { scope: 'mail' }); + if (!user) { req.resume(); return jsonOut(res, 401, { ok: false, error: 'unauthenticated' }); } + try { + // X-File-Name 은 URL 인코딩이 깨져 있어도(잘못된 % 열) 오류 대신 원문을 이름으로 씁니다. + const rawName = String(req.headers['x-file-name'] || ''); let filename; try { filename = decodeURIComponent(rawName); } catch { filename = rawName; } + const file = await uploadLargeFile(t, user, req, { draftId: url.searchParams.get('draft'), filename, contentType: req.headers['content-type'], declaredSize: Number(req.headers['content-length'] || 0) }); + return jsonOut(res, 201, { ok: true, file: { id: file.id, filename: file.filename, size: file.size, content_type: file.content_type, state: file.state } }); + } catch (e) { + req.resume(); + const status = e.message === 'file_too_large' ? 413 : e.message === 'large_files_disabled' ? 404 : e.message === 'mail_locked' ? 409 : e.message === 'bad_request' ? 400 : 422; + return jsonOut(res, status, { ok: false, error: e.message || 'server_error', ...(e.limit !== undefined ? { limit: e.limit } : {}) }); + } +} +/** 랜딩 페이지 HTML(외부 공개). 스크립트 없이 폼(POST)만 쓰며 검색 엔진이 색인하지 않게 합니다. */ +function landing(t, title, body) { + return `${escapeHtml(title)}
${body}

${escapeHtml(t.STRINGS.appName)} · ${escapeHtml(t.PUBLIC_HOST || '')}

`; +} +/** + * /dl/<토큰> — 받는 사람이 여는 다운로드 링크(0.9.3, 로그인 없음). + * GET : 파일 이름·크기·남은 횟수·기한을 보여 주는 랜딩 페이지. 메일 보안 장치·미리 보기 봇이 미리 열어도 횟수가 줄지 않습니다. + * POST : "다운로드" 단추. 횟수를 1 차지한 뒤 파일을 흘려보내고, 마지막 허용 횟수였으면 끝난 뒤 파일을 지웁니다. + * 만료·없는 토큰에는 410/404 안내 페이지를 줍니다. + */ +async function download(t, req, res, token, prefix = '') { + const page = (status, title, body) => { res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY, 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" }); res.end(landing(t, title, body)); }; + const s = (k) => t.STRINGS[k] || k; + if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) return page(404, s('dl_missingTitle'), `

${escapeHtml(s('dl_missingTitle'))}

${escapeHtml(s('dl_missing'))}

`); + if (req.method === 'GET' || req.method === 'HEAD') { + const info = await largeFileInfo(t, token); + if (info.status === 'missing') return page(404, s('dl_missingTitle'), `

${escapeHtml(s('dl_missingTitle'))}

${escapeHtml(s('dl_missing'))}

`); + if (info.status === 'expired') return page(410, s('dl_expiredTitle'), `

${escapeHtml(s('dl_expiredTitle'))}

${escapeHtml(s('dl_expired'))}

${escapeHtml(s('dl_file'))}
${escapeHtml(info.file.filename)}
`); + const f = info.file; + return page(200, s('dl_title'), `

${escapeHtml(s('dl_title'))}

${escapeHtml(s('dl_file'))}
${escapeHtml(f.filename)}
${escapeHtml(s('dl_size'))}
${humanSize(f.size)}
${escapeHtml(s('dl_remaining'))}
${info.remaining} / ${f.max_downloads}
${escapeHtml(s('dl_until'))}
${formatExpiry(f.expires_at, t)}

${escapeHtml(s('dl_note'))}

`); + } + if (req.method !== 'POST') { res.writeHead(405, SECURITY); return res.end(); } + req.resume(); + const claim = await claimDownload(t, token); + if (!claim) return page(410, s('dl_expiredTitle'), `

${escapeHtml(s('dl_expiredTitle'))}

${escapeHtml(s('dl_expired'))}

`); + const { row } = claim; + res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(row.size), 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(row.filename)}`, ...SECURITY }); + const stream = openLargeFile(claim.path); + stream.on('error', (e) => { console.error('대용량 파일 전송 오류', row.id, e.message); res.destroy(); }); + // 전송이 끝나면(또는 끊기면) 마지막 허용 횟수였던 파일을 지웁니다. 끊긴 경우도 횟수는 이미 차지했으므로 같은 처리입니다. + res.on('close', () => { finishDownload(t, claim).catch((e) => console.error('대용량 파일 정리 오류', row.id, e.message)); }); + stream.pipe(res); +} + +// ------------------------------------------------------------------ POP3 / IMAP / SMTP +// 평문 포트(110/143)의 TLS 없는 로그인(0.9.7): POP_ALLOW_PLAINTEXT=1 이면 항상 허용(진단용), 그 밖에는 null 을 넘겨 관리자 설정 "평문 로그인 허용"(기본 켬)을 접속마다 읽습니다. +// 0.9.5 까지는 이 변수가 1 이 아니면 항상 거부해, 설치 템플릿 기본값(0)에서 STLS 를 쓰지 않는 Gmail 의 110 접속이 전부 실패했습니다. +const allowPlaintext = env.POP_ALLOW_PLAINTEXT === '1' ? true : null; +// resolveEnv(1.0.7): 로그인 ID 의 @도메인(없으면 TLS SNI 호스트, 그래도 없으면 기본 도메인)으로 그 접속이 쓸 도메인 env 를 고릅니다. 메일 서버 이름·인증서는 기본 도메인 것 하나입니다. +const resolveEnv = (login, servername) => tenants.resolveLogin(login, servername); +const mailOpts = { hostname: env.PUBLIC_HOST || 'mail.arihakdang.com', tlsOptions: tlsFiles ? { key: tlsFiles.key, cert: tlsFiles.cert } : null, resolveEnv }; +const pop = createPop3(env, { ...mailOpts, allowPlaintext }); +if (env.POP_DISABLED !== '1') { + // TLS 포트는 인증서가 없으면 지금 열지 않고 기억해 두어, 아래 1시간 점검이 인증서를 읽는 순간 엽니다(0.9.7, 예전에는 재시작 전까지 닫혀 있었음). + pop.listen({ plainPort: Number(env.POP_PORT || 110), securePort: Number(env.POPS_PORT || 995), host: env.POP_HOST || '0.0.0.0' }) + .then((bound) => { console.log(' POP3 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ POP TLS 인증서 없음 — 995 포트는 인증서를 읽을 수 있게 되면 엽니다(110 의 TLS 없는 로그인은 관리자 설정 "평문 로그인 허용" 을 따름)'); }) + .catch((e) => console.error(' POP3 시작 실패:', e.message)); +} +const imap = createImap(env, { ...mailOpts, allowPlaintext }); +if (env.IMAP_DISABLED !== '1') { + imap.listen({ plainPort: Number(env.IMAP_PORT || 143), securePort: Number(env.IMAPS_PORT || 993), host: env.POP_HOST || '0.0.0.0' }) + .then((bound) => { console.log(' IMAP 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ TLS 인증서 없음 — 993 포트는 인증서를 읽을 수 있게 되면 엽니다(143 의 TLS 없는 로그인은 관리자 설정 "평문 로그인 허용" 을 따름)'); }) + .catch((e) => console.error(' IMAP 시작 실패:', e.message)); +} +// 메일 프로그램의 "보내는 메일 서버". 웹 메일과 같은 아이디·비밀번호로 인증하고, 실제 발송에만 서버의 RESEND_API_KEY 를 씁니다. +// 인증은 TLS 위에서만 받습니다(SMTP_ALLOW_PLAINTEXT=1 은 진단용). 인증서는 POP·IMAP 과 같은 파일입니다. +const smtp = createSmtp(env, { ...mailOpts, allowPlaintext: env.SMTP_ALLOW_PLAINTEXT === '1' }); +if (env.SMTP_DISABLED !== '1') { + smtp.listen({ plainPort: Number(env.SMTP_PORT || 587), securePort: Number(env.SMTPS_PORT || 465), host: env.SMTP_HOST || env.POP_HOST || '0.0.0.0' }) + .then((bound) => { console.log(' SMTP 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ TLS 인증서 없음 — 465 포트는 인증서를 읽을 수 있게 되면 열고, 587 은 STARTTLS 없이 로그인을 거부합니다'); }) + .catch((e) => console.error(' SMTP 시작 실패:', e.message)); +} +// 인증서 갱신(certbot 훅이 사본을 바꿈)을 1시간마다 확인해 POP·IMAP·SMTP 에 새 인증서를 넣습니다. 시작 때 인증서가 없었다면(tlsFiles=null) 이때 TLS 포트도 열립니다(0.9.7). +setInterval(() => { const next = readTls(); if (next && next.mtime !== tlsFiles?.mtime) { tlsFiles = next; pop.setTls({ key: next.key, cert: next.cert }); imap.setTls({ key: next.key, cert: next.cert }); smtp.setTls({ key: next.key, cert: next.cert }); setup.setTls({ key: next.key, cert: next.cert }); for (const t of tenants.envs()) t.POP_TLS_ACTIVE = true; console.log(' TLS 인증서를 다시 읽었습니다'); } }, 3600000).unref(); + +// ------------------------------------------------------------------ 전체 설정 화면·초기 설정 페이지(1.0.0 요구사항 4, 1.0.7 요구사항 2) +// 전용 포트(SETUP_PORT, 기본 8791, 0 이면 끔)에서 토큰으로 인증하는 설정 화면을 엽니다. 인증서를 읽을 수 있으면 HTTPS, 없으면 HTTP. +// 1.0.7: 이 화면이 "전체 설정" 입니다 — 도메인 목록·추가·중지와 도메인별 설정(API 키·DNS·수·발신 방식)을 기본 도메인의 이 포트에서 관리합니다. +// 접속 주소는 로그와 데이터 디렉터리의 setup-url.txt 에 남겨 배포 스크립트가 끝에 출력합니다. +const setup = createSetupServer(env, { webDir: join(ROOT, 'web'), tlsOptions: tlsFiles ? { key: tlsFiles.key, cert: tlsFiles.cert } : null, tenants }); +const SETUP_PORT = Number(env.SETUP_PORT ?? 8791); +if (SETUP_PORT > 0) { + setup.listen({ port: SETUP_PORT, host: env.SETUP_HOST || '0.0.0.0' }) + .then(() => { const urls = setup.urls(setupToken(env)); console.log(' 전체 설정 화면: ' + urls[0] + (urls.length > 1 ? ` (또는 ${urls.slice(1).join(' , ')})` : '')); writeSetupUrls(env, urls); }) + .catch((e) => console.error(' 전체 설정 화면 시작 실패:', e.message)); +} + +// ------------------------------------------------------------------ 주기 작업(도메인마다, 1.0.7) +const SYNC_INTERVAL = Number(env.SYNC_INTERVAL_MS || 3600000); +const runSync = (trigger) => { for (const t of tenants.envs()) if (t.RESEND_API_KEY && usesResend(t)) syncMailbox(t, { trigger }).then((r) => { if (!r.busy) console.log(` 동기화(${trigger}${t === env ? '' : ', ' + t.DOMAIN}): ${r.imported}건`); }).catch((e) => console.error(` 동기화 실패(${t.DOMAIN}):`, e.message, e.providerMessage || '')); }; +// 도메인 검증 자동 재요청(1.0.11): 검증이 실패 상태로 멈춘 도메인의 Resend 레코드가 모두 보이면 검증을 다시 요청합니다(동기화와 같은 주기). +// 운영(webmail.ibit.kr)에서 DKIM 레코드 불일치로 failed 가 된 뒤 사용자가 DNS 를 고쳐도 다시 요청하지 않으면 영영 실패 상태라 발송이 막혔습니다. +const runAutoVerify = () => { for (const t of tenants.envs()) if (t.RESEND_API_KEY && usesResend(t)) autoVerifyDomain(t).then((r) => { if (r.requested) console.log(` Resend 도메인 검증 자동 요청(${t.DOMAIN}): 이전 상태 ${r.status}`); else if (r.skipped === 'records') console.warn(` Resend 도메인 검증 대기(${t.DOMAIN}): ${r.status} — ${r.problems.join(', ')}`); }).catch((e) => console.warn(` Resend 도메인 검증 확인 실패(${t.DOMAIN}):`, e.message, e.providerMessage || '')); }; +setTimeout(runAutoVerify, 20000).unref(); setInterval(runAutoVerify, SYNC_INTERVAL).unref(); +setTimeout(() => runSync('startup'), 10000).unref(); +setInterval(() => runSync('timer'), SYNC_INTERVAL).unref(); +// 만료된 로그인 세션 정리. 예전에는 같은 작업이 보관 기간(30일)이 지난 메일도 지웠지만 0.8.0 에서 그 삭제를 없앴습니다. +const runCleanup = () => { for (const t of tenants.envs()) cleanupSessions(t).catch((e) => console.error(` 세션 정리 실패(${t.DOMAIN}):`, e.message)); }; +// 대용량 첨부파일 정리(0.9.3): 기한·횟수가 끝난 파일과 버려진 초안의 파일을 1시간마다 지웁니다. +const runFileCleanup = () => { for (const t of tenants.envs()) cleanupLargeFiles(t).then((r) => { if (r.expired || r.removed) console.log(` 대용량 파일 정리(${t.DOMAIN}): 만료 ${r.expired}건, 삭제 ${r.removed}건`); }).catch((e) => console.error(` 대용량 파일 정리 실패(${t.DOMAIN}):`, e.message)); }; +setTimeout(runFileCleanup, 45000).unref(); setInterval(runFileCleanup, 3600000).unref(); +setTimeout(runCleanup, 30000).unref(); setInterval(runCleanup, 3600000).unref(); + +for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { console.log(`\n ${sig} — 종료합니다`); pop.close(); imap.close(); smtp.close(); setup.close(); server.close(() => { tenants.close(); db.close(); process.exit(0); }); setTimeout(() => process.exit(0), 3000).unref(); }); diff --git a/modules/arimail/server/mime.mjs b/modules/arimail/server/mime.mjs new file mode 100644 index 0000000..37ce0c9 --- /dev/null +++ b/modules/arimail/server/mime.mjs @@ -0,0 +1,117 @@ +/** + * RFC 5322/2045 메시지 생성. Resend 가 원본(raw)을 내려주지 못한 경우, 저장한 본문·헤더·첨부로 + * POP 클라이언트에 줄 수 있는 완전한 메시지를 만듭니다. 원본이 있으면 이 코드는 쓰지 않습니다. + */ +const CRLF = '\r\n'; +const isAscii = (s) => /^[\x20-\x7e]*$/.test(s); +export function encodeHeaderWord(value) { + const s = String(value ?? ''); + if (isAscii(s)) return s; + // RFC 2047 encoded-word, base64, split to keep each word under 75 characters. + const bytes = Buffer.from(s, 'utf8'); const words = []; const chunk = 45; + for (let i = 0; i < bytes.length; i += chunk) { + // avoid splitting inside a multi-byte sequence + let end = Math.min(bytes.length, i + chunk); + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; + words.push(`=?UTF-8?B?${bytes.subarray(i, end).toString('base64')}?=`); + i = end - chunk; + } + return words.join(CRLF + ' '); +} +export function formatAddress(value) { + const s = String(value || '').trim(); + const m = s.match(/^(.*?)\s*<([^<>]+)>$/); + if (!m) return s; + const name = m[1].replace(/^"|"$/g, '').trim(); + if (!name) return `<${m[2]}>`; + return `${isAscii(name) ? `"${name.replace(/"/g, '\\"')}"` : encodeHeaderWord(name)} <${m[2]}>`; +} +export function foldHeader(name, value) { + const line = `${name}: ${value}`; + if (line.length <= 78 || /\r\n/.test(value)) return line; + // fold at commas or spaces + const out = []; let current = ''; + for (const piece of line.split(/(?<=,)\s+|\s+/)) { + if (current && (current + ' ' + piece).length > 76) { out.push(current); current = ' ' + piece; } else current = current ? current + ' ' + piece : piece; + } + out.push(current); return out.join(CRLF); +} +export function base64Lines(buffer) { return Buffer.from(buffer).toString('base64').replace(/(.{76})/g, `$1${CRLF}`).replace(/\r\n$/, ''); } +function boundary(tag) { return `----=_ari_${tag}_${crypto.randomUUID().replace(/-/g, '')}`; } +function part(headers, body) { return headers.join(CRLF) + CRLF + CRLF + body; } +/** + * @param {object} m + * @param {string} m.from @param {string[]} m.to @param {string[]} [m.cc] @param {string[]} [m.bcc] @param {string[]} [m.replyTo] + * @param {string} m.subject @param {string|Date} [m.date] @param {string} [m.messageId] + * @param {Record} [m.headers] @param {string} [m.text] @param {string} [m.html] + * @param {{filename:string,contentType:string,bytes:Buffer,contentId?:string,disposition?:string}[]} [m.attachments] + */ +export function buildEml(m) { + const date = m.date ? new Date(m.date) : new Date(); + const headers = []; + const custom = Object.fromEntries(Object.entries(m.headers || {}).map(([k, v]) => [k.toLowerCase(), [k, v]])); + // 표준 헤더는 현재 값으로만 씁니다. 값이 비어 있어도 custom(headers_json 에 남은 옛 값)에서 지워, IMAP 으로 올린 초안의 Cc·Reply-To 를 웹에서 지운 뒤 + // 재구성한 원본에 옛 받는 사람이 되살아나지 않게 합니다(0.9.0 수정). Bcc 도 재구성 원본에는 넣지 않습니다. + const push = (name, value) => { delete custom[name.toLowerCase()]; if (value === undefined || value === null || value === '') return; headers.push(foldHeader(name, value)); }; + push('Date', date.toUTCString().replace('GMT', '+0000')); + push('From', formatAddress(m.from)); + push('To', (m.to || []).map(formatAddress).join(', ')); + push('Cc', (m.cc || []).map(formatAddress).join(', ')); + push('Reply-To', (m.replyTo || []).map(formatAddress).join(', ')); + // Bcc 는 현재 값(m.bcc, 초안의 bcc_json)으로만 씁니다. headers_json 에 남은 옛 Bcc 는 쓰지 않습니다. + push('Bcc', (m.bcc || []).map(formatAddress).join(', ')); + push('Subject', encodeHeaderWord(m.subject || '')); + // Message-ID 의 도메인은 이 게이트웨이의 메일 도메인(MAILBOX 의 @ 뒤)입니다. 다른 사이트에 독립 설치해도 남의 도메인이 찍히지 않게 합니다. + push('Message-ID', m.messageId || `<${crypto.randomUUID()}@${String(process.env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || 'arihakdang.com'}>`); + push('MIME-Version', '1.0'); + for (const [k, v] of Object.values(custom)) { + if (/^(content-|mime-version|received$|return-path$|delivered-to$)/i.test(k)) continue; + if (!/^[!-9;-~]+$/.test(k) || /[\r\n]/.test(String(v))) continue; + headers.push(foldHeader(k, isAscii(String(v)) ? String(v) : encodeHeaderWord(String(v)))); + } + const text = m.text ?? '', html = m.html ?? ''; + const textPart = part(['Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64'], base64Lines(Buffer.from(text || ' ', 'utf8'))); + const htmlPart = html ? part(['Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64'], base64Lines(Buffer.from(html, 'utf8'))) : null; + const inline = (m.attachments || []).filter((a) => a.contentId && (a.disposition || 'inline') === 'inline'); + const files = (m.attachments || []).filter((a) => !inline.includes(a)); + const attachmentPart = (a) => part([ + `Content-Type: ${a.contentType || 'application/octet-stream'}; name="${encodeHeaderWord(a.filename || 'attachment').replace(/"/g, '')}"`, + 'Content-Transfer-Encoding: base64', + `Content-Disposition: ${a.contentId && inline.includes(a) ? 'inline' : 'attachment'}; filename="${encodeHeaderWord(a.filename || 'attachment').replace(/"/g, '')}"`, + ...(a.contentId ? [`Content-ID: <${String(a.contentId).replace(/^<|>$/g, '')}>`] : []), + ], base64Lines(a.bytes)); + let body, contentType; + let alternative; + if (htmlPart) { + const b = boundary('alt'); + alternative = { contentType: `multipart/alternative; boundary="${b}"`, body: `--${b}${CRLF}${textPart}${CRLF}--${b}${CRLF}${htmlPart}${CRLF}--${b}--` }; + } else alternative = { contentType: 'text/plain; charset=UTF-8', body: base64Lines(Buffer.from(text || ' ', 'utf8')), encoding: 'base64' }; + let main = alternative; + if (inline.length) { + const b = boundary('rel'); + main = { contentType: `multipart/related; boundary="${b}"`, body: `--${b}${CRLF}${part([`Content-Type: ${alternative.contentType}`, ...(alternative.encoding ? ['Content-Transfer-Encoding: base64'] : [])], alternative.body)}${inline.map((a) => `${CRLF}--${b}${CRLF}${attachmentPart(a)}`).join('')}${CRLF}--${b}--` }; + } + if (files.length) { + const b = boundary('mix'); + contentType = `multipart/mixed; boundary="${b}"`; + body = `--${b}${CRLF}${part([`Content-Type: ${main.contentType}`, ...(main.encoding ? ['Content-Transfer-Encoding: base64'] : [])], main.body)}${files.map((a) => `${CRLF}--${b}${CRLF}${attachmentPart(a)}`).join('')}${CRLF}--${b}--`; + } else { contentType = main.contentType; body = main.body; if (main.encoding) headers.push('Content-Transfer-Encoding: base64'); } + headers.push(foldHeader('Content-Type', contentType)); + return Buffer.from(headers.join(CRLF) + CRLF + CRLF + body + CRLF, 'utf8'); +} +/** POP 전송용: CRLF 정규화와 점 스터핑(RFC 1939 §3). */ +export function normalizeCrlf(buffer) { + const s = Buffer.from(buffer).toString('latin1').replace(/\r?\n/g, '\r\n'); + return Buffer.from(s, 'latin1'); +} +export function dotStuff(buffer) { + const s = Buffer.from(buffer).toString('latin1').replace(/(^|\r\n)\./g, '$1..'); + return Buffer.from(s, 'latin1'); +} +/** 헤더와 본문을 분리합니다(TOP 명령용). */ +export function splitHeader(buffer) { + const s = Buffer.from(buffer).toString('latin1'); + const i = s.indexOf('\r\n\r\n'); + if (i < 0) return { header: Buffer.from(s, 'latin1'), body: Buffer.alloc(0) }; + return { header: Buffer.from(s.slice(0, i + 2), 'latin1'), body: Buffer.from(s.slice(i + 4), 'latin1') }; +} diff --git a/modules/arimail/server/pop3.mjs b/modules/arimail/server/pop3.mjs new file mode 100644 index 0000000..d61b484 --- /dev/null +++ b/modules/arimail/server/pop3.mjs @@ -0,0 +1,244 @@ +/** + * POP3 서버 (RFC 1939 + RFC 2449 CAPA + RFC 2595 STLS). + * + * - 계정: www.arihakdang.com 관리자 계정(login_id 또는 email + 비밀번호). 별도 비밀번호 없음. + * - 메일드롭: 받은 메일함(inbox)의 수신 메일 중 이 계정에 아직 전달하지 않은 것. + * - 중복 방지: RETR 또는 DELE 한 메일은 QUIT 시점에 pop_deliveries 에 기록되어 같은 계정에 다시 내려가지 않습니다. + * 서버의 저장본은 지우지 않으며, 웹 메일함에서 영구 삭제하기 전까지 기한 없이 계속 볼 수 있습니다. + * - 평문 포트(110): 995 는 즉시 TLS 이고, 110 은 STLS 뒤에 로그인하는 것이 원칙입니다. 다만 Gmail 의 "다른 계정에서 메일 확인" 처럼 + * STLS 를 지원하지 않는 클라이언트가 110 으로 USER/PASS 를 바로 보내므로, 관리자 설정 "평문 로그인 허용"(services.plainLogin, 기본 켬)이 + * 켜져 있으면 TLS 없이도 로그인을 받습니다(0.9.7). 꺼져 있으면 예전처럼 `-ERR [SYS/PERM] use STLS or port 995 before login` 으로 거절합니다. + * 접속마다 설정을 한 번 읽어(plainOk) CAPA 광고와 로그인 허용에 같은 값을 씁니다. + * - 세션 로그: 접속·인증·거부 외에 세션이 끝날 때 한 줄(내려준 통수·삭제 표시·걸린 시간·끝난 이유)을 남겨 "되다말다" 하는 증상을 추적할 수 있게 합니다(0.9.7). + */ +import net from 'node:net'; +import tls from 'node:tls'; +import { verifyAdmin, serviceAllowed } from './auth.mjs'; +import { serviceEnabled, plainLoginAllowed } from './limits.mjs'; +import { normalizeCrlf, dotStuff, splitHeader } from './mime.mjs'; +import { now, memoryRateStore } from './util.mjs'; + +const CRLF = '\r\n'; +/** latin1 로 읽은 줄(문자 = 바이트)을 UTF-8 문자열로 되돌립니다. */ +const utf8 = (s) => Buffer.from(s, 'latin1').toString('utf8'); +// user_id -> 그 메일함을 잠근 세션. RFC 1939 의 메일드롭 잠금: 같은 계정이 동시에 두 세션을 열면 두 번째는 [IN-USE] 로 거절합니다. +// 잠금은 세션이 끝날 때(QUIT·끊김·오류·시간 초과) 풉니다. 상대가 FIN 없이 사라진 소켓은 10분 유휴 시간 초과로 정리됩니다. +const locks = new Map(); +const rate = memoryRateStore(); +/** 잠금을 든 세션이 아직 살아 있는지. closed 표시가 없어도 소켓이 이미 파기됐으면(오류 뒤 close 이벤트 지연 등) 잠금을 비어 있는 것으로 봅니다. */ +const lockHeld = (userId) => { const s = locks.get(userId); return !!s && !s.closed && !s.socket?.destroyed; }; + +export async function maildrop(env, userId, mailbox = null) { + mailbox = mailbox || String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase(); + const rows = (await env.DB.prepare(`SELECT m.id, m.raw_size FROM messages m WHERE m.mailbox=? AND m.direction='inbound' AND m.folder='inbox' AND m.raw_status IN ('stored','built') + AND NOT EXISTS (SELECT 1 FROM pop_deliveries d WHERE d.message_id=m.id AND d.user_id=?) ORDER BY m.received_at ASC, m.id ASC LIMIT 5000`).bind(mailbox, userId).all()).results; + return rows.map((r) => ({ id: r.id, size: Number(r.raw_size) || 0 })); +} +async function loadEml(env, id) { + const row = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first(); + return row ? normalizeCrlf(Buffer.from(row.eml)) : null; +} +/** + * QUIT 시점에 이 세션이 내려준(RETR)·삭제 표시한(DELE) 메일을 pop_deliveries 에 기록합니다. + * 세션 중에 웹(영구 삭제)이나 IMAP(휴지통 비우기)으로 이미 지워진 메일은 건너뜁니다(WHERE EXISTS). 예전에는 그런 행 하나가 외래 키 위반을 일으켜 + * 묶음 전체가 되돌아갔고, QUIT 이 -ERR 로 끝나며 그 세션의 다른 메일도 전부 기록되지 않아 다음 접속에 다시 내려갔습니다(0.9.7). + */ +export async function commitDeliveries(env, userId, entries) { + if (!entries.length) return; + const stamp = now(); + await env.DB.batch(entries.map(([id, via]) => env.DB.prepare('INSERT OR REPLACE INTO pop_deliveries(user_id,message_id,delivered_at,via) SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM messages WHERE id=?)').bind(userId, id, stamp, via, id))); +} + +class Session { + constructor(server, socket, { secure }) { + this.server = server; this.env = server.env; this.secure = secure; this.state = 'auth'; this.user = null; this.pendingUser = ''; + this.messages = []; this.deleted = new Set(); this.retrieved = new Set(); this.failures = 0; this.buffer = ''; this.closed = false; + this.startedAt = Date.now(); this.endReason = 'close'; this.summarized = false; + // plainOk: 이 접속에서 TLS 없이 로그인을 받을지. TLS 접속이면 항상 참. 평문 접속은 서버 옵션(allowPlaintext 가 true/false 로 고정) 또는 + // 관리자 설정(plainLoginAllowed)을 따릅니다. 설정 읽기는 비동기라 명령 큐의 첫 작업으로 두어, 첫 명령(CAPA/USER)이 처리될 때는 값이 정해져 있습니다. + this.plainOk = secure || server.allowPlaintext === true; + this.queue = this.loadPlainLogin(); + this.ip = socket.remoteAddress || ''; this.attach(socket); + console.log(`POP3 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`); + this.write(`+OK ${server.hostname} POP3 ready`); + } + async loadPlainLogin() { + if (this.secure || typeof this.server.allowPlaintext === 'boolean') return; + try { this.plainOk = await plainLoginAllowed(this.env); } catch (e) { console.error('POP3 평문 로그인 설정 읽기 실패', e?.message); this.plainOk = false; } + } + attach(socket) { + this.socket = socket; socket.setTimeout(10 * 60 * 1000); + socket.on('data', (chunk) => this.onData(chunk)); + socket.on('timeout', () => { this.endReason = 'timeout'; this.close('-ERR timeout'); }); + socket.on('error', (e) => { if (this.endReason === 'close') this.endReason = 'error:' + (e?.code || e?.message || 'unknown'); this.finish(); }); + socket.on('close', () => this.finish()); + } + write(line) { if (!this.closed) this.socket.write(line + CRLF); } + close(line) { if (line) this.write(line); this.closed = true; try { this.socket.end(); } catch {} this.finish(); } + /** 세션 종료 공통 처리: 잠금 해제 + 요약 로그 한 줄(로그인한 세션만, 한 번만). close·error·close 이벤트가 겹쳐 불려도 안전합니다. */ + finish() { + if (this.user && locks.get(this.user.id) === this) locks.delete(this.user.id); + this.closed = true; + if (this.user && !this.summarized) { + this.summarized = true; + const secs = ((Date.now() - this.startedAt) / 1000).toFixed(1); + console.log(`POP3 종료 ${this.ip} user=${this.user.login} 메일 ${this.messages.length}통 중 RETR ${this.retrieved.size}·DELE ${this.deleted.size} ${secs}s (${this.endReason})`); + } + } + onData(chunk) { + // STLS 를 받은 뒤 TLS 전환 전까지 평문으로 오는 바이트는 버립니다(아래 설명). + if (this.upgrading) return; + this.buffer += chunk.toString('latin1'); + let i; + while ((i = this.buffer.indexOf('\n')) >= 0) { + const line = this.buffer.slice(0, i).replace(/\r$/, ''); this.buffer = this.buffer.slice(i + 1); + if (line.length > 4096) { this.close('-ERR line too long'); return; } + this.queue = this.queue.then(() => this.handle(line)).catch((e) => { console.error('POP3 오류', e?.message); this.write('-ERR internal error'); }); + // STLS 명령 주입 방지(CVE-2011-0411 유형): 같은 평문 묶음에서 STLS 뒤에 붙어 온 명령은 TLS 전환 뒤에 암호화된 명령처럼 + // 실행될 수 있으므로 버립니다. 클라이언트는 STLS 응답을 받은 뒤에만 다음 명령을 보내야 합니다(RFC 2595). + if (/^\s*STLS\s*$/i.test(line)) { this.upgrading = true; this.buffer = ''; return; } + } + // 줄 길이 검사는 줄 단위로 합니다. 예전에는 쌓인 버퍼 전체가 4KB 를 넘으면 끊어, 파이프라인으로 여러 명령을 보내는 클라이언트가 끊겼습니다. + if (this.buffer.length > 4096) this.close('-ERR line too long'); + } + async handle(line) { + if (this.closed) return; + const [cmd, ...rest] = line.trim().split(/\s+/); const command = (cmd || '').toUpperCase(); const args = rest; + if (command === 'QUIT') return this.quit(); + if (command === 'CAPA') return this.capa(); + if (command === 'NOOP') return this.write('+OK'); + if (this.state === 'auth') return this.authCommand(command, args, line); + return this.transactionCommand(command, args); + } + capa() { + const caps = ['TOP', 'UIDL', 'RESP-CODES', 'PIPELINING', 'IMPLEMENTATION arihakdang-mail-gateway']; + if (this.plainOk) caps.push('USER', 'SASL PLAIN'); // 평문 접속에서 로그인을 안 받으면 USER 를 광고하지 않습니다(RFC 2449) + if (!this.secure && this.server.secureContext) caps.push('STLS'); + this.write('+OK Capability list follows'); for (const c of caps) this.write(c); this.write('.'); + } + async authCommand(command, args, line) { + const loginAllowed = this.plainOk; // TLS 접속이면 항상 참, 평문 접속이면 관리자 설정(또는 서버 옵션) + if (command === 'STLS') { + this.upgrading = false; // 아래에서 거절하면 평문 입력을 다시 받습니다. 전환에 성공하면 새 TLS 소켓으로 받습니다. + if (this.secure) return this.write('-ERR already using TLS'); + if (!this.server.secureContext) return this.write('-ERR TLS not available'); + this.write('+OK Begin TLS negotiation'); + const plain = this.socket; plain.removeAllListeners('data'); plain.removeAllListeners('timeout'); plain.removeAllListeners('error'); plain.removeAllListeners('close'); + const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext, SNICallback: (n, cb) => cb(null, this.server.secureContext) }); + this.secure = true; this.plainOk = true; this.buffer = ''; this.attach(secureSocket); return; + } + if (!loginAllowed && ['USER', 'PASS', 'AUTH', 'APOP'].includes(command)) { console.log(`POP3 평문 로그인 거부 ${this.ip} (STLS 필요)`); return this.write('-ERR [SYS/PERM] use STLS or port 995 before login'); } + // USER/PASS 인자는 줄을 latin1 로 읽었으므로 바이트 그대로입니다. 한글 등 비 ASCII 비밀번호는 UTF-8 로 되돌려야 웹·IMAP·AUTH PLAIN 과 같은 값이 됩니다(0.9.7). + // 예전에는 latin1 문자열을 그대로 검사해 비 ASCII 비밀번호가 POP USER/PASS 에서만 항상 틀렸습니다. + if (command === 'USER') { this.pendingUser = utf8(args.join(' ')); return this.write('+OK name accepted'); } + if (command === 'PASS') { const password = utf8(line.trim().slice(5)); return this.login(this.pendingUser, password); } + if (command === 'AUTH') { + if ((args[0] || '').toUpperCase() !== 'PLAIN') return this.write('-ERR unsupported mechanism'); + let payload = args[1]; + if (!payload) { this.write('+ '); this.awaitingSasl = true; return; } + return this.saslPlain(payload); + } + if (this.awaitingSasl) { this.awaitingSasl = false; if (line.trim() === '*') return this.write('-ERR cancelled'); return this.saslPlain(line.trim()); } + return this.write('-ERR unknown command'); + } + saslPlain(payload) { + let parts; try { parts = Buffer.from(payload, 'base64').toString('utf8').split('\0'); } catch { return this.write('-ERR invalid'); } + if (parts.length < 3) return this.write('-ERR invalid'); + return this.login(parts[1], parts[2]); + } + async login(login, password) { + if (rate.blocked('pop:' + this.ip, { limit: 20 })) return this.close('-ERR [AUTH] too many attempts'); + // 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 모르는·꺼 둔 도메인이면 인증 실패와 같게 처리합니다. + const env = this.server.resolveEnv ? this.server.resolveEnv(login, sniName(this.socket)) : this.env; + const user = env ? await verifyAdmin(env, login, password) : null; + if (user) this.env = env; + if (!user) rate.hit('pop:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다(성공한 접속은 제한에 들지 않음) + console.log(`POP3 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login).slice(0, 80)}${this.secure ? '' : ' (plain)'}`); + if (!user) { + this.failures++; await new Promise((r) => setTimeout(r, this.env.POP_AUTH_DELAY_MS ?? 1500)); + if (this.failures >= 3) return this.close('-ERR [AUTH] authentication failed'); + return this.write('-ERR [AUTH] authentication failed'); + } + // 관리자가 POP 서비스 자체를 꺼 두었으면(0.9.0, 관리자 페이지 설정) 모든 계정의 접속을 거부합니다. + if (!(await serviceEnabled(this.env, 'pop'))) { console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (관리자가 POP 서비스를 껐음)`); return this.close('-ERR [SYS/PERM] POP service is disabled by the administrator'); } + // 계정 설정에서 POP 접속을 꺼 두었으면 비밀번호가 맞아도 거부합니다(0.8.4~). 비밀번호 오류와 구분되는 문구를 돌려줍니다. + if (!(await serviceAllowed(this.env, user, 'pop'))) { console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (계정 설정에서 POP 사용 안 함)`); return this.close('-ERR [SYS/PERM] POP access is disabled for this account'); } + // 같은 계정의 다른 세션이 아직 열려 있으면 거절합니다. 예전에는 로그가 없어 "비밀번호는 맞는데 가끔 실패" 로 보였습니다(0.9.7 부터 기록). + if (lockHeld(user.id)) { const other = locks.get(user.id); console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (다른 세션 ${other.ip} 이 ${Math.round((Date.now() - other.startedAt) / 1000)}s 전부터 메일함 사용 중)`); return this.write('-ERR [IN-USE] mailbox is locked by another session'); } + locks.set(user.id, this); this.user = user; this.state = 'transaction'; + this.messages = await maildrop(this.env, user.id, user.mailbox); + this.write(`+OK maildrop has ${this.messages.length} messages (${this.total()} octets)`); + } + total() { return this.messages.reduce((n, m, i) => this.deleted.has(i) ? n : n + m.size, 0); } + index(arg) { const n = Number(arg); if (!Number.isInteger(n) || n < 1 || n > this.messages.length || this.deleted.has(n - 1)) return -1; return n - 1; } + async transactionCommand(command, args) { + const live = () => this.messages.length - this.deleted.size; + if (command === 'STAT') return this.write(`+OK ${live()} ${this.total()}`); + if (command === 'LIST' || command === 'UIDL') { + const value = (m) => command === 'LIST' ? m.size : m.id; + if (args[0] !== undefined) { const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message'); return this.write(`+OK ${i + 1} ${value(this.messages[i])}`); } + this.write(`+OK ${live()} messages (${this.total()} octets)`); + this.messages.forEach((m, i) => { if (!this.deleted.has(i)) this.write(`${i + 1} ${value(m)}`); }); return this.write('.'); + } + if (command === 'RETR' || command === 'TOP') { + const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message'); + const eml = await loadEml(this.env, this.messages[i].id); if (!eml) return this.write('-ERR message unavailable'); + let out = eml; + if (command === 'TOP') { const lines = Number(args[1]); if (!Number.isInteger(lines) || lines < 0) return this.write('-ERR bad argument'); const { header, body } = splitHeader(eml); const bodyLines = body.toString('latin1').split(CRLF).slice(0, lines).join(CRLF); out = Buffer.concat([header, Buffer.from(CRLF + bodyLines, 'latin1')]); } + const data = dotStuff(out); + this.write(`+OK ${data.length} octets`); + if (!this.closed) this.socket.write(data); + if (!data.length || !data.subarray(-2).equals(Buffer.from(CRLF))) this.write(''); + this.write('.'); + if (command === 'RETR') this.retrieved.add(i); + return; + } + if (command === 'DELE') { const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message'); this.deleted.add(i); return this.write(`+OK message ${i + 1} deleted`); } + if (command === 'RSET') { this.deleted.clear(); return this.write(`+OK maildrop has ${this.messages.length} messages (${this.total()} octets)`); } + if (command === 'LAST') { return this.write(`+OK ${Math.max(0, ...[...this.retrieved].map((i) => i + 1))}`); } + return this.write('-ERR unknown command'); + } + async quit() { + this.endReason = 'quit'; + if (this.state === 'transaction' && this.user) { + const entries = []; + for (const i of new Set([...this.retrieved, ...this.deleted])) entries.push([this.messages[i].id, this.deleted.has(i) ? 'dele' : 'retr']); + try { await commitDeliveries(this.env, this.user.id, entries); } catch (e) { console.error('POP 전달 기록 실패', e.message); return this.close('-ERR some deleted messages not removed'); } + } + this.close(`+OK ${this.server.hostname} signing off`); + } +} + +/** + * @param {{key:string|Buffer,cert:string|Buffer}|null} tlsOptions 인증서 PEM. 갱신 시 setTls 로 교체합니다. + * @param {boolean|null} allowPlaintext 평문 포트의 TLS 없는 로그인: true 면 항상 허용, false 면 항상 거부(테스트·진단), null(기본)이면 + * 접속마다 관리자 설정 "평문 로그인 허용" 을 읽습니다(0.9.7). index.mjs 는 POP_ALLOW_PLAINTEXT=1 일 때만 true 를, 그 밖에는 null 을 넘깁니다. + */ +/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 도메인 없이 로그인한 사용자가 어느 도메인의 사용자인지 짐작하는 데 씁니다(1.0.7). */ +const sniName = (socket) => String(socket?.servername || ''); +/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작(단일 도메인). */ +export function createPop3(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = null, resolveEnv = null } = {}) { + const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv }; + const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); }); + // secure 서버는 인증서가 없어도 만들어 둡니다(핸드셰이크는 setSecureContext 뒤에만 성공). 인증서가 없으면 포트를 열지 않고 기억만 합니다(아래 pendingSecure). + const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); }); + secure.on('tlsClientError', () => {}); + let pendingSecure = null; + // 0.9.7: 인증서가 시작 때 없거나 읽히지 않았으면 TLS 포트를 바로 열 수 없으므로 포트를 기억해 두고(pendingSecure), 시간마다 돌아오는 setTls 가 + // 인증서를 넣을 때 그 포트를 엽니다. 예전에는 secure 서버를 만들지 않아 setTls 가 아무 일도 하지 않았고, 재시작 전까지 TLS 포트가 닫힌 채였습니다. + const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); })); + return { + plain, secure, + setTls(options) { + shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options); + if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'pop3s', []).then(() => console.log(` POP3 TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' POP3 TLS 포트 열기 실패:', e.message)); } + }, + async listen({ plainPort, securePort, host = '0.0.0.0' }) { + const out = []; + if (plainPort) await listenOn(plain, plainPort, host, 'pop3', out); + if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'pop3s', out); else pendingSecure = { port: securePort, host }; } + return out; + }, + close() { plain.close(); if (secure.listening) secure.close(); }, + }; +} diff --git a/modules/arimail/server/providers/index.mjs b/modules/arimail/server/providers/index.mjs new file mode 100644 index 0000000..82501d0 --- /dev/null +++ b/modules/arimail/server/providers/index.mjs @@ -0,0 +1,69 @@ +/** + * 메일 서비스 모듈 선택(1.0.0, 요구사항 2) — 발송·수신 "길" 을 설정(config.json 의 inbound / outbound)으로 고릅니다. + * + * 발신(outbound) + * resend : Resend REST API(providers/resend.mjs). 예전과 같은 기본값. + * haraka : 내장 Haraka 의 로컬 제출 포트(127.0.0.1:HARAKA_RELAY_PORT, 암호화·인증 없음)로 넘기면 Haraka 가 받는 쪽 MX 로 직접 배달합니다. + * smtp : 외부 SMTP 중계 서버(SMTP_RELAY_*)로 넘깁니다(회사 메일 서버, 다른 발송 서비스의 SMTP 등). + * 수신(inbound) + * resend : Resend 웹훅 + 목록 동기화(store.mjs). + * haraka : 내장 Haraka 가 25 포트로 받은 메일을 로컬 HTTP(/api/inbound/haraka)로 넘겨 줍니다(store.mjs ingestRaw). + * + * 발송 호출 지점은 모두 sendMail(env, payload, key) 하나입니다. payload 는 Resend 요청과 같은 모양(from, to, cc, bcc, reply_to, subject, + * text, html, headers, attachments[{filename, content(base64), content_type, content_id}])이고 돌려주는 값은 { id } 입니다. + * 새 발송 서비스를 더하려면 providers/<이름>.mjs 에 같은 모양으로 send 를 만들고 여기 outboundMode·sendMail·outboundStatus 에 한 줄씩 더합니다. + */ +import { resend, providerStatus as resendStatus } from './resend.mjs'; +import { sendViaSmtp } from './smtp.mjs'; +import { smtpProbe } from './smtp-client.mjs'; +import { now } from '../util.mjs'; + +export const OUTBOUND_MODES = ['resend', 'haraka', 'smtp'], INBOUND_MODES = ['resend', 'haraka']; +/** 설정된 발신 방식(알 수 없는 값은 resend). env.MAIL_OUTBOUND 는 config.mjs 의 applyConfig 가 채웁니다. */ +export const outboundMode = (env) => (OUTBOUND_MODES.includes(env.MAIL_OUTBOUND) ? env.MAIL_OUTBOUND : 'resend'); +/** 설정된 수신 방식(알 수 없는 값은 resend). */ +export const inboundMode = (env) => (INBOUND_MODES.includes(env.MAIL_INBOUND) ? env.MAIL_INBOUND : 'resend'); +/** Resend 를 어느 쪽으로든 쓰는지(동기화·웹훅·도메인 상태 확인이 의미 있는지). */ +export const usesResend = (env) => inboundMode(env) === 'resend' || outboundMode(env) === 'resend'; +/** 내장 Haraka 를 어느 쪽으로든 쓰는지(haraka/run.mjs 가 Haraka 를 띄울지). */ +export const usesHaraka = (env) => inboundMode(env) === 'haraka' || outboundMode(env) === 'haraka'; + +/** 발신 방식에 따른 중계 서버 접속 정보. haraka 는 로컬 고정, smtp 는 SMTP_RELAY_* 설정. */ +export function relaySettings(env, mode = outboundMode(env)) { + // haraka: 서버에 하나뿐인 Haraka 의 제출 포트는 기본 도메인의 설정(HARAKA_RELAY_PORT)이 정합니다(1.0.7: 추가 도메인도 같은 포트로 넘김). + if (mode === 'haraka') { const primary = env.TENANTS?.primary?.env || env; return { host: '127.0.0.1', port: Number(primary.HARAKA_RELAY_PORT || 2525), security: 'none', user: '', pass: '' }; } + if (mode === 'smtp') return { host: String(env.SMTP_RELAY_HOST || '').trim(), port: Number(env.SMTP_RELAY_PORT || 587), security: ['none', 'starttls', 'tls'].includes(env.SMTP_RELAY_SECURITY) ? env.SMTP_RELAY_SECURITY : 'starttls', user: String(env.SMTP_RELAY_USER || ''), pass: String(env.SMTP_RELAY_PASS || ''), rejectUnauthorized: env.SMTP_RELAY_INSECURE !== '1' }; + return null; +} + +/** + * 메일 한 통 발송. 어느 모듈을 쓰든 { id } 를 돌려주고, 실패는 공통 오류 코드(mail_not_configured / mail_auth_failed / mail_rejected / + * mail_rate_limited / mail_provider_unavailable)의 Error 로 던집니다(providerMessage 에 서비스 응답). + * @param {string} [key] 멱등 키(Resend 만 씀) + */ +export async function sendMail(env, payload, key) { + const mode = outboundMode(env); + if (mode === 'resend') return resend(env, '/emails', 'POST', payload, key); + return sendViaSmtp(env, payload, relaySettings(env, mode)); +} + +// 상태 캐시는 env(= DB) 마다 따로 둡니다(시험처럼 한 프로세스에 여러 env 가 있을 때 섞이지 않게). 운영은 env 가 하나라 전역 캐시와 같습니다. +const statusCaches = new WeakMap(); +/** + * 발신 서비스 상태(설정 화면·상태 화면). resend 는 도메인 검증 상태까지, haraka/smtp 는 중계 서버 접속·EHLO(·AUTH) 확인입니다. + * 60초 캐시(force 로 즉시). 돌려주는 모양은 예전 providerStatus 와 같고 mode 와 relay 를 더합니다. + * @returns {Promise<{mode:string, provider:string, detail?:string, domain?:object, relay?:object, checkedAt:string}>} + */ +export async function outboundStatus(env, { force = false } = {}) { + const mode = outboundMode(env), key = mode + JSON.stringify(relaySettings(env, mode) || {}) + (env.RESEND_API_KEY ? 'k' : ''); + const statusCache = statusCaches.get(env.DB || env) || { at: 0, key: '', value: null }; + if (!force && statusCache.value && statusCache.key === key && Date.now() - statusCache.at < 60000) return statusCache.value; + let value; + if (mode === 'resend') value = { mode, ...(await resendStatus(env, { force })) }; + else { + const relay = relaySettings(env, mode); + const probe = await smtpProbe({ ...relay, hostname: env.PUBLIC_HOST || 'localhost' }); + value = { mode, provider: probe.ok ? 'ok' : probe.error, detail: probe.detail, relay: { host: relay.host, port: relay.port, security: relay.security, auth: !!relay.user }, checkedAt: now() }; + } + statusCaches.set(env.DB || env, { at: Date.now(), key, value }); return value; +} diff --git a/modules/arimail/server/providers/resend.mjs b/modules/arimail/server/providers/resend.mjs new file mode 100644 index 0000000..c8be002 --- /dev/null +++ b/modules/arimail/server/providers/resend.mjs @@ -0,0 +1,223 @@ +/** + * Resend 발송·수신 서비스 모듈(providers/resend.mjs). 1.0.0 에서 server/resend.mjs 를 이 디렉터리로 옮겼습니다(요구사항 2: 발송 모듈화). + * 하는 일: Resend REST API 호출(직렬화·간격·오류 코드), 서명된 다운로드 주소에서 원본·첨부 받기, 도메인 상태 확인. + * 발송 자체는 providers/index.mjs 의 sendMail() 이 설정(outbound)에 따라 이 모듈 또는 SMTP 중계 모듈을 고릅니다. + * www 사이트(server/mailbox.mjs)와 같은 직렬화·간격·오류 코드 규칙을 씁니다. + */ +import { now } from '../util.mjs'; +import { lookup } from '../dns/common.mjs'; +let requestQueue = Promise.resolve(), nextRequestAt = 0; +/** + * Resend 응답 상태 코드 → 공통 오류 코드. + * 1.0.11: 403 이라도 응답 문구가 "도메인이 검증되지 않았다"(The domain is not verified…)이면 mail_forbidden(키 권한 문제로 읽힘)이 아니라 + * mail_domain_unverified 로 구분합니다. 운영에서 "Resend 가 요청을 거부했습니다 … API 키 권한을 확인" 문구가 떴지만 실제 원인은 DNS 레코드(DKIM) + * 불일치로 도메인 검증이 실패한 것이어서, 사용자가 엉뚱한 곳(키 권한)을 살피게 했습니다. + * @param {number} status HTTP 상태 + * @param {string} [message] Resend 응답의 message(있으면 403 의 원인 구분에 씀) + */ +export function providerError(status, message = '') { + if (status === 403 && /not verified|verify your domain/i.test(String(message || ''))) return 'mail_domain_unverified'; + return status === 401 ? 'mail_auth_failed' : status === 403 ? 'mail_forbidden' : status === 429 ? 'mail_rate_limited' + : status === 400 || status === 422 ? 'mail_rejected' : status === 404 ? 'not_found' : 'mail_provider_unavailable'; +} +export async function resend(env, path, method = 'GET', body, key) { + if (!env.RESEND_API_KEY) throw new Error('mail_not_configured'); + const execute = async () => { + if (!env.RESEND_FETCH) { await new Promise((r) => setTimeout(r, Math.max(0, nextRequestAt - Date.now()))); nextRequestAt = Date.now() + 600; } + let r; + // 연결 단계 오류(ECONNRESET·연결 시간 초과·DNS)는 한 번 더 시도합니다(1.0.9). 운영 기록에 9일 동안 두 번, 모두 다음 주기에는 정상이던 일시 오류였습니다. + // 응답을 받은 뒤의 오류(4xx·5xx)는 재시도하지 않습니다(발송 POST 는 Idempotency-Key 가 있어 재시도해도 두 번 보내지지 않지만, 거부된 요청을 되풀이할 이유가 없음). + const attempt = () => (env.RESEND_FETCH || fetch)((env.RESEND_API_BASE || 'https://api.resend.com') + path, { + method, headers: { Authorization: 'Bearer ' + env.RESEND_API_KEY, 'Content-Type': 'application/json', ...(key ? { 'Idempotency-Key': key } : {}) }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000), + }); + // 재시도 조건: 연결 자체가 안 된 오류 코드(요청이 서버에 닿지 않았음이 확실)이고, 조회(GET)이거나 멱등 키가 있는 발송일 때만. 응답 대기 시간 초과(TimeoutError)는 + // 요청이 이미 처리됐을 수 있고(웹훅·도메인 만들기 같은 관리 POST 가 두 번 될 수 있음) 재시도하지 않습니다. + const RETRY_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'EPIPE', 'ETIMEDOUT']); + const retryable = (e) => RETRY_CODES.has(String(e?.cause?.code || e?.code || '')) && (method === 'GET' || !!key); + try { r = await attempt(); } + catch (first) { + const reason = String(first?.cause?.code || first?.name || first?.message || 'network').slice(0, 120); + if (!retryable(first)) { const error = new Error('mail_provider_unavailable'); error.providerMessage = reason; console.error('Resend 연결 실패', method, path, reason); throw error; } + console.warn('Resend 연결 실패 — 2초 뒤 한 번 더 시도', method, path, reason); + await new Promise((res) => setTimeout(res, env.RESEND_RETRY_MS ?? 2000)); + try { r = await attempt(); } + catch (e) { + const error = new Error('mail_provider_unavailable'); error.providerMessage = String(e?.cause?.code || e?.name || e?.message || 'network').slice(0, 120); + console.error('Resend 연결 실패', method, path, error.providerMessage); throw error; + } + } + const data = await r.json().catch(() => ({})); + if (!r.ok) { + const error = new Error(providerError(r.status, data.message)); error.providerStatus = r.status; error.providerMessage = String(data.message || data.name || '').slice(0, 300); + console.error('Resend 실패', method, path, r.status, error.providerMessage); throw error; + } + return data; + }; + const result = requestQueue.then(execute, execute); requestQueue = result.catch(() => {}); return result; +} +/** 서명된 다운로드 주소(원본·첨부)에서 바이트를 받습니다. 크기 상한을 넘기면 중단합니다. */ +export async function download(env, url, { maxBytes = 40 * 1024 * 1024 } = {}) { + const u = new URL(url); + if (u.protocol !== 'https:') throw new Error('invalid_download'); + const r = await (env.DOWNLOAD_FETCH || env.RESEND_FETCH || fetch)(u, { signal: AbortSignal.timeout(120000) }); + if (!r.ok) { const e = new Error('download_failed'); e.providerStatus = r.status; throw e; } + const length = Number(r.headers.get('content-length') || 0); + if (length > maxBytes) throw new Error('download_too_large'); + const chunks = []; let size = 0; + for await (const chunk of r.body) { size += chunk.length; if (size > maxBytes) throw new Error('download_too_large'); chunks.push(Buffer.from(chunk)); } + return Buffer.concat(chunks); +} +// ------------------------------------------------------------------ 도메인 검증 상태 진단(1.0.11) +/** Resend 도메인 상태 값을 사람이 읽는 말로(화면 문구는 서버가 한 번만 만듭니다). */ +export const DOMAIN_STATUS_WORD = { not_started: '검증을 시작하지 않음', pending: '검증 진행 중', verified: '검증 완료', failed: '검증 실패', temporary_failure: '일시 실패' }; +/** + * 긴 값(DKIM 공개키 등)을 화면 문구용으로 줄입니다. 비교 상대(other)가 있으면 둘이 같은 앞부분은 건너뛰고 **달라지는 자리**부터 보여 줍니다 — + * RSA 공개키는 앞 30자쯤("p=MIGfMA0GCSqGSIb3DQEBAQUAA4…")이 늘 같아 앞만 자르면 두 값이 똑같아 보였습니다(종단 시험에서 확인). + */ +export function short(v, other = null, n = 28) { + const s = String(v ?? ''); if (s.length <= n) return s; + const o = other === null ? null : String(other); + let cp = 0; if (o !== null) while (cp < s.length && cp < o.length && s[cp] === o[cp]) cp++; + if (o === null || cp < 12) return s.slice(0, n) + '…'; + const from = Math.max(0, cp - 4); + return `${s.slice(0, 8)}…${s.slice(from, from + 20)}${from + 20 < s.length ? '…' : ''}`; +} + +/** + * Resend 가 요구하는 DNS 레코드가 실제로 세상에 보이는지 레코드마다 확인합니다(공개 리졸버 8.8.8.8/1.1.1.1, dns/common.mjs lookup). + * 운영에서 겪은 문제를 정확히 가리키기 위한 것입니다: "검증 실패" 만으로는 어느 레코드가 빠졌는지, 값이 다른지 알 수 없어 사용자가 API 키 권한을 의심했습니다. + * + * 판정 규칙(레코드마다) + * - ok: 우리 조회로 값이 맞거나, Resend 자신이 verified 라고 하는 레코드(Resend 가 받아들인 값은 그대로 두어야 하므로 — 예: 예전 MX 값 + * inbound-smtp.<지역>.amazonaws.com 을 Resend 가 새 값 receive-*.forge.rmta.net 과 같은 것으로 인정하는 경우). + * - problem: 'missing'(조회 결과 없음 — 아직 안 넣었거나 전파 대기) / 'wrong'(다른 값이 보임 — 다른 Resend 등록·계정의 값이거나 오타) / null + * @param {object} env 테넌트 환경(env.DNS_RESOLVER 가 있으면 그 리졸버로 — 시험용) + * @param {object} domain Resend 도메인 상세(records 포함, getDomain 응답) + * @returns {Promise<{ok:boolean, records:Array<{record:string,type:string,name:string,value:string,priority?:number,resendStatus:string,ok:boolean,found:string[],problem:string|null}>}>} + */ +export async function checkResendRecords(env, domain, { resolver = env.DNS_RESOLVER || null } = {}) { + const records = []; + for (const r of resendDnsRecords(domain)) { + const check = await lookup(r, { resolver }); + const byResend = String(r.status || '') === 'verified'; + const ok = check.ok || byResend; + records.push({ record: r.purpose.replace(/^Resend /, ''), type: r.type, name: r.name, value: r.values[0], ...(r.priority !== undefined ? { priority: r.priority } : {}), resendStatus: String(r.status || 'not_started'), ok, found: check.found || [], problem: ok ? null : check.found && check.found.length ? 'wrong' : 'missing' }); + } + return { ok: records.every((r) => r.ok), records }; +} + +/** + * 검증되지 않은 도메인의 원인을 한 문장으로(상태 화면·목록 상단 표시·설정 화면 점검표에 그대로 보임). + * 예: "검증 실패 — 값이 다른 레코드: TXT resend._domainkey.ibit.kr (지금 p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN… / 필요 p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN…). …" + */ +export function describeUnverified(domain, check) { + const word = DOMAIN_STATUS_WORD[String(domain?.status || '')] || String(domain?.status || '알 수 없음'); + const wrong = check.records.filter((r) => r.problem === 'wrong'), missing = check.records.filter((r) => r.problem === 'missing'); + const parts = []; + if (wrong.length) parts.push('값이 다른 레코드: ' + wrong.map((r) => `${r.type} ${r.name} (지금 ${short(r.found[0], r.value)} / 필요 ${short(r.value, r.found[0])})`).join(', ')); + if (missing.length) parts.push('없는 레코드: ' + missing.map((r) => `${r.type} ${r.name}`).join(', ')); + if (!parts.length) parts.push(domain?.status === 'pending' ? 'DNS 레코드는 모두 보입니다. Resend 가 확인을 끝내기를 기다리는 중입니다(보통 몇 분)' : 'DNS 레코드는 모두 보입니다. 검증을 다시 요청하면 됩니다(1시간마다 자동으로 요청)'); + const hint = wrong.length ? ' 다른 Resend 계정·예전 등록의 값일 수 있습니다. 관리자 › 메일 서비스 설정 › 자동 설정의 표에 있는 값으로 바꾸면 1시간 안에 자동으로 다시 검증합니다.' : ''; + return `${word} — ${parts.join('; ')}.${hint}`; +} + +// 도메인 상태 캐시(60초). env 마다 따로 둡니다(시험에서 여러 env 가 섞이지 않게; 운영은 env 하나). +const statusCaches = new WeakMap(); +/** 상태 캐시 비우기(검증 요청 직후처럼 다음 조회가 새 값을 보여야 할 때). */ +export const clearStatusCache = (env) => statusCaches.delete(env.DB || env); +/** + * 발신 도메인의 Resend 상태. + * ok 도메인이 등록되어 있고 검증 완료 + * mail_domain_missing Resend 계정에 이 도메인이 없음(1.0.11 — 예전에는 이 경우를 'ok' 로 보고해 "정상" 으로 보였음) + * mail_domain_unverified 등록은 됐지만 검증이 끝나지 않음(not_started/pending/failed/temporary_failure). records 에 레코드별 진단, detail 에 원인 문장 + * 그 밖(mail_auth_failed 등) Resend 호출 자체의 오류 코드 + * 60초 캐시(force 로 즉시). 검증되지 않은 경우에만 도메인 상세(레코드)와 공개 DNS 조회를 더 합니다(정상일 때는 호출 하나). + */ +export async function providerStatus(env, { force = false } = {}) { + const statusCache = statusCaches.get(env.DB || env) || { at: 0, value: null }; + if (!force && statusCache.value && Date.now() - statusCache.at < 60000) return statusCache.value; + let value; + const domainName = String(env.MAILBOX || '').split('@')[1] || ''; + if (!env.RESEND_API_KEY) value = { provider: 'mail_not_configured' }; + else try { + const domains = await resend(env, '/domains'); + const domain = (domains.data || []).find((d) => String(d.name || '').toLowerCase() === domainName); + if (!domain) value = { provider: 'mail_domain_missing', domain: null, detail: `Resend 계정에 도메인 ${domainName} 이 등록되어 있지 않습니다. 관리자 › 메일 서비스 설정에서 "자동 설정 실행" 을 누르면 등록합니다.` }; + else if (domain.status !== 'verified') { + let detail = DOMAIN_STATUS_WORD[domain.status] || String(domain.status), records = []; + // 레코드별 진단은 보조 정보라 실패해도(일시적 DNS 오류 등) 상태 자체는 보고합니다. + try { const full = await resend(env, '/domains/' + encodeURIComponent(domain.id)); const check = await checkResendRecords(env, full); detail = describeUnverified(full, check); records = check.records; } catch (e) { detail += ` — 레코드 진단 실패(${e.providerMessage || e.message})`; } + value = { provider: 'mail_domain_unverified', domain: { name: domain.name, status: domain.status, id: domain.id, region: domain.region }, records, detail }; + } else value = { provider: 'ok', domain: { name: domain.name, status: domain.status } }; + } catch (e) { value = { provider: e.message, detail: e.providerMessage, providerStatus: e.providerStatus }; } + value.checkedAt = now(); statusCaches.set(env.DB || env, { at: Date.now(), value }); return value; +} + +/** + * 자동 재검증(1.0.11): 도메인 검증이 실패(failed/temporary_failure)했거나 시작되지 않았는데 Resend 가 요구하는 레코드가 이제 모두 보이면 검증을 다시 요청합니다. + * Resend 는 레코드가 틀린 채 시간이 지나면 상태를 failed 로 바꾸고 멈추므로, 사용자가 DNS 를 고친 뒤 "검증 요청" 을 다시 누르지 않으면 영영 실패 상태입니다. + * main.mjs 의 정기 동기화(기본 1시간)마다 테넌트별로 부릅니다. 정상(verified)·진행 중(pending)·레코드가 아직 틀린 경우는 아무 것도 하지 않습니다. + * 환경 변수 RESEND_AUTO_VERIFY=0 으로 끌 수 있습니다. + * @returns {Promise<{requested?:boolean, skipped?:string, status?:string, problems?:string[]}>} + */ +export async function autoVerifyDomain(env, { resolver = env.DNS_RESOLVER || null } = {}) { + if (String(env.RESEND_AUTO_VERIFY ?? '1') === '0') return { skipped: 'disabled' }; + if (!env.RESEND_API_KEY) return { skipped: 'no_key' }; + const domainName = String(env.MAILBOX || '').split('@')[1] || ''; + const found = await findDomain(env, domainName); + if (!found) return { skipped: 'missing' }; + const status = String(found.status || 'not_started'); + if (status === 'verified' || status === 'pending') return { skipped: status, status }; + const full = await getDomain(env, found.id); + const check = await checkResendRecords(env, full, { resolver }); + if (!check.ok) return { skipped: 'records', status, problems: check.records.filter((r) => !r.ok).map((r) => `${r.type} ${r.name}: ${r.problem}`) }; + await verifyDomain(env, found.id); + clearStatusCache(env); + return { requested: true, status }; +} + +// ------------------------------------------------------------------ 도메인·웹훅 관리(1.0.5, 요구사항 3·4) +// 외부 서비스 설정 자동화(provision.mjs)가 쓰는 Resend 관리 API 묶음입니다. 모두 resend() 를 거치므로 직렬화·오류 코드 규칙이 같습니다. +/** 계정의 도메인 목록. */ +export const listDomains = async (env) => (await resend(env, '/domains')).data || []; +/** 도메인 상세(status, capabilities, records[{record,name,type,value,priority,status}]). */ +export const getDomain = (env, id) => resend(env, '/domains/' + encodeURIComponent(id)); +/** + * 도메인 등록. region 은 발송 서버 위치(수신 MX 값도 지역에 따라 다름). capabilities 로 발송/수신을 켭니다. + * @returns {Promise} 생성된 도메인(records 포함) + */ +export const createDomain = (env, { name, region = 'ap-northeast-1', sending = true, receiving = false }) => + resend(env, '/domains', 'POST', { name, region, capabilities: { sending: sending ? 'enabled' : 'disabled', receiving: receiving ? 'enabled' : 'disabled' } }); +/** 도메인의 발송/수신 기능 켬·끔(PATCH). */ +export const updateDomainCapabilities = (env, id, { sending, receiving }) => + resend(env, '/domains/' + encodeURIComponent(id), 'PATCH', { capabilities: { sending: sending ? 'enabled' : 'disabled', receiving: receiving ? 'enabled' : 'disabled' } }); +/** DNS 레코드 검증 요청. 결과는 잠시 뒤 getDomain 의 status 로 확인합니다(pending → verified / failed / temporary_failure). */ +export const verifyDomain = (env, id) => resend(env, '/domains/' + encodeURIComponent(id) + '/verify', 'POST', {}); +/** 웹훅 목록 [{id, endpoint, events[], status}]. 서명 비밀키는 목록에 없고 만들 때만 돌려줍니다. */ +export const listWebhooks = async (env) => (await resend(env, '/webhooks')).data || []; +/** 웹훅 만들기 → { id, signing_secret }. 비밀키는 이때만 받을 수 있으므로 호출자가 바로 설정에 저장합니다. */ +export const createWebhook = (env, { endpoint, events = ['email.received'] }) => resend(env, '/webhooks', 'POST', { endpoint, events }); +/** 웹훅 삭제(새 비밀키를 받기 위해 다시 만들 때). */ +export const deleteWebhook = (env, id) => resend(env, '/webhooks/' + encodeURIComponent(id), 'DELETE'); +/** + * 우리 도메인을 찾습니다(없으면 null). 이름 비교는 소문자. + */ +export async function findDomain(env, name) { + const want = String(name || '').toLowerCase(); + return (await listDomains(env)).find((d) => String(d.name || '').toLowerCase() === want) || null; +} +/** + * Resend 가 요구하는 DNS 레코드를 공통 모양으로 바꿉니다(dns/index.mjs 의 레코드 모양). + * name: Resend 는 도메인 기준 상대 이름('' = 루트, 'send', 'resend._domainkey') → FQDN + * TXT 값은 그대로(따옴표는 DNS 모듈이 붙임), MX 는 priority 와 함께, CNAME 은 값 그대로. + * @param {object} domain getDomain/createDomain 응답 + */ +export function resendDnsRecords(domain) { + const base = String(domain?.name || '').toLowerCase(); + return (domain?.records || []).map((r) => ({ + name: r.name ? `${String(r.name).toLowerCase()}.${base}` : base, type: String(r.type || 'TXT').toUpperCase(), ttl: 300, + values: [String(r.value || '')], ...(r.priority !== undefined ? { priority: Number(r.priority) } : {}), + purpose: `Resend ${r.record || r.type}`, source: 'resend', status: r.status || null, + })); +} diff --git a/modules/arimail/server/providers/smtp-client.mjs b/modules/arimail/server/providers/smtp-client.mjs new file mode 100644 index 0000000..d98dbb0 --- /dev/null +++ b/modules/arimail/server/providers/smtp-client.mjs @@ -0,0 +1,156 @@ +/** + * SMTP 클라이언트(1.0.0) — 외부 의존성 없이 node:net / node:tls 로 메일 한 통을 중계 서버에 넘깁니다. + * + * 쓰는 곳: providers/smtp.mjs(발신 방식 haraka / smtp). 내장 Haraka 의 로컬 제출 포트(127.0.0.1:2525, 암호화·인증 없음)나 + * 외부 SMTP 중계 서버(587 STARTTLS + 로그인, 465 TLS 등)에 모두 쓸 수 있습니다. + * + * 흐름: 접속 → 220 인사 → EHLO → (STARTTLS → EHLO) → (AUTH PLAIN 또는 LOGIN) → MAIL FROM → RCPT TO(여러 명) → DATA → 본문(점 스터핑) → QUIT. + * 응답은 여러 줄(250-… / 250 …)을 끝 줄까지 모아 코드와 본문으로 돌려줍니다. 시간 제한 안에 응답이 없으면 끊고 실패로 봅니다. + * + * 오류 코드(providers/resend.mjs 의 providerError 와 같은 어휘로 맞춰 화면·SMTP 응답 변환이 공통으로 동작): + * mail_not_configured(중계 서버 주소 없음) · mail_auth_failed(535) · mail_rejected(5xx) · mail_rate_limited(421/450/451/452) + * · mail_provider_unavailable(접속 실패·시간 초과·그 밖의 4xx). providerMessage 에 서버 응답 문구를 남깁니다. + */ +import net from 'node:net'; +import tls from 'node:tls'; +import { dotStuff } from '../mime.mjs'; + +const CRLF = '\r\n'; + +/** 서버 응답 코드를 공통 오류 코드로 바꿉니다. */ +export function smtpErrorCode(code, phase) { + if (phase === 'auth' && (code === 535 || code === 534 || code === 530)) return 'mail_auth_failed'; + if ([421, 450, 451, 452].includes(code)) return 'mail_rate_limited'; + if (code >= 500) return 'mail_rejected'; + return 'mail_provider_unavailable'; +} + +/** + * 메일 한 통을 보냅니다. + * @param {object} o + * @param {string} o.host 중계 서버 주소 + * @param {number} o.port 포트(587/465/25/2525) + * @param {'none'|'starttls'|'tls'} [o.security] none = 암호화 없음(로컬 Haraka), starttls = 접속 뒤 STARTTLS(없으면 실패), tls = 접속 즉시 TLS + * @param {string} [o.user] @param {string} [o.pass] 로그인(있을 때만 AUTH) + * @param {string} [o.hostname] EHLO 에 보낼 우리 이름 + * @param {string} o.from 봉투 보낸 사람(MAIL FROM) + * @param {string[]} o.to 봉투 받는 사람 전체(To·Cc·Bcc 합침) + * @param {Buffer} o.eml 보낼 원본(CRLF) + * @param {number} [o.timeoutMs] 한 응답을 기다리는 최대 시간 + * @param {boolean} [o.rejectUnauthorized] TLS 인증서 검증(기본 true, 로컬 시험용 false) + * @returns {Promise<{response:string, accepted:string[], rejected:{address:string,reply:string}[]}>} + */ +export async function smtpSend({ host, port, security = 'starttls', user = '', pass = '', hostname = 'localhost', from, to, eml, timeoutMs = 60000, rejectUnauthorized = true }) { + if (!host || !port) { const e = new Error('mail_not_configured'); e.providerMessage = 'SMTP relay host/port missing'; throw e; } + let socket = null; + const fail = (code, message, phase) => { const e = new Error(code); e.providerMessage = String(message || '').replace(/[\r\n]+/g, ' ').slice(0, 300); e.phase = phase; return e; }; + // 줄 단위 응답 읽기: 데이터가 올 때마다 버퍼에 모으고, 마지막 줄(코드 뒤가 공백)이 오면 전체 응답을 돌려줍니다. + let buffer = '', waiter = null; + const onData = (chunk) => { + buffer += chunk.toString('utf8'); + while (waiter) { + const lines = buffer.split(CRLF); if (lines.length < 2) return; + // 완성된 줄들 중 "NNN " 으로 끝나는 줄이 있는지 봅니다(여러 줄 응답은 "NNN-") + let endIndex = -1; + for (let i = 0; i < lines.length - 1; i++) if (/^\d{3}(?: |$)/.test(lines[i])) { endIndex = i; break; } + if (endIndex < 0) return; + const reply = lines.slice(0, endIndex + 1); buffer = lines.slice(endIndex + 1).join(CRLF); + const w = waiter; waiter = null; w.resolve({ code: Number(reply[0].slice(0, 3)), lines: reply.map((l) => l.slice(4)), text: reply.join(' ') }); + } + }; + const read = () => new Promise((resolve, reject) => { + waiter = { resolve, reject }; + const timer = setTimeout(() => { if (waiter) { waiter = null; reject(fail('mail_provider_unavailable', 'timeout waiting for reply', 'read')); } }, timeoutMs); + const done = waiter.resolve; waiter.resolve = (v) => { clearTimeout(timer); done(v); }; + onData(Buffer.alloc(0)); + }); + const attach = (s) => { socket = s; s.setTimeout(timeoutMs + 5000); s.on('data', onData); s.on('error', (e) => { if (waiter) { const w = waiter; waiter = null; w.reject(fail('mail_provider_unavailable', e.message, 'socket')); } }); s.on('close', () => { if (waiter) { const w = waiter; waiter = null; w.reject(fail('mail_provider_unavailable', 'connection closed', 'socket')); } }); }; + const send = async (line, expect, phase) => { + socket.write(line + CRLF); + const r = await read(); + if (!expect.includes(r.code)) throw fail(smtpErrorCode(r.code, phase), `${r.code} ${r.lines.join(' ')}`, phase); + return r; + }; + const connect = () => new Promise((resolve, reject) => { + const onError = (e) => reject(fail('mail_provider_unavailable', e.message, 'connect')); + const s = security === 'tls' ? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)) : net.connect({ host, port }, () => resolve(s)); + s.once('error', onError); s.setTimeout(timeoutMs, () => onError(new Error('connect timeout'))); + }); + try { + attach(await connect()); + const greeting = await read(); if (greeting.code !== 220) throw fail('mail_provider_unavailable', greeting.text, 'greeting'); + let ehlo = await send('EHLO ' + hostname, [250], 'ehlo'); + if (security === 'starttls') { + if (!ehlo.lines.some((l) => /^STARTTLS/i.test(l))) throw fail('mail_provider_unavailable', 'server does not offer STARTTLS', 'starttls'); + await send('STARTTLS', [220], 'starttls'); + const plain = socket; plain.removeAllListeners('data'); plain.removeAllListeners('error'); plain.removeAllListeners('close'); + const secure = await new Promise((resolve, reject) => { const s = tls.connect({ socket: plain, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)); s.once('error', (e) => reject(fail('mail_provider_unavailable', e.message, 'starttls'))); }); + attach(secure); + ehlo = await send('EHLO ' + hostname, [250], 'ehlo'); + } + if (user) { + const mechanisms = (ehlo.lines.find((l) => /^AUTH\b/i.test(l)) || '').toUpperCase(); + if (mechanisms.includes('PLAIN') || !mechanisms.includes('LOGIN')) { + await send('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64'), [235], 'auth'); + } else { + await send('AUTH LOGIN', [334], 'auth'); + await send(Buffer.from(user, 'utf8').toString('base64'), [334], 'auth'); + await send(Buffer.from(pass, 'utf8').toString('base64'), [235], 'auth'); + } + } + await send(`MAIL FROM:<${from}>`, [250], 'mail'); + const accepted = [], rejected = []; + for (const rcpt of to) { + socket.write(`RCPT TO:<${rcpt}>` + CRLF); + const r = await read(); + if (r.code === 250 || r.code === 251) accepted.push(rcpt); else rejected.push({ address: rcpt, reply: `${r.code} ${r.lines.join(' ')}` }); + } + if (!accepted.length) { const first = rejected[0]; throw fail(smtpErrorCode(Number(first.reply.slice(0, 3)), 'rcpt'), first.reply, 'rcpt'); } + await send('DATA', [354], 'data'); + // 본문은 점 스터핑해 보내고 "." 로 끝냅니다. 원본이 이미 CRLF 로 끝나면 빈 줄이 하나 더 생기지 않게 CRLF 를 덧붙이지 않습니다. + const body = dotStuff(eml); const endsCrlf = body.length >= 2 && body[body.length - 2] === 13 && body[body.length - 1] === 10; + socket.write(body); socket.write((endsCrlf ? '' : CRLF) + '.' + CRLF); + const final = await read(); + if (final.code !== 250) throw fail(smtpErrorCode(final.code, 'data'), `${final.code} ${final.lines.join(' ')}`, 'data'); + try { socket.write('QUIT' + CRLF); } catch { /* 응답은 기다리지 않습니다 */ } + return { response: final.lines.join(' '), accepted, rejected }; + } finally { + if (socket) { waiter = null; socket.removeAllListeners('close'); socket.end(); setTimeout(() => socket.destroy(), 2000).unref(); } + } +} + +/** + * 연결 확인(화면의 "연결 확인" 단추·상태 표시): 접속해 EHLO 까지만 주고받고 끊습니다. 로그인 정보가 있으면 AUTH 도 시험합니다. + * @returns {Promise<{ok:boolean, error?:string, detail?:string, features?:string[]}>} + */ +export async function smtpProbe({ host, port, security = 'starttls', user = '', pass = '', hostname = 'localhost', timeoutMs = 10000, rejectUnauthorized = true }) { + if (!host || !port) return { ok: false, error: 'mail_not_configured' }; + // smtpSend 와 같은 절차를 쓰기 위해 받는 사람 없이 호출하면 RCPT 단계에서 끝나므로, 여기서는 간단히 접속·EHLO(·AUTH)만 따로 수행합니다. + let socket = null; + const fail = (code, detail) => ({ ok: false, error: code, detail: String(detail || '').replace(/[\r\n]+/g, ' ').slice(0, 300) }); + try { + let buffer = ''; + const read = (s) => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs); + const onData = (c) => { buffer += c.toString('utf8'); const lines = buffer.split(CRLF); const end = lines.findIndex((l, i) => i < lines.length - 1 && /^\d{3}(?: |$)/.test(l)); if (end < 0) return; const reply = lines.slice(0, end + 1); buffer = lines.slice(end + 1).join(CRLF); clearTimeout(timer); s.off('data', onData); resolve({ code: Number(reply[0].slice(0, 3)), lines: reply.map((l) => l.slice(4)) }); }; + s.on('data', onData); s.once('error', (e) => { clearTimeout(timer); reject(e); }); + }); + socket = await new Promise((resolve, reject) => { const s = security === 'tls' ? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)) : net.connect({ host, port }, () => resolve(s)); s.once('error', reject); s.setTimeout(timeoutMs, () => reject(new Error('connect timeout'))); }); + const greeting = await read(socket); if (greeting.code !== 220) return fail('mail_provider_unavailable', greeting.lines.join(' ')); + socket.write('EHLO ' + hostname + CRLF); let ehlo = await read(socket); if (ehlo.code !== 250) return fail('mail_provider_unavailable', ehlo.lines.join(' ')); + if (security === 'starttls') { + if (!ehlo.lines.some((l) => /^STARTTLS/i.test(l))) return fail('mail_provider_unavailable', 'server does not offer STARTTLS'); + socket.write('STARTTLS' + CRLF); const r = await read(socket); if (r.code !== 220) return fail('mail_provider_unavailable', r.lines.join(' ')); + const plain = socket; plain.removeAllListeners('data'); + socket = await new Promise((resolve, reject) => { const s = tls.connect({ socket: plain, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)); s.once('error', reject); }); + socket.write('EHLO ' + hostname + CRLF); ehlo = await read(socket); if (ehlo.code !== 250) return fail('mail_provider_unavailable', ehlo.lines.join(' ')); + } + if (user) { + socket.write('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64') + CRLF); + const r = await read(socket); if (r.code !== 235) return fail(smtpErrorCode(r.code, 'auth'), r.lines.join(' ')); + } + socket.write('QUIT' + CRLF); + return { ok: true, features: ehlo.lines.slice(1) }; + } catch (e) { return fail('mail_provider_unavailable', e.message); } + finally { if (socket) { socket.end(); setTimeout(() => socket.destroy(), 1000).unref(); } } +} diff --git a/modules/arimail/server/providers/smtp.mjs b/modules/arimail/server/providers/smtp.mjs new file mode 100644 index 0000000..a28ec1e --- /dev/null +++ b/modules/arimail/server/providers/smtp.mjs @@ -0,0 +1,46 @@ +/** + * SMTP 중계 발송 모듈(1.0.0) — 발신 방식이 haraka(내장 Haraka 의 로컬 제출 포트) 또는 smtp(외부 중계 서버)일 때 쓰입니다. + * + * 입력은 Resend 발송 요청과 같은 모양의 메시지 객체입니다(providers/index.mjs 참고). 그래서 store.mjs(웹 발송)·smtp.mjs(제출 서버)· + * automation.mjs(전달·자동 응답)는 어느 발송 모듈을 쓰는지 몰라도 됩니다. 이 모듈은 그 객체를 RFC 5322 원본(.eml)으로 만들어 + * (mime.mjs 의 buildEml 재사용) 봉투(MAIL FROM / RCPT TO)와 함께 중계 서버에 넘깁니다. + * + * 주의: Bcc 는 헤더에 쓰지 않고 봉투에만 넣습니다(buildEml 에 bcc 를 넘기면 헤더가 생기므로 넘기지 않음). + * 멱등 키(key)는 SMTP 에 해당 개념이 없어 쓰지 않습니다. 발송 전에 저장소가 send_key 로 중복을 막고 있습니다. + */ +import { randomUUID } from 'node:crypto'; +import { buildEml } from '../mime.mjs'; +import { address, addresses } from '../util.mjs'; +import { smtpSend } from './smtp-client.mjs'; + +/** + * Resend 형식 메시지 객체 → { eml, envelope }. 시험과 보낸 메일함 저장이 같은 변환을 쓸 수 있게 따로 두었습니다. + * @param {object} payload { from, to[], cc?[], bcc?[], reply_to?[], subject, text?, html?, headers?{}, attachments?[{filename, content(base64), content_type, content_id?}] } + * @param {string} domain Message-ID 의 @ 뒤 도메인 + */ +export function payloadToEml(payload, domain) { + const messageId = `<${randomUUID()}@${domain || 'localhost'}>`; + const eml = buildEml({ + from: payload.from, to: payload.to || [], cc: payload.cc || [], replyTo: payload.reply_to || [], subject: payload.subject || '', + text: payload.text, html: payload.html, headers: payload.headers || {}, messageId, + attachments: (payload.attachments || []).map((a) => ({ filename: a.filename, contentType: a.content_type, bytes: Buffer.from(a.content || '', 'base64'), contentId: a.content_id || undefined, disposition: a.content_id ? 'inline' : 'attachment' })), + }); + const envelope = { from: address(payload.from), to: addresses([...(payload.to || []), ...(payload.cc || []), ...(payload.bcc || [])]) }; + return { eml, envelope, messageId }; +} + +/** + * 메시지 객체를 중계 서버로 보냅니다. + * @param {object} env SMTP_RELAY_* 가 아니라 호출자가 고른 relay 설정을 씁니다(env 는 도메인·호스트 이름용) + * @param {object} payload Resend 형식 메시지 + * @param {object} relay { host, port, security, user, pass, rejectUnauthorized? } + * @returns {Promise<{id:string, accepted:string[], rejected:object[], response:string}>} id = 생성한 Message-ID(괄호 없이). messages.provider_id 에 저장됩니다. + */ +export async function sendViaSmtp(env, payload, relay) { + const domain = String(env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || 'localhost'; + const { eml, envelope, messageId } = payloadToEml(payload, domain); + if (!envelope.to.length) { const e = new Error('mail_rejected'); e.providerMessage = 'no recipients'; throw e; } + const result = await smtpSend({ ...relay, hostname: env.PUBLIC_HOST || 'localhost', from: envelope.from, to: envelope.to, eml }); + if (result.rejected.length) console.warn('SMTP 중계: 일부 받는 사람 거부', result.rejected.map((r) => `${r.address} (${r.reply})`).join(', ')); + return { id: messageId.replace(/^<|>$/g, ''), ...result }; +} diff --git a/modules/arimail/server/provision.mjs b/modules/arimail/server/provision.mjs new file mode 100644 index 0000000..973bf2f --- /dev/null +++ b/modules/arimail/server/provision.mjs @@ -0,0 +1,209 @@ +/** + * 메일 서비스 자동 설정(1.0.5, 요구사항 2·4·5) — "계획(plan)" 모델. + * + * 생각의 틀 + * 사용자가 수·발신 방식과 도메인을 정하면, 메일을 실제로 쓰기까지 필요한 일이 여러 곳에 흩어져 있습니다: Resend 에 도메인 등록·기능 켜기·검증·웹훅, + * DNS 에 MX/SPF/DKIM/CNAME 레코드, 내장 Haraka 설치·DKIM 키·PTR·방화벽, 외부 SMTP 중계 서버 접속 확인 … 이 모듈은 그 일들을 **단계(step)** 로 나누어 + * 현재 상태를 진단하고(buildPlan), 자동으로 할 수 있는 단계는 실행하며(runPlan), 사람이 해야 하는 단계는 무엇을·어떻게 해야 하는지와 입력 칸을 돌려줍니다. + * 화면(web/setup.js)은 이 계획을 점검표로 그리고 "자동 설정 실행" 단추 하나로 돌립니다. 실제 변경은 각 모듈(providers/resend.mjs, dns/*.mjs, config.mjs)이 합니다. + * + * 단계 상태(state) + * ok 끝남 todo 자동으로 할 수 있고 아직 안 함(runPlan 이 실행) + * wait 반영·검증을 기다리는 중(DNS 전파, Resend 검증) action 사용자가 해야 함(입력 칸 input 또는 단추 actions, 안내 detail) + * error 실패(원인 detail) info 참고(준비 판정에 영향 없음) skip 이번 구성에서는 필요 없음(화면에 안 보임) + * + * 계획 모양: { domain, inbound, outbound, dns:{provider, configured, zone?, error?}, records:[레코드+check], steps:[{id, group, title, state, detail, auto, input?, actions?}], ready, readyMessage } + */ +import { inboundMode, outboundMode, usesResend, usesHaraka, relaySettings } from './providers/index.mjs'; +import * as resend from './providers/resend.mjs'; +import { smtpProbe } from './providers/smtp-client.mjs'; +import { requiredRecords, checkRecords, applyRecords, dnsProviderName, dnsConfigured, probeDns } from './dns/index.mjs'; +import { lookup, reverseCheck, bare } from './dns/common.mjs'; +import { saveConfig, generateDkim, mailDomain, harakaHostname, harakaStatus, harakaDir, CONFIG_SCHEMA } from './config.mjs'; + +/** Resend 상태·오류 코드를 사람이 읽는 말로. 화면 문구와 겹치지 않게 서버가 한 번만 만듭니다. */ +const RESEND_STATUS = { not_started: '검증을 시작하지 않음', pending: '검증 진행 중(DNS 반영 뒤 보통 몇 분, 최대 72시간)', verified: '검증 완료', failed: '검증 실패(Resend 가 레코드를 확인하지 못해 멈춤 — 고친 뒤 다시 요청해야 함)', temporary_failure: '일시 실패 — DNS 반영을 기다린 뒤 다시 검증' }; +const ERR = { mail_not_configured: 'API 키가 없습니다', mail_auth_failed: 'API 키가 거부되었습니다', mail_forbidden: '권한이 없습니다(키 종류·도메인 소유 확인)', mail_rate_limited: 'Resend 가 요청을 제한합니다. 잠시 뒤 다시', mail_rejected: 'Resend 가 요청을 거부했습니다', mail_provider_unavailable: 'Resend 에 연결할 수 없습니다', dns_not_configured: 'DNS 모듈 인증 정보가 없습니다', dns_bad_credentials: 'DNS 인증 정보 형식이 틀립니다', dns_auth_failed: 'DNS 서비스가 인증을 거부했습니다', dns_forbidden: 'DNS 서비스 권한이 없습니다', dns_zone_not_found: 'DNS 존을 찾지 못했습니다', dns_unavailable: 'DNS 서비스에 연결할 수 없습니다' }; +const say = (e) => (ERR[e?.message] || e?.message || '알 수 없는 오류') + (e?.detail || e?.providerMessage ? ` (${e.detail || e.providerMessage})` : ''); + +/** + * 진단에 필요한 외부 상태를 한 번씩만 읽어 모아 둡니다(Resend 도메인·웹훅, DNS 조회). 실패는 오류로 남겨 단계가 설명합니다. + * @param {object} o resolver: 시험용 DNS 리졸버 + */ +async function gather(env, { resolver = null } = {}) { + const ctx = { domain: mailDomain(env), inbound: inboundMode(env), outbound: outboundMode(env), host: bare(harakaHostname(env)), resendDomain: null, resendError: null, webhooks: null, webhookError: null, existingSpf: null, resolver }; + if (usesResend(env) && env.RESEND_API_KEY) { + try { + let d = null; + if (env.RESEND_DOMAIN_ID) { try { d = await resend.getDomain(env, env.RESEND_DOMAIN_ID); if (String(d.name || '').toLowerCase() !== ctx.domain) d = null; } catch { d = null; } } + if (!d) { const found = await resend.findDomain(env, ctx.domain); if (found) d = await resend.getDomain(env, found.id); } + ctx.resendDomain = d; + } catch (e) { ctx.resendError = e; } + if (ctx.inbound === 'resend') { try { ctx.webhooks = await resend.listWebhooks(env); } catch (e) { ctx.webhookError = e; } } + } + if (ctx.outbound === 'haraka') { const r = await lookup({ name: ctx.domain, type: 'TXT', values: [] }, { resolver }); ctx.existingSpf = (r.found || []).find((v) => /^v=spf1\b/i.test(v)) || null; } + return ctx; +} + +const step = (id, group, title, state, detail = '', extra = {}) => ({ id, group, title, state, detail, auto: false, ...extra }); +// 경로 접두사(1.0.8): site.example.com/mail 처럼 등록한 도메인은 웹훅도 그 경로 아래(https://site.example.com/mail/api/webhooks/resend)로 받습니다. +const webhookEndpoint = (env) => `https://${env.PUBLIC_HOST || 'mail.arihakdang.com'}${env.PUBLIC_PATH || ''}/api/webhooks/resend`; +const inputs = (...keys) => keys.map((key) => { const item = CONFIG_SCHEMA.find((i) => i.key === key); return { key, type: item?.type || 'text', ...(item?.options ? { options: item.options } : {}), ...(item?.multiline ? { multiline: true } : {}) }; }); + +/** + * 현재 상태를 진단해 계획을 만듭니다. 외부 호출(Resend·DNS 조회)을 하므로 1~3초 걸릴 수 있습니다. + * @param {object} [o] resolver: 시험용 DNS 리졸버, ctx: gather 결과 재사용 + */ +export async function buildPlan(env, { resolver = null, ctx = null } = {}) { + ctx = ctx || await gather(env, { resolver }); + const steps = []; + const { domain, inbound, outbound, host } = ctx; + // ---- Resend + if (usesResend(env)) { + if (!env.RESEND_API_KEY) steps.push(step('resend.key', 'resend', 'Resend API 키', 'action', 'Resend → API Keys 에서 전체 접근(Full access) 키를 만들어 넣으세요. 도메인 등록·검증·웹훅을 이 키로 자동 처리합니다.', { input: inputs('RESEND_API_KEY', 'RESEND_REGION') })); + else { + steps.push(step('resend.key', 'resend', 'Resend API 키', 'ok', '설정됨')); + const d = ctx.resendDomain, wantRecv = inbound === 'resend', wantSend = outbound === 'resend'; + if (ctx.resendError) steps.push(step('resend.domain', 'resend', `Resend 에 도메인 ${domain} 등록`, 'error', say(ctx.resendError))); + else if (!d) steps.push(step('resend.domain', 'resend', `Resend 에 도메인 ${domain} 등록`, 'todo', `지역 ${env.RESEND_REGION || 'ap-northeast-1'} 로 등록하고 ${[wantSend && '발송', wantRecv && '수신'].filter(Boolean).join('·')} 기능을 켭니다.`, { auto: true })); + else { + const cap = d.capabilities || {}, recvOn = cap.receiving === 'enabled', sendOn = cap.sending !== 'disabled'; + if (recvOn !== wantRecv || sendOn !== wantSend) steps.push(step('resend.domain', 'resend', `Resend 도메인 기능 맞추기`, 'todo', `지금: 발송 ${sendOn ? '켬' : '끔'} · 수신 ${recvOn ? '켬' : '끔'} → 필요: 발송 ${wantSend ? '켬' : '끔'} · 수신 ${wantRecv ? '켬' : '끔'}`, { auto: true })); + else steps.push(step('resend.domain', 'resend', `Resend 도메인 ${domain}`, 'ok', `등록됨 (지역 ${d.region || '-'}, 발송 ${sendOn ? '켬' : '끔'} · 수신 ${recvOn ? '켬' : '끔'})`)); + } + } + } + // ---- Haraka 준비(레코드 계산 전에 DKIM 이 있어야 하므로 먼저) + const hs = usesHaraka(env) ? harakaStatus(env) : null; + if (usesHaraka(env)) { + const svc = hs.status && !hs.stale; + if (!hs.installed) steps.push(step('haraka.install', 'haraka', '내장 Haraka 설치', 'action', `서버에서 root 로 한 번 실행하세요: sudo bash ${harakaDir(env).replace(/\/haraka$/, '')}/repo/deploy/haraka-apply.sh (npm 설치 + 서비스 등록, 인터넷 필요)`, { actions: [{ id: 'recheck', label: '설치 확인' }] })); + else steps.push(step('haraka.install', 'haraka', '내장 Haraka 설치', 'ok', `Haraka ${hs.status?.version || ''} 설치됨`)); + if (hs.installed && !svc) steps.push(step('haraka.service', 'haraka', 'Haraka 관리 서비스', 'action', '관리 서비스가 돌고 있지 않습니다. 서버에서: sudo systemctl start <서비스 이름>-haraka (deploy/haraka-apply.sh 를 다시 실행해도 됩니다)', { actions: [{ id: 'recheck', label: '다시 확인' }] })); + else if (hs.installed) steps.push(step('haraka.service', 'haraka', 'Haraka 관리 서비스', 'ok', '실행 중')); + if (outbound === 'haraka') steps.push(env.HARAKA_DKIM_PRIVATE ? step('haraka.dkim', 'haraka', 'DKIM 서명 키', 'ok', `셀렉터 ${env.HARAKA_DKIM_SELECTOR || 'arimail'}`) : step('haraka.dkim', 'haraka', 'DKIM 서명 키 만들기', 'todo', 'RSA 2048 키를 만들어 Haraka 가 보내는 메일에 서명합니다. 공개키는 DNS TXT 로 올립니다.', { auto: true })); + } + // ---- DNS + const records = await checkRecords(requiredRecords(env, { resendDomain: ctx.resendDomain, existingSpf: ctx.existingSpf }), { resolver: ctx.resolver }); + const needed = records.filter((r) => !r.optional), missing = needed.filter((r) => !r.check.ok); + const provider = dnsProviderName(env), configured = dnsConfigured(env); + let dnsInfo = { provider, configured }; + if (records.length) { + if (!missing.length) steps.push(step('dns.apply', 'dns', 'DNS 레코드', 'ok', `${needed.length}개 레코드가 모두 반영되어 있습니다.`)); + else if (provider === 'none' || !configured) { + // 1.0.11: 값이 다른 레코드(⚠️ 지금 값 표시)와 아직 없는 레코드를 구분해 알립니다. + const wrongCount = missing.filter((r) => r.check.problem === 'wrong').length; + const shape = wrongCount ? `값이 다른 레코드 ${wrongCount}개(표의 "지금 값" 참고)${missing.length - wrongCount ? `, 없는 레코드 ${missing.length - wrongCount}개` : ''}. ` : ''; + steps.push(step('dns.apply', 'dns', `DNS 레코드 ${missing.length}개 ${wrongCount ? '고치기' : '넣기'}`, 'action', shape + (provider === 'none' ? '자동 적용할 DNS 모듈이 없습니다. 아래 표의 레코드를 도메인의 DNS 서비스에 직접 넣고 "반영 확인" 을 누르세요. Google Cloud DNS 나 AWS Route 53 을 쓰면 "DNS 모듈" 카드에 인증 정보를 넣어 자동 적용할 수 있습니다.' : `DNS 모듈(${provider})의 인증 정보가 비어 있습니다. "DNS 모듈" 카드에서 채우거나 레코드를 직접 넣으세요.`), { actions: [{ id: 'recheck', label: '반영 확인' }] })); + } + else { + const probe = await probeDns(env); dnsInfo = { ...dnsInfo, zone: probe.zone, error: probe.ok ? null : probe.error, detail: probe.detail }; + if (!probe.ok) steps.push(step('dns.apply', 'dns', `DNS 레코드 ${missing.length}개 넣기`, 'error', `DNS 모듈(${provider}) 연결 실패: ${say({ message: probe.error, detail: probe.detail })}. 인증 정보·권한·존을 확인하세요. 또는 아래 레코드를 직접 넣으세요.`, { actions: [{ id: 'recheck', label: '반영 확인' }] })); + else steps.push(step('dns.apply', 'dns', `DNS 레코드 ${missing.length}개 넣기`, env.PLAN_DNS_APPLIED_AT && Date.now() - Date.parse(env.PLAN_DNS_APPLIED_AT) < 1800000 ? 'wait' : 'todo', env.PLAN_DNS_APPLIED_AT && Date.now() - Date.parse(env.PLAN_DNS_APPLIED_AT) < 1800000 ? `${provider} 존 ${probe.zone} 에 적용했습니다. 전파를 기다리는 중(보통 1~10분): ${missing.map((r) => `${r.type} ${r.name}`).join(', ')}` : `${provider} 존 ${probe.zone} 에 자동으로 넣습니다: ${missing.map((r) => `${r.type} ${r.name}`).join(', ')}`, { auto: true, actions: [{ id: 'recheck', label: '반영 확인' }] })); + } + } + // ---- Resend 검증·웹훅 + if (usesResend(env) && env.RESEND_API_KEY && ctx.resendDomain) { + const d = ctx.resendDomain, st = String(d.status || 'not_started'); + // 1.0.11: 빠진 Resend 레코드를 "값이 다름(wrong)" 과 "없음(missing)" 으로 나눕니다. 값이 다른 것은 기다려도 해결되지 않으므로 사용자 작업(action)으로, + // 없는 것만 전파 대기(wait)로 안내합니다. 운영에서 DKIM TXT 가 다른 등록의 공개키로 들어가 있어 "검증 실패" 만 보이던 일에서 나온 구분입니다. + const resendMissing = missing.filter((r) => r.source === 'resend'), wrong = resendMissing.filter((r) => r.check.problem === 'wrong'); + const wrongText = wrong.map((r) => `${r.type} ${r.name} (지금 ${resend.short(r.check.found[0] || '', r.values[0])} / 필요 ${resend.short(r.values[0], r.check.found[0] || '')})`).join(', '); + if (st === 'verified') steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증', 'ok', RESEND_STATUS.verified)); + else if (st === 'pending') steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증', 'wait', RESEND_STATUS.pending, { actions: [{ id: 'recheck', label: '상태 확인' }] })); + else if (wrong.length) steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증 — DNS 레코드 값이 다릅니다', 'action', `${RESEND_STATUS[st] || st}. 값이 다른 레코드: ${wrongText}. 아래 표의 값(복사 단추)으로 바꾸세요 — 다른 Resend 계정이나 예전 등록에서 받은 값일 수 있습니다. 바꾼 뒤에는 1시간마다 자동으로 검증을 다시 요청하므로 기다리거나 "지금 검증 요청" 을 누르세요.`, { actions: [{ id: 'recheck', label: '반영 확인' }, { id: 'verify', label: '지금 검증 요청' }] })); + else steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증 요청', resendMissing.length ? 'wait' : 'todo', (RESEND_STATUS[st] || st) + (resendMissing.length ? ` — 아직 DNS 에 없는 Resend 레코드: ${resendMissing.map((r) => `${r.type} ${r.name}`).join(', ')}. 반영되면 자동으로 검증을 요청합니다(1시간마다).` : ' — 검증을 요청합니다.'), { auto: !resendMissing.length, actions: [{ id: 'verify', label: '지금 검증 요청' }] })); + if (inbound === 'resend') { + const ep = webhookEndpoint(env); + if (ctx.webhookError) steps.push(step('resend.webhook', 'resend', '수신 웹훅', 'error', say(ctx.webhookError))); + else { + const mine = (ctx.webhooks || []).find((w) => String(w.endpoint || '').replace(/\/$/, '') === ep); + if (!mine) steps.push(step('resend.webhook', 'resend', '수신 웹훅 만들기', 'todo', `${ep} 로 email.received 이벤트를 보내는 웹훅을 만들고 서명 비밀키를 설정에 저장합니다.`, { auto: true })); + else if (mine.status !== 'enabled') steps.push(step('resend.webhook', 'resend', '수신 웹훅', 'action', `웹훅이 꺼져 있습니다(${mine.status}). Resend 대시보드에서 켜거나 아래에서 새로 만드세요.`, { actions: [{ id: 'recreate', label: '웹훅 새로 만들기' }] })); + else if (!env.RESEND_WEBHOOK_SECRET) steps.push(step('resend.webhook', 'resend', '수신 웹훅 서명 비밀키', 'action', `웹훅은 있지만(${mine.id}) 서명 비밀키가 설정에 없습니다. Resend 는 비밀키를 만들 때만 보여 주므로, 대시보드의 Signing Secret 을 넣거나 웹훅을 새로 만드세요(기존 것은 지웁니다).`, { input: inputs('RESEND_WEBHOOK_SECRET'), actions: [{ id: 'recreate', label: '웹훅 새로 만들기' }] })); + else steps.push(step('resend.webhook', 'resend', '수신 웹훅', 'ok', `${mine.id} (이벤트 ${(mine.events || []).length}개${(mine.events || []).length > 1 ? ' — email.received 외 이벤트는 무시됩니다' : ''})`)); + } + } + } + // ---- Haraka 실행·발신 준비 + if (usesHaraka(env) && hs.installed && hs.status && !hs.stale) { + const st = hs.status; + if (st.running) steps.push(step('haraka.running', 'haraka', 'Haraka 실행', 'ok', `대기 중: ${(st.listen || []).join(', ')}`)); + else steps.push(step('haraka.running', 'haraka', 'Haraka 실행', 'wait', st.lastExit ? `마지막 종료 코드 ${st.lastExit.code} (${st.lastExit.at}). 로그: journalctl -u <서비스 이름>-haraka` : '관리 서비스가 곧 띄웁니다(설정 저장 뒤 수 초).', { actions: [{ id: 'recheck', label: '다시 확인' }] })); + if (!st.tls) steps.push(step('haraka.tls', 'haraka', 'STARTTLS 인증서', 'info', '메일 포트 인증서(POP_TLS_CERT)가 없어 Haraka 가 STARTTLS 를 제공하지 않습니다. 인증서가 생기면 자동으로 켭니다.')); + if (inbound === 'haraka') steps.push(step('haraka.port', 'haraka', `수신 포트 ${env.HARAKA_PORT || 25} 개방`, 'info', `방화벽(클라우드 보안 그룹 포함)에서 TCP ${env.HARAKA_PORT || 25} 를 열어야 외부 메일 서버가 접속할 수 있습니다. 서버 안에서는 확인할 수 없으니 외부에서 telnet ${host} ${env.HARAKA_PORT || 25} 로 확인하세요.`)); + if (outbound === 'haraka') { + const ptr = await reverseCheck(host, { resolver: ctx.resolver }); + steps.push(ptr.ok ? step('haraka.ptr', 'haraka', '역방향 DNS(PTR)', 'ok', `${ptr.ips.join(', ')} → ${host}`) : step('haraka.ptr', 'haraka', '역방향 DNS(PTR)', 'action', ptr.error ? `${host} 의 A 레코드를 찾지 못했습니다(${ptr.error}). 메일 서버 이름이 이 서버 IP 를 가리켜야 합니다.` : `IP ${ptr.ips.join(', ')} 의 PTR 이 ${host} 가 아닙니다(지금: ${ptr.ptr.join(', ') || '없음'}). 호스팅 업체에 PTR(역방향 DNS)을 ${host} 로 요청하세요. 없으면 Gmail 등에서 스팸으로 분류되기 쉽습니다.`, { actions: [{ id: 'recheck', label: '다시 확인' }] })); + } + } + // ---- 외부 SMTP 중계 + if (outbound === 'smtp') { + const relay = relaySettings(env, 'smtp'); + if (!relay.host) steps.push(step('smtp.relay', 'smtp', 'SMTP 중계 서버', 'action', '보내는 메일을 넘길 중계 서버 접속 정보를 넣으세요.', { input: inputs('SMTP_RELAY_HOST', 'SMTP_RELAY_PORT', 'SMTP_RELAY_SECURITY', 'SMTP_RELAY_USER', 'SMTP_RELAY_PASS') })); + else { const p = await smtpProbe({ ...relay, hostname: env.PUBLIC_HOST || 'localhost', timeoutMs: 8000 }); steps.push(p.ok ? step('smtp.relay', 'smtp', 'SMTP 중계 서버', 'ok', `${relay.host}:${relay.port} 연결·${relay.user ? '인증 ' : ''}확인`) : step('smtp.relay', 'smtp', 'SMTP 중계 서버', 'error', `${relay.host}:${relay.port} — ${ERR[p.error] || p.error}${p.detail ? ' (' + p.detail + ')' : ''}`, { input: inputs('SMTP_RELAY_HOST', 'SMTP_RELAY_PORT', 'SMTP_RELAY_SECURITY', 'SMTP_RELAY_USER', 'SMTP_RELAY_PASS'), actions: [{ id: 'recheck', label: '다시 확인' }] })); } + } + const blocking = steps.filter((s) => !['ok', 'info', 'skip'].includes(s.state)); + const ready = blocking.length === 0; + const readyMessage = ready ? `메일 사용 준비가 끝났습니다 — 수신 ${label(inbound)} · 발신 ${label(outbound)} (${domain}).` : blocking.some((s) => s.state === 'wait') && blocking.every((s) => s.state === 'wait') ? '반영을 기다리는 중입니다. 잠시 뒤 "다시 확인" 을 누르세요.' : `${blocking.length}개 단계가 남았습니다: ${blocking.map((s) => s.title).join(', ')}`; + return { domain, inbound, outbound, host, dns: dnsInfo, records, steps, ready, readyMessage, checkedAt: new Date().toISOString() }; +} +const label = (m) => ({ resend: 'Resend', haraka: '내장 Haraka', smtp: '외부 SMTP 중계' }[m] || m); + +/** + * 계획을 실행합니다. input 은 먼저 설정에 저장하고(허용된 키만), step 이 있으면 그 단계(또는 그 단계의 action)만, 없으면 자동 가능한 단계를 순서대로 실행합니다. + * 실행 뒤 다시 진단한 계획과 실행 기록(log: [{id, result, detail}])을 돌려줍니다. 한 단계가 실패해도 다음 단계로 넘어가며 기록에 남깁니다. + */ +export async function runPlan(env, { step: only = null, stepAction = null, input = {}, resolver = null } = {}) { + const log = []; + const patch = Object.fromEntries(Object.entries(input).filter(([k]) => CONFIG_SCHEMA.some((i) => i.key === k && !i.hidden))); + if (Object.keys(patch).length) { saveConfig(env, patch); log.push({ id: 'config', result: 'saved', detail: Object.keys(patch).join(', ') }); } + let plan = await buildPlan(env, { resolver }); + const run = async (s) => { + try { + const r = await execute(env, s, stepAction, plan, resolver); + log.push({ id: s.id, result: r ? 'done' : 'noop', detail: r || '' }); + } catch (e) { log.push({ id: s.id, result: 'error', detail: say(e) }); } + }; + if (only) { const s = plan.steps.find((x) => x.id === only); if (s) await run(s); } + else { + // 순서가 중요: 도메인 등록 → DKIM → DNS 적용 → (다시 진단) → 검증·웹훅 + // 한 단계가 끝나면 다시 진단합니다: DKIM 키가 생기면 DNS 레코드 목록이 바뀌고, 도메인이 등록되면 Resend 레코드가 생기기 때문입니다. + for (const id of ['resend.domain', 'haraka.dkim', 'dns.apply', 'resend.verify', 'resend.webhook']) { + const s = plan.steps.find((x) => x.id === id && x.auto && x.state === 'todo'); if (!s) continue; + await run(s); plan = await buildPlan(env, { resolver }); + } + } + plan = await buildPlan(env, { resolver }); + return { plan, log }; +} + +/** 단계 하나의 실제 동작. 돌려주는 문자열은 기록용 요약, null 이면 할 일이 없었음. */ +async function execute(env, s, action, plan, resolver) { + const domain = plan.domain, wantRecv = plan.inbound === 'resend', wantSend = plan.outbound === 'resend'; + switch (s.id) { + case 'resend.domain': { + const found = await resend.findDomain(env, domain); + if (!found) { const d = await resend.createDomain(env, { name: domain, region: env.RESEND_REGION || 'ap-northeast-1', sending: wantSend, receiving: wantRecv }); saveConfig(env, { RESEND_DOMAIN_ID: d.id }, { allowHidden: true }); return `도메인 등록 ${d.id}`; } + saveConfig(env, { RESEND_DOMAIN_ID: found.id }, { allowHidden: true }); + await resend.updateDomainCapabilities(env, found.id, { sending: wantSend, receiving: wantRecv }); return `기능 갱신(발송 ${wantSend ? '켬' : '끔'} · 수신 ${wantRecv ? '켬' : '끔'})`; + } + case 'haraka.dkim': generateDkim(env); return 'DKIM 키 생성'; + case 'dns.apply': { + if (action === 'recheck') return null; + const r = await applyRecords(env, plan.records.filter((x) => !x.check.ok)); + env.PLAN_DNS_APPLIED_AT = new Date().toISOString(); // 적용 직후에는 "전파 대기" 로 표시(메모리에만, 재시작하면 다시 진단) + return `${r.provider} 존 ${r.zone}: 변경 ${r.changed.length}개${r.unchanged.length ? `, 그대로 ${r.unchanged.length}개` : ''}`; + } + case 'resend.verify': { if (action === 'recheck') return null; const id = env.RESEND_DOMAIN_ID || (await resend.findDomain(env, domain))?.id; if (!id) throw new Error('mail_rejected'); await resend.verifyDomain(env, id); return '검증 요청'; } + case 'resend.webhook': { + const ep = webhookEndpoint(env); + if (action === 'recreate') { for (const w of await resend.listWebhooks(env)) if (String(w.endpoint || '').replace(/\/$/, '') === ep) { try { await resend.deleteWebhook(env, w.id); } catch (e) { console.warn('웹훅 삭제 실패', w.id, e.message); } } } + else if ((await resend.listWebhooks(env)).some((w) => String(w.endpoint || '').replace(/\/$/, '') === ep)) return null; + const w = await resend.createWebhook(env, { endpoint: ep, events: ['email.received'] }); + saveConfig(env, { RESEND_WEBHOOK_SECRET: w.signing_secret || '' }); saveConfig(env, { RESEND_WEBHOOK_ID: w.id || '' }, { allowHidden: true }); + return `웹훅 생성 ${w.id}${w.signing_secret ? ' + 서명 비밀키 저장' : ''}`; + } + default: return null; // recheck 류: buildPlan 이 다시 진단하는 것으로 충분 + } +} diff --git a/modules/arimail/server/resend.mjs b/modules/arimail/server/resend.mjs new file mode 100644 index 0000000..c4f5148 --- /dev/null +++ b/modules/arimail/server/resend.mjs @@ -0,0 +1,5 @@ +/** + * 호환용 재수출(1.0.0). Resend 모듈은 server/providers/resend.mjs 로 옮겼습니다(요구사항 2: 발송 서비스 모듈화). + * 예전 import 경로('./resend.mjs')를 쓰는 코드·시험이 그대로 동작하게 같은 이름을 다시 내보냅니다. 새 코드는 providers/ 를 직접 쓰세요. + */ +export { resend, download, providerStatus, providerError } from './providers/resend.mjs'; diff --git a/modules/arimail/server/sanitize.mjs b/modules/arimail/server/sanitize.mjs new file mode 100644 index 0000000..39f403c --- /dev/null +++ b/modules/arimail/server/sanitize.mjs @@ -0,0 +1,116 @@ +/** + * 수신 HTML 정제. www 사이트의 build/sanitize_mail.py 와 같은 허용 목록을 JS 로 옮겼습니다. + * 스크립트·스타일·이벤트 속성·위험한 URL 스킴은 남기지 않으며, 미리보기는 그 위에 sandbox iframe + CSP 를 더 씁니다. + */ +const ALLOWED = new Set(['p','br','strong','b','em','i','u','ul','ol','li','blockquote','div','span','h1','h2','h3','h4','pre','code','s','strike','font','a','img','hr','table','tbody','thead','tr','td','th']); +const VOID = new Set(['br','img','hr']); +const BLOCKED = new Set(['script','style','iframe','object','embed','form','svg','math','template','noscript','head','title']); +// embed 는 HTML5 빈 요소(닫는 태그 없음)라 열린 범위를 만들면 뒤의 본문이 모두 사라집니다. 원문 텍스트 요소(script/style/title/textarea 류)는 +// 자기 닫는 태그까지 바로 건너뛰고, 닫는 태그가 없으면 범위를 열지 않습니다(예전에는 닫히지 않은 ·<form> 하나로 본문 전체가 비었습니다, 0.8.25). +const VOID_BLOCKED = new Set(['embed']); +const RAW_TEXT_BLOCKED = new Set(['script', 'style', 'title', 'noscript', 'template']); +// 껍데기만 벗기는 요소(0.9.0): form·head·object·iframe 은 태그만 버리고 안의 내용은 계속 정제합니다. 예전에는 이 태그가 범위(depth)를 열어 +// <form> 으로 감싼 뉴스레터 본문이 통째로 사라지고, 닫는 태그가 없는 <form>/<iframe>/<head>(브라우저는 <body> 에서 자동으로 닫음) 뒤의 본문이 모두 비었습니다. +// 범위를 여는 것은 svg·math(안이 HTML 이 아님)뿐이며, 그마저도 <body> 시작 태그를 만나면 닫습니다. +const UNWRAP_BLOCKED = new Set(['form', 'head', 'object', 'iframe']); +// svg/math 안에서 만나면 브라우저가 외래 내용(foreign content)을 끝내는 HTML 시작 태그(HTML 명세 13.2.6.5). 닫히지 않은 <svg> 뒤의 본문이 사라지지 않게 같은 규칙으로 범위를 닫습니다(0.9.3). +const BREAKOUT = new Set(['b','big','blockquote','body','br','center','code','dd','div','dl','dt','em','embed','h1','h2','h3','h4','h5','h6','head','hr','i','img','li','listing','menu','meta','nobr','ol','p','pre','ruby','s','small','span','strong','strike','sub','sup','table','tt','u','ul','var']); +// text-decoration-line/-style/-color: Chrome 은 편집기의 밑줄·취소선을 text-decoration 이 아니라 text-decoration-line 으로 씁니다. +// 이 키가 없어 Chrome 에서 넣은 밑줄·취소선이 저장·발송에서 사라졌습니다(0.8.12 수정). +const STYLE_KEYS = new Set(['color','background-color','font-family','font-size','font-weight','font-style','text-decoration','text-decoration-line','text-decoration-style','text-decoration-color','text-align','line-height','margin','margin-left','margin-right','margin-top','margin-bottom','padding','padding-left','padding-right','padding-top','padding-bottom','border','border-collapse','border-color','border-width','border-style','width','height','min-height','max-width','vertical-align','white-space']); +// height 를 그대로 둘 요소(0.9.9). 그림은 크기 자체이고 표·칸은 내용이 많으면 어차피 늘어나므로 height 가 겹침을 만들지 않습니다. +// 그 밖의 요소(div·p·span·blockquote 등)는 height 를 min-height 로 바꿉니다: 웹 페이지·문서 편집기에서 복사한 본문에는 `<div style="height:40px">` 처럼 +// 고정 높이가 붙어 오는데, 안의 글이 그 높이보다 길면 overflow 가 visible 이라 다음 문단 위로 번져 겹쳐 보입니다(편집기에서도, 받는 쪽 메일에서도). +// min-height 로 바꾸면 비어 있는 간격용 div 는 그 높이를 지키고, 글이 든 div 는 글 길이대로 자랍니다. +const KEEP_HEIGHT = new Set(['img', 'table', 'tr', 'td', 'th']); +const NUMERIC = new Set(['width','height','colspan','rowspan','cellpadding','cellspacing','border','size']); +const SIMPLE = new Set(['color','bgcolor','align','valign','face']); +const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; +export const escapeHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +export function decodeEntities(s) { + return String(s).replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (m, e) => { + if (e[0] === '#') { const code = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m; } + return ENTITIES[e.toLowerCase()] ?? m; + }); +} +function scheme(value) { const m = String(value).trim().replace(/[\u0000- ]/g, '').match(/^([a-z][a-z0-9+.-]*):/i); return m ? m[1].toLowerCase() : ''; } +function parseAttrs(raw) { + const out = []; const re = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>`]+)))?/g; let m; + while ((m = re.exec(raw))) out.push([m[1].toLowerCase(), decodeEntities(m[2] ?? m[3] ?? m[4] ?? '')]); + return out; +} +function cleanAttrs(tag, attrs) { + const clean = []; + for (const [name, value] of attrs) { + if (name === 'href' && tag === 'a' && ['https', 'http', 'mailto'].includes(scheme(value))) clean.push(`href="${escapeHtml(value)}"`); + if (name === 'src' && tag === 'img' && (['https', 'http', 'cid'].includes(scheme(value)) || /^\/api\/mail\/[A-Za-z0-9-]+\/attachments\/[A-Za-z0-9-]+(?:\?inline=1)?$/.test(value))) clean.push(`src="${escapeHtml(value)}"`); + if (name === 'style') { + const styles = []; + for (const declaration of value.split(';')) { + const i = declaration.indexOf(':'); if (i < 0) continue; + const key = declaration.slice(0, i).trim().toLowerCase(), v = declaration.slice(i + 1).trim(); + // 음수 여백은 버립니다(0.9.9). margin-top:-400px 처럼 음수 여백이 붙은 문단은 앞 문단 위로 올라가 겹치거나 화면 밖으로 사라져(margin-left:-9999px), + // 편집기에서도 읽기 창에서도 글이 가려졌습니다. 양수 여백·들여쓰기는 그대로 둡니다. + if (/^margin/.test(key) && /(^|[\s,(])-\.?\d/.test(v)) continue; + if (STYLE_KEYS.has(key) && v.length < 160 && /^[a-zA-Z0-9#(),.% "'_-]+$/.test(v) && !/url|expression|javascript|behavior|var\(/i.test(v)) styles.push((key === 'height' && !KEEP_HEIGHT.has(tag) ? 'min-height' : key) + ':' + v); + } + if (styles.length) clean.push(`style="${escapeHtml(styles.join(';'))}"`); + } + if (NUMERIC.has(name) && /^[0-9]{1,4}%?$/.test(value)) clean.push(`${name}="${value}"`); + if (SIMPLE.has(name) && /^[a-zA-Z0-9# ,'-]{1,100}$/.test(value)) clean.push(`${name}="${escapeHtml(value)}"`); + if (name === 'alt' || name === 'title') clean.push(`${name}="${escapeHtml(value.slice(0, 300))}"`); + } + if (tag === 'a') clean.push('target="_blank"', 'rel="noopener noreferrer"'); + return clean; +} +/** + * 허용 목록 방식 HTML 정제. + * @param {string} input 정제할 HTML + * @param {{limit?: number}} [options] limit: 받는 최대 글자 수. 넘으면 message_too_large 를 던집니다. + * 기본 40만 자는 화면 입력(미리 보기 정제 API)용입니다. 받은 메일·초안처럼 큰 HTML(뉴스레터 등)이 정상인 경로는 + * 더 큰 한도를 넘기거나 store.mjs 의 safeContent 로 감싸 예외 대신 텍스트로 대체합니다. + * @returns {{html:string,text:string}} + */ +export function sanitizeHtml(input, { limit = 400000 } = {}) { + const html = String(input || ''); + if (html.length > limit) throw new Error('message_too_large'); + const parts = [], text = []; let depth = 0, i = 0; + const re = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<![^>]*>|<\?[^>]*>|<\/([a-zA-Z][a-zA-Z0-9:-]*)\s*>|<([a-zA-Z][a-zA-Z0-9:-]*)((?:\s+[^\s"'<>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>`]+))?)*)\s*\/?>/g; + let m; + while ((m = re.exec(html))) { + if (m.index > i) { const data = html.slice(i, m.index); if (!depth) { const decoded = decodeEntities(data); parts.push(escapeHtml(decoded)); text.push(decoded); } } + i = re.lastIndex; + const token = m[0]; + if (token.startsWith('<!') || token.startsWith('<?')) continue; + if (m[1]) { // end tag + const tag = m[1].toLowerCase(); + if (BLOCKED.has(tag)) { if (!UNWRAP_BLOCKED.has(tag) && !RAW_TEXT_BLOCKED.has(tag)) depth = Math.max(0, depth - 1); continue; } + if (depth) continue; + if (ALLOWED.has(tag) && !VOID.has(tag)) parts.push(`</${tag}>`); + if (['p', 'div', 'br', 'li', 'h1', 'h2', 'h3', 'h4', 'tr', 'blockquote', 'pre'].includes(tag)) text.push('\n'); + continue; + } + const tag = m[2].toLowerCase(); + if (depth && BREAKOUT.has(tag)) depth = 0; // svg/math 범위 안의 HTML 시작 태그: 브라우저처럼 외래 내용을 끝내고 그 태그부터 보통 HTML 로 처리합니다. + if (tag === 'body') continue; // 본문 시작 태그 자체는 쓰지 않습니다. + if (BLOCKED.has(tag)) { + if (VOID_BLOCKED.has(tag) || UNWRAP_BLOCKED.has(tag) || token.endsWith('/>')) continue; + if (RAW_TEXT_BLOCKED.has(tag)) { + // 닫는 태그까지의 내용(스크립트·스타일 본문)을 통째로 건너뜁니다. 닫는 태그가 없으면 열린 범위를 만들지 않습니다(뒤의 본문 보존). + const close = html.slice(re.lastIndex).search(new RegExp(`</${tag}\\s*>`, 'i')); + if (close >= 0) { const after = re.lastIndex + close; const endTag = html.slice(after).match(/^<\/[a-zA-Z0-9:-]+\s*>/); re.lastIndex = after + (endTag ? endTag[0].length : 0); } + else { const next = html.indexOf('<', re.lastIndex); re.lastIndex = next < 0 ? html.length : next; } // 닫는 태그가 없으면 바로 뒤의 글자(제목·스크립트 본문)만 버리고 다음 태그부터 이어 갑니다. + i = re.lastIndex; continue; + } + depth++; continue; + } + if (depth || !ALLOWED.has(tag)) continue; + const attrs = cleanAttrs(tag, parseAttrs(m[3] || '')); + parts.push(`<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>`); + if (tag === 'br') text.push('\n'); + } + if (i < html.length && !depth) { const decoded = decodeEntities(html.slice(i)); parts.push(escapeHtml(decoded)); text.push(decoded); } + return { html: parts.join(''), text: text.join('').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() }; +} +export function textToHtml(value) { return `<p>${escapeHtml(value || '').replaceAll('\n', '<br>')}</p>`; } +export function imageSources(html) { return [...String(html || '').matchAll(/<img\b[^>]*\bsrc="([^"]+)"/g)].map((x) => decodeEntities(x[1])); } diff --git a/modules/arimail/server/setup.mjs b/modules/arimail/server/setup.mjs new file mode 100644 index 0000000..4cdf453 --- /dev/null +++ b/modules/arimail/server/setup.mjs @@ -0,0 +1,230 @@ +/** + * 전체 설정 화면(1.0.7, 요구사항 2)·초기 설정 페이지(1.0.0, 요구사항 4) — 운영 설정(config.json)과 도메인 등록부(domains.json)를 웹에서 다루는 화면과 API. + * + * 두 곳에서 같은 화면·API 를 씁니다. + * 1) 전용 포트(SETUP_PORT, 기본 8791): 배포 스크립트가 출력하는 주소 http(s)://<기본 도메인 호스트>:8791/?token=<토큰> 으로 엽니다. 이것이 "전체 설정" 입니다. + * 로그인 계정이 아직 없거나 nginx·인증서가 준비되기 전에도 쓸 수 있어야 하므로, 데이터 디렉터리의 setup-token 파일(배포 계정만 읽음)로 인증합니다. + * 메일 포트 인증서(POP_TLS_CERT)를 읽을 수 있으면 같은 인증서로 HTTPS 를, 없으면 HTTP 를 씁니다(주소는 로그·setup-url.txt 에 남김). + * 1.0.7: 도메인 목록·추가·중지·삭제(/api/setup/domains)와, ?domain=<도메인> 으로 고른 도메인의 설정(아래 config·plan…)을 모두 여기서 합니다. + * 요구사항대로 기본 도메인의 주소로 열어야 하며, 추가 도메인의 호스트 이름으로 이 포트에 오면 안내 화면만 줍니다. + * 2) 운영 중 수정: 관리자 페이지(/admin)의 "메일 서비스 설정" → /setup. 관리자 세션으로 /api/setup/* 를 부릅니다(api.mjs 가 이 모듈의 setupApi 로 넘김). + * 이쪽은 그 도메인(Host 로 고른 테넌트)의 설정만 다루며 도메인 관리는 할 수 없습니다. + * + * API(/api/setup/…) — 전용 포트에서는 ?domain=<도메인> 으로 대상 도메인을 고릅니다(없으면 기본 도메인) + * GET config 현재 설정(비밀값은 가림)·출처·스키마 + 메타(호스트·도메인·웹훅 주소·Haraka 상태·DNS 레코드·발신 서비스 상태) + * PUT config { values: { 키: 값 } } 저장 → 즉시 적용(재시작 없음) → GET 과 같은 응답 + * POST check { what: 'outbound' } 발신 서비스 연결 확인(캐시 무시) + * POST dkim { force?: true } DKIM 키 생성(이미 있으면 force 일 때만 교체) → DNS 레코드 + * GET plan / POST plan/run / POST dns/probe 자동 설정(1.0.5, provision.mjs) + * POST restart systemd 아래에서 돌고 있을 때만: 응답 뒤 프로세스를 끝내 systemd(Restart=always)가 다시 띄움 + * GET/POST domains, PATCH/DELETE domains/<도메인> 도메인 등록부(1.0.7, 전용 포트 전용) — domains.mjs + */ +import { createServer as createHttpServer } from 'node:http'; +import { createServer as createHttpsServer } from 'node:https'; +import { readFileSync, existsSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { networkInterfaces } from 'node:os'; +import { json, bad, safeEqual, escapeHtml, secret } from './util.mjs'; +import { parseAllowList, invalidAllowEntries } from './usage.mjs'; +import { publicConfig, saveConfig, generateDkim, dnsRecords, setupToken, configDir, configFile, harakaHostname, mailDomain, harakaDir, harakaStatus, maskSecret } from './config.mjs'; +import { buildPlan, runPlan } from './provision.mjs'; +import { probeDns } from './dns/index.mjs'; +import { outboundStatus, inboundMode, outboundMode, usesHaraka, usesResend } from './providers/index.mjs'; +import { noServicePage, normalizeHost } from './domains.mjs'; + +/** systemd 서비스 안에서 돌고 있는지(부모가 PID 1 이고 INVOCATION_ID 가 있음). 재시작 단추를 보일지 정합니다. */ +export const underSystemd = () => process.ppid === 1 && !!process.env.INVOCATION_ID; +// harakaDir / harakaStatus 는 1.0.5 에서 config.mjs 로 옮겼습니다(provision.mjs 와 공유). 예전 import 경로를 위해 다시 내보냅니다. +export { harakaDir, harakaStatus }; + +/** 이 서버의 공개 IPv4 후보(초기 설정 주소 안내용). 루프백·내부 주소는 뺍니다. */ +export function serverAddresses() { + const out = []; + for (const list of Object.values(networkInterfaces())) for (const a of list || []) if (a.family === 'IPv4' && !a.internal) out.push(a.address); + return out; +} + +/** 설정 화면이 쓰는 메타 정보. 비용이 있는 발신 상태 확인은 60초 캐시(outboundStatus)를 씁니다. */ +async function meta(env) { + const host = env.PUBLIC_HOST || 'mail.arihakdang.com'; + return { + // loginMode(1.0.8): standalone | www | webmaker. publicPath: 이 도메인 첫 호스트의 경로 접두사('' 또는 '/mail'). + host, mailbox: String(env.MAILBOX || ''), domain: mailDomain(env), harakaHost: harakaHostname(env), version: env.APP_VERSION || null, loginMode: env.LOGIN_MODE === 'webmaker' && env.WEBMAKER_DIR ? 'webmaker' : env.WWW_DB_FILE ? 'www' : 'standalone', publicPath: env.PUBLIC_PATH || '', + webhookUrl: `https://${host}${env.PUBLIC_PATH || ''}/api/webhooks/resend`, configFile: configFile(env), setupPort: Number(env.SETUP_PORT ?? 8791), gatewayPort: Number(env.PORT || 8790), + inbound: inboundMode(env), outbound: outboundMode(env), usesResend: usesResend(env), usesHaraka: usesHaraka(env), + haraka: harakaStatus(env), dns: dnsRecords(env), outboundStatus: await outboundStatus(env).catch((e) => ({ provider: e.message })), + systemd: underSystemd(), tlsActive: !!env.POP_TLS_ACTIVE, + // 1.0.7: 이 설정이 기본 도메인의 것인지(Haraka 포트 같은 서버 공통 값은 기본 도메인만 바꿀 수 있음), 메일 프로그램이 접속할 서버 이름(기본 도메인의 호스트). + primary: !env.TENANTS || env.TENANTS.primary.env === env, serverHost: env.MAIL_SERVER_HOST || host, domains: env.TENANTS ? env.TENANTS.all().length : 1, + // 사용량 API(1.0.9): 키는 기본 도메인 설정에만 있으므로 기본 도메인 env 로 상태를 봅니다. 키 자체는 주지 않고 있음/없음과 가린 값, 허용 IP, 주소만. + usageApi: (() => { const p = env.TENANTS ? env.TENANTS.primary.env : env; const key = String(p.USAGE_API_KEY || ''); return { enabled: !!key, keyMasked: key ? maskSecret(key) : '', allow: parseAllowList(p.USAGE_API_ALLOW), endpoint: `https://${normalizeHost(p.PUBLIC_HOST || host)}${p.PUBLIC_PATH || ''}/api/usage`, updatedAt: p.CONFIG?._meta?.usageKeyAt || null }; })(), + }; +} + +/** + * /api/setup/* 처리(인증은 호출자가 끝낸 뒤 부릅니다: 전용 서버는 토큰, api.mjs 는 관리자 세션). + * @param {string[]} path 'setup' 뒤의 경로 조각 + */ +export async function setupApi(env, request, path, { ip = '', user = null, tenants = null } = {}) { + const method = request.method, action = path[0] || ''; + const respond = async () => json({ ok: true, ...publicConfig(env), meta: await meta(env) }); + // 도메인 등록부(1.0.7): 전용 포트(토큰)에서만. 관리자 세션(/setup)은 자기 도메인만 다룹니다. + if (action === 'domains') { if (!tenants) return bad('not_found', 404); return await domainsApi(tenants, request, path.slice(1), ip); } + if (action === 'config' && method === 'GET') return respond(); + if (action === 'config' && method === 'PUT') { + const body = await request.json().catch(() => null); if (!body || typeof body !== 'object' || typeof body.values !== 'object') return bad('bad_request'); + // 사용량 API 허용 IP(1.0.9): 잘못된 항목이 하나라도 있으면 저장하지 않습니다(오타 하나로 모든 호출이 403 이 되는 일 방지). + if (body.values.USAGE_API_ALLOW !== undefined) { const badEntries = invalidAllowEntries(body.values.USAGE_API_ALLOW); if (badEntries.length) return bad('validation', 422, { field: 'USAGE_API_ALLOW', detail: badEntries.join(', ') }); } + try { saveConfig(env, body.values); } catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; } + console.log(`설정 저장(${user ? user.login : 'setup-token'} ${ip}): ${Object.keys(body.values).join(', ')} → 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`); + return respond(); + } + if (action === 'check' && method === 'POST') { + const body = await request.json().catch(() => ({})); + if (body.what === 'outbound') return json({ ok: true, outboundStatus: await outboundStatus(env, { force: true }) }); + if (body.what === 'haraka') return json({ ok: true, haraka: harakaStatus(env) }); + return bad('bad_request'); + } + // 자동 설정(1.0.5, 요구사항 2·4·5): 계획(단계 목록·상태) 조회와 실행. 실행은 전부(자동 가능한 단계) 또는 한 단계(step)이며, 사용자 입력(input)은 먼저 설정에 저장합니다. + if (action === 'plan' && !path[1] && method === 'GET') return json({ ok: true, plan: await buildPlan(env) }); + if (action === 'plan' && path[1] === 'run' && method === 'POST') { + const body = await request.json().catch(() => ({})); + try { const result = await runPlan(env, { step: body.step || null, stepAction: body.action || null, input: body.input && typeof body.input === 'object' ? body.input : {} }); console.log(`자동 설정 실행(${user ? user.login : 'setup-token'} ${ip}): ${body.step || '전체'}${body.action ? '/' + body.action : ''} → ${result.log.map((l) => l.id + ':' + l.result).join(', ') || '변화 없음'}`); return json({ ok: true, ...result }); } + catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; } + } + if (action === 'dns' && path[1] === 'probe' && method === 'POST') return json({ ok: true, probe: await probeDns(env) }); + // 사용량 API 키(1.0.9): { action:'generate' } 새 키를 만들어(있으면 교체) 한 번만 돌려줍니다 · { action:'revoke' } 키를 지워 API 를 끕니다. 기본 도메인에서만(키는 서버 하나에 하나). + if (action === 'usage-key' && method === 'POST') { + const primary = env.TENANTS ? env.TENANTS.primary.env : env; + if (primary !== env) return bad('primary_only', 409); + const body = await request.json().catch(() => ({})); + if (body.action === 'revoke') { saveConfig(env, { USAGE_API_KEY: '' }, { allowHidden: true }); console.log(`사용량 API 키 삭제(${user ? user.login : 'setup-token'} ${ip})`); return json({ ok: true, enabled: false }); } + if (body.action !== 'generate') return bad('bad_request'); + const key = 'au_' + secret(32); + // _meta.usageKeyAt: 언제 만든 키인지(화면 안내). saveConfig 의 _meta 갱신 뒤에 덧붙입니다. + saveConfig(env, { USAGE_API_KEY: key }, { allowHidden: true }); env.CONFIG._meta = { ...(env.CONFIG._meta || {}), usageKeyAt: new Date().toISOString() }; saveConfig(env, {}); + console.log(`사용량 API 키 생성(${user ? user.login : 'setup-token'} ${ip})`); + return json({ ok: true, enabled: true, key, endpoint: `https://${normalizeHost(env.PUBLIC_HOST || 'mail.arihakdang.com')}${env.PUBLIC_PATH || ''}/api/usage` }); + } + if (action === 'dkim' && method === 'POST') { const body = await request.json().catch(() => ({})); return json({ ok: true, dns: generateDkim(env, { force: !!body.force }) }); } + if (action === 'restart' && method === 'POST') { + // systemd 서비스로 돌 때만(부모가 PID 1). INVOCATION_ID 는 사용자 셸에도 있을 수 있어 쓰지 않습니다. 개발 실행·시험에서는 거절합니다. + if (!underSystemd()) return bad('restart_unavailable', 409); + console.log(`재시작 요청(${user ? user.login : 'setup-token'} ${ip}) — systemd 가 다시 띄웁니다`); + setTimeout(() => process.exit(0), 500).unref(); + return json({ ok: true, restarting: true }); + } + return bad('not_found', 404); +} + +/** + * 도메인 등록부 API(1.0.7, 요구사항 2). 전용 포트(토큰)에서만 부릅니다. + * GET domains 목록(기본 도메인 먼저; 계정·메일 수, 수·발신 방식, 로그인 방식, 사용 여부) + * POST domains { domain, hosts?, mailbox?, auth?:{mode:'local'|'www', wwwDbFile?, ownerLogin?}, adminPassword?, copyFromPrimary?, note? } → 추가(즉시 동작) + * PATCH domains/<도메인> { enabled?, hosts?, auth?, note? } → 변경(기본 도메인은 hosts·note 만) + * DELETE domains/<도메인> 등록 해제(데이터 디렉터리는 남김). 기본 도메인은 불가 + * 오류: validation(422, field) · domain_exists(409) · host_taken(409, detail) · www_db_unreadable(422) · primary_domain(409) · not_found(404) + */ +async function domainsApi(tenants, request, path, ip) { + const method = request.method, target = path[0] ? decodeURIComponent(path[0]) : ''; + const list = async () => ({ domains: await tenants.list(), primary: tenants.registry.primary, serverHost: tenants.primary.env.MAIL_SERVER_HOST || '' }); + try { + if (method === 'GET' && !target) return json({ ok: true, ...(await list()) }); + if (method === 'POST' && !target) { + const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request'); + const r = await tenants.add(body); + console.log(`도메인 추가(setup-token ${ip}): ${r.entry.domain}`); + return json({ ok: true, domain: r.entry, initialAdmin: r.initialAdmin, ...(await list()) }, 201); + } + if (method === 'PATCH' && target) { + const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request'); + const entry = tenants.update(target, body); + console.log(`도메인 변경(setup-token ${ip}): ${entry.domain} ${JSON.stringify(Object.keys(body))}`); + return json({ ok: true, domain: entry, ...(await list()) }); + } + if (method === 'DELETE' && target) { const r = tenants.remove(target); console.log(`도메인 삭제(setup-token ${ip}): ${r.domain}`); return json({ ok: true, removed: r, ...(await list()) }); } + return bad('not_found', 404); + } catch (e) { + const status = e.message === 'validation' || e.message === 'www_db_unreadable' || e.message === 'webmaker_dir_unreadable' ? 422 : ['domain_exists', 'host_taken', 'primary_domain'].includes(e.message) ? 409 : e.message === 'not_found' ? 404 : 500; + if (status === 500) console.error('도메인 등록부 오류', e); + return bad(status === 500 ? 'server_error' : e.message, status, { ...(e.field ? { field: e.field } : {}), ...(e.detail ? { detail: e.detail } : {}) }); + } +} + +/** + * 초기 설정 전용 HTTP(S) 서버. 화면 파일(web/setup.html·setup.js·setup.css)과 토큰 인증 API 만 제공합니다. + * @param {object} o + * @param {string} o.webDir web/ 디렉터리 + * @param {object|null} [o.tlsOptions] { key, cert } 가 있으면 HTTPS + * @param {object|null} [o.tenants] 도메인 관리자(domains.mjs openTenants). 있으면 전체 설정(도메인 목록·?domain= 선택)으로 동작합니다(1.0.7). + */ +export function createSetupServer(env, { webDir, tlsOptions = null, tenants = null }) { + const SECURITY = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'X-Robots-Tag': 'noindex, nofollow', 'Cache-Control': 'no-store' }; + const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; + const STATIC = { '/setup.js': 'application/javascript; charset=utf-8', '/setup.css': 'text/css; charset=utf-8', '/favicon.svg': 'image/svg+xml' }; + let server = null, bound = null; const secure = !!tlsOptions; + const handler = async (req, res) => { + const url = new URL(req.url, 'http://x'); const ip = req.socket.remoteAddress || ''; + try { + // 1.0.7: 전체 설정은 기본 도메인의 주소로 엽니다. 추가 도메인의 호스트 이름으로 오면(인증서도 맞지 않음) 안내만 줍니다. IP·localhost 는 기본 도메인으로 봅니다. + if (tenants) { const r = tenants.resolveHost(String(req.headers.host || '')); if (r.tenant && r.tenant !== tenants.primary) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY }); return res.end(noServicePage({ host: r.host, strings: { domainMissingTitle: '전체 설정은 기본 도메인 주소로 엽니다', domainMissing: `이 주소(${r.host})로는 전체 설정 화면을 열 수 없습니다.`, domainAskAdmin: `기본 도메인의 주소 https://${normalizeHost(env.PUBLIC_HOST || '')}:${bound?.port || Number(env.SETUP_PORT ?? 8791)}/ 로 여세요(배포 출력에 토큰 포함 주소가 있습니다).` }, version: env.APP_VERSION })); } } + if (url.pathname === '/' || url.pathname === '/index.html') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(setupPage(env, webDir, 'token')); } + if (url.pathname === '/favicon.ico') { res.writeHead(301, { Location: '/favicon.svg' }); return res.end(); } + if (STATIC[url.pathname]) { res.writeHead(200, { 'Content-Type': STATIC[url.pathname], ...SECURITY }); return res.end(readFileSync(join(webDir, url.pathname))); } + if (url.pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, version: env.APP_VERSION || null, setup: true })); } + if (!url.pathname.startsWith('/api/setup/')) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'not_found' })); } + // 토큰 인증: 헤더(X-Setup-Token) 또는 주소(?token=). 틀리면 IP 당 15분 10회만 허용합니다(토큰 추측 방지). + const rateKey = 'setup:' + ip; + if (env.RATE?.blocked(rateKey, { limit: 10 })) { res.writeHead(429, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'rate_limited' })); } + const token = req.headers['x-setup-token'] || url.searchParams.get('token') || ''; + if (!safeEqual(String(token), setupToken(env))) { env.RATE?.hit(rateKey, { limit: 10, windowSec: 900 }); res.writeHead(401, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'setup_token_invalid' })); } + const chunks = []; let size = 0; if (!['GET', 'HEAD'].includes(req.method)) for await (const c of req) { size += c.length; if (size > 1024 * 1024) throw new Error('body_too_large'); chunks.push(c); } + const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) if (typeof v === 'string') headers.set(k, v); + const request = new Request(`${secure ? 'https' : 'http'}://${req.headers.host || 'localhost'}${req.url}`, { method: req.method, headers, body: chunks.length ? Buffer.concat(chunks) : undefined }); + // 대상 도메인(1.0.7): ?domain=<도메인> 이 있으면 그 테넌트의 env, 없으면 기본 도메인. 모르는 도메인은 404. + let target = env; const want = url.searchParams.get('domain'); + if (want && tenants) { const t = tenants.byDomain(want); if (!t) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'domain_not_found' })); } target = t.env; } + const response = await setupApi(target, request, url.pathname.slice('/api/setup/'.length).split('/').filter(Boolean), { ip, tenants }); + const out = { ...SECURITY }; response.headers.forEach((v, k) => { out[k] = v; }); + const buf = Buffer.from(await response.arrayBuffer()); res.writeHead(response.status, out); res.end(buf); + } catch (e) { + console.error('초기 설정 페이지 오류', req.method, url.pathname, e.message); + if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json', ...SECURITY }); res.end(JSON.stringify({ ok: false, error: 'server_error' })); + } + }; + return { + /** 포트를 엽니다. 실패(이미 쓰는 포트 등)는 예외로 알립니다. */ + listen({ port, host = '0.0.0.0' }) { + return new Promise((resolve, reject) => { + server = secure ? createHttpsServer({ ...tlsOptions, minVersion: 'TLSv1.2' }, handler) : createHttpServer(handler); + server.once('error', reject); + server.listen(port, host, () => { bound = { port: server.address().port, host, secure }; resolve(bound); }); + }); + }, + /** 접속 주소. HTTPS 면 인증서와 맞는 공개 호스트 이름을, HTTP 면 서버 IP 를 씁니다(DNS 가 아직 안 바뀐 새 서버에서도 열 수 있게). */ + urls(token) { + if (!bound) return []; + const scheme = bound.secure ? 'https' : 'http', host = env.PUBLIC_HOST || 'localhost'; + const hosts = bound.secure ? [host, ...serverAddresses()] : [...serverAddresses(), host]; + return [...new Set(hosts)].map((h) => `${scheme}://${h}:${bound.port}/?token=${encodeURIComponent(token)}`); + }, + /** 인증서 갱신(index.mjs 의 1시간 점검)을 반영합니다. HTTP 로 시작했으면 바꾸지 않습니다(주소 체계가 달라지므로 다음 재시작부터 HTTPS). */ + setTls(opts) { if (server && bound?.secure && opts) server.setSecureContext(opts); }, + close() { if (server) server.close(); }, + get bound() { return bound; }, + }; +} + +/** + * 설정 화면 HTML. web/setup.html 의 자리표시자를 채웁니다: __MODE__(token | admin — 인증 방식), __VERSION__, __HOST__, __TITLE__, __BASE__(1.0.8: 기준 경로, 보통 "/"). + * index.mjs 가 /setup 에서(경로 접두사 아래면 base 가 "/mail/"), 전용 서버가 / 에서 같은 함수를 씁니다. + */ +export function setupPage(env, webDir, mode, { base = '/' } = {}) { + const title = (env.STRINGS?.appName || 'AriMail') + ' 설정'; + return readFileSync(join(webDir, 'setup.html'), 'utf8').replace(/__MODE__/g, mode).replace(/__VERSION__/g, escapeHtml(env.APP_VERSION || '')).replace(/__HOST__/g, escapeHtml(env.PUBLIC_HOST || '')).replace(/__TITLE__/g, escapeHtml(title)).replace(/__BASE__/g, escapeHtml(base)); +} + +/** 배포 스크립트가 읽어 출력할 접속 주소 파일(데이터 디렉터리의 setup-url.txt). 쓸 수 없어도 서버는 계속 뜹니다. */ +export function writeSetupUrls(env, urls) { + try { writeFileSync(join(configDir(env), 'setup-url.txt'), urls.join('\n') + '\n', { mode: 0o600 }); } catch (e) { console.warn(' ⚠️ setup-url.txt 를 쓸 수 없습니다:', e.message); } +} diff --git a/modules/arimail/server/smtp.mjs b/modules/arimail/server/smtp.mjs new file mode 100644 index 0000000..f1c24d2 --- /dev/null +++ b/modules/arimail/server/smtp.mjs @@ -0,0 +1,446 @@ +/** + * SMTP 제출(submission) 서버 — RFC 5321 + RFC 6409(메일 제출) + RFC 3207(STARTTLS) + RFC 4954(AUTH). + * + * 왜 필요한가 + * 0.8.0 까지는 메일 프로그램(Outlook·Thunderbird·Apple Mail 등)이 Resend SMTP(smtp.resend.com)에 직접 접속했고, + * SMTP 비밀번호로 Resend API 키를 입력해야 했습니다. 키가 사용자 PC·메일 프로그램 설정에 퍼지면 노출 위험이 큽니다. + * 이제 메일 프로그램은 이 게이트웨이(mail.arihakdang.com:465/587)에 **웹 메일 로그인과 같은 아이디·비밀번호**로 인증하고, + * 게이트웨이가 받은 메일을 서버에만 보관된 RESEND_API_KEY 로 Resend API 를 호출해 실제로 발송합니다. + * API 키는 서버 환경 파일 밖으로 나가지 않습니다. + * + * 동작 요약 + * - 465: 접속 즉시 TLS(SMTPS). 587: 평문으로 접속한 뒤 STARTTLS 로 암호화(권장 제출 포트). + * - 인증(AUTH PLAIN / AUTH LOGIN)은 TLS 위에서만 받습니다(POP·IMAP 과 같은 정책, 진단용 SMTP_ALLOW_PLAINTEXT 예외). + * - 인증한 사용자는 자기 메일함 주소로만 보낼 수 있습니다(0.9.0 부터 관리자도 마찬가지 — 메일 기능에서는 자기 메일함만 씀). + * 다른 주소를 보내는 사람으로 쓰면 거부합니다(우리 키로 남의 주소를 사칭하지 못하게). + * - 받는 사람 수·첨부 크기·메일함 용량·월간 통수 제한(0.9.0, limits.mjs)을 발송 전에 검사해 SMTP 오류로 돌려줍니다. + * - DATA 로 받은 원본(.eml)을 해석해 Resend API 요청(받는 사람·참조·숨은 참조·제목·본문·첨부·인라인 이미지)으로 바꿔 보냅니다. + * - 발송에 성공하면 원본을 그대로 보낸 메일함에 저장합니다. 메일 프로그램이 IMAP 으로 같은 메일을 보낸 메일함에 + * 올려도(APPEND) Message-ID 로 한 통으로 합칩니다. + * - 이 서버는 외부에서 오는 메일을 받는 MX 가 아닙니다. 인증하지 않은 접속은 MAIL 단계에서 거부합니다(오픈 릴레이 방지). + */ +import net from 'node:net'; +import tls from 'node:tls'; +import { createHash } from 'node:crypto'; +import { verifyUser, audit } from './auth.mjs'; +import { sendMail } from './providers/index.mjs'; +import { normalizeCrlf } from './mime.mjs'; +import { parseMime, summarize, header, parseAddresses, decodeWords } from './imap-mime.mjs'; +import { appendMessage, setFlag } from './store.mjs'; +import { now, isEmail, memoryRateStore } from './util.mjs'; +import { checkOutbound, recipientLimit } from './limits.mjs'; + +const CRLF = '\r\n'; +// 한 통의 최대 크기(원본 바이트). Resend 는 base64 로 늘어난 뒤 40MB 까지 받으므로 원본 기준 30MB 로 둡니다. +export const SMTP_MAX_SIZE = 30 * 1024 * 1024; +// 한 통의 최대 받는 사람 수는 0.9.0 부터 관리자·시스템 설정(limits.recipientLimit, 내장 상한 50 = Resend 한도)을 따릅니다. +// 명령 한 줄의 최대 길이. RFC 5321 은 512 바이트지만 AUTH 초기 응답이 길 수 있어 넉넉히 둡니다. +const MAX_LINE = 4096; +// 인증 실패 요청 제한(접속 IP 기준). 비밀번호 대입을 늦춥니다. +const rate = memoryRateStore(); +const TERMINATOR = Buffer.from('\r\n.\r\n'); + +/** + * SMTP 응답 코드를 실어 나르는 오류. submitMessage 가 던지면 세션이 그대로 클라이언트에 전달합니다. + * @param {number} code 3자리 응답 코드(예: 550) + * @param {string} enhanced 확장 상태 코드(RFC 3463, 예: '5.7.1') + * @param {string} message 사람이 읽는 설명(영문, 메일 프로그램이 그대로 보여 줌) + */ +export class SmtpError extends Error { + constructor(code, enhanced, message) { super(message); this.code = code; this.enhanced = enhanced; } + get reply() { return `${this.code} ${this.enhanced} ${this.message}`; } +} + +/** + * 발송 모듈의 오류 코드(providers/resend.mjs 의 providerError, providers/smtp-client.mjs 의 smtpErrorCode)를 SMTP 응답으로 바꿉니다. + * 키·도메인 설정 문제나 일시 장애는 4xx(임시 실패)로 돌려 메일 프로그램이 나중에 다시 보내게 하고, + * 내용이 거부된 경우만 5xx(영구 실패)로 돌려 사용자가 고치게 합니다. 응답에 키 관련 정보는 넣지 않습니다. + */ +function providerReply(e) { + if (e instanceof SmtpError) return e; + switch (e?.message) { + case 'mail_rejected': return new SmtpError(550, '5.6.0', 'Message rejected by the mail service' + (e.providerMessage ? ': ' + String(e.providerMessage).replace(/[\r\n]+/g, ' ').slice(0, 200) : '')); + case 'mail_rate_limited': return new SmtpError(451, '4.7.0', 'Sending rate limit reached, try again shortly'); + default: return new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later'); + } +} + +/** "이름 <주소>" 형식 문자열. 이름이 없으면 주소만. 이름에 따옴표·꺾쇠가 있으면 따옴표로 감쌉니다. */ +function displayAddress(name, addr) { + if (!name) return addr; + const clean = String(name).replace(/[\r\n]+/g, ' ').trim(); + return /[",<>()@;:]/.test(clean) ? `"${clean.replace(/["\\]/g, '\\$&')}" <${addr}>` : `${clean} <${addr}>`; +} +/** parseAddresses 결과({name, mailbox, host})를 소문자 주소와 표시 문자열로 바꿉니다. */ +const toEntries = (value) => parseAddresses(value).filter((a) => a.host).map((a) => { const addr = `${a.mailbox}@${a.host}`.toLowerCase(); return { addr, display: displayAddress(a.name, addr) }; }); + +/** + * 이 사용자가 보내는 사람으로 쓸 수 있는 주소 목록: 자기 메일함 하나뿐입니다. + * 0.8.x 까지는 관리자가 게이트웨이의 모든 메일함 주소로 보낼 수 있었지만(웹에서도 모든 메일함을 열 수 있었음), 0.9.0 에서 관리자의 + * 메일함 전환 기능을 없애면서 발신 주소도 자기 메일함으로 좁혔습니다. 목록 형태는 호출부 호환을 위해 유지합니다. + */ +export async function allowedSenders(env, user) { return [user.mailbox]; } +/** limits.checkOutbound 의 오류 코드를 SMTP 응답으로 바꿉니다. 용량·통수 제한은 임시(4xx)/영구(5xx) 성격에 맞춰 돌려줍니다. */ +function limitReply(e) { + switch (e?.message) { + case 'too_many_recipients': return new SmtpError(452, '4.5.3', `Too many recipients (limit ${e.limit})`); + case 'attachment_too_large': return new SmtpError(552, '5.3.4', `Attachment exceeds the size limit (${e.limit} bytes after MIME encoding)`); + case 'quota_exceeded': return new SmtpError(452, '4.2.2', 'Mailbox full (quota exceeded), free space and try again'); + case 'storage_full': return new SmtpError(452, '4.3.1', 'Insufficient system storage'); + case 'monthly_limit_exceeded': return new SmtpError(550, '5.7.1', `Monthly message limit reached (${e.limit})`); + default: return null; + } +} + +/** + * 인증된 사용자가 DATA 로 보낸 원본을 Resend API 로 발송하고 보낸 메일함에 저장합니다. + * + * @param {object} env + * @param {object} user verifyUser 가 돌려준 사용자(id, login, name, role, mailbox) + * @param {object} envelope + * @param {string} envelope.mailFrom MAIL FROM 주소(빈 문자열 = 반송 주소 없음 "<>") + * @param {string[]} envelope.rcpt RCPT TO 주소들(실제로 배달할 대상, 소문자) + * @param {Buffer} envelope.eml DATA 원본(점 스터핑을 푼 RFC 5322 메시지) + * @param {string} [envelope.ip] 접속 IP(감사 기록용) + * @returns {Promise<{id:string, providerId:string}>} 저장한 메일 id 와 Resend 발송 id + * @throws {SmtpError} 클라이언트에 돌려줄 SMTP 오류 + */ +export async function submitMessage(env, user, { mailFrom = '', rcpt = [], eml, ip = '' }) { + if (!env.RESEND_API_KEY) throw new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later'); + const raw = normalizeCrlf(eml); + let parsed, info; + try { parsed = parseMime(raw); info = summarize(parsed); } catch { throw new SmtpError(550, '5.6.0', 'Malformed message'); } + const headers = parsed.root.headers; + + // 1) 보내는 사람 확인: 헤더 From 과 봉투 MAIL FROM 이 모두 이 사용자에게 허용된 주소여야 합니다. + const allowed = await allowedSenders(env, user); + const fromEntry = toEntries(header(headers, 'from'))[0]; + const fromAddr = fromEntry?.addr || mailFrom || user.mailbox; + if (!allowed.includes(fromAddr) || (mailFrom && !allowed.includes(mailFrom))) throw new SmtpError(550, '5.7.1', `Sender address not allowed for this account (use ${user.mailbox})`); + // From 헤더가 없으면 계정 이름으로 채웁니다. 이름만 비어 있으면 계정 이름을 씁니다. + const from = fromEntry && fromEntry.display !== fromEntry.addr ? fromEntry.display : displayAddress(user.name || '', fromAddr); + + // 2) 받는 사람 나누기. 실제 배달 대상은 봉투(RCPT)입니다. 헤더의 To/Cc 중 봉투에 있는 주소만 to/cc 로 두고, + // 봉투에만 있는 주소는 숨은 참조(bcc)로 보냅니다(메일 프로그램은 Bcc 헤더를 지우고 RCPT 로만 알려 주기 때문). + const rcptSet = new Set(rcpt); + const to = toEntries(header(headers, 'to')).filter((a) => rcptSet.has(a.addr)); + const cc = toEntries(header(headers, 'cc')).filter((a) => rcptSet.has(a.addr) && !to.some((t) => t.addr === a.addr)); + const listed = new Set([...to, ...cc].map((a) => a.addr)); + const bcc = rcpt.filter((a) => !listed.has(a)); + // Resend 는 to 가 꼭 있어야 합니다. 모두 숨은 참조로 보낸 경우(예: "받는 사람 없음" 단체 메일)에는 + // 보내는 사람 자신을 to 로 둡니다. 받는 사람들에게는 서로의 주소가 보이지 않고, 보낸 사람에게 사본이 한 통 갑니다. + const toList = to.length ? to.map((a) => a.display) : [from]; + + // 3) Resend 요청 만들기 + const replyTo = toEntries(header(headers, 'reply-to')).map((a) => a.display); + const custom = {}; + for (const name of ['In-Reply-To', 'References']) { const v = header(headers, name); if (v) custom[name] = String(v).replace(/[\r\n]+/g, ' ').slice(0, 2000); } + const payload = { + from, to: toList, ...(cc.length ? { cc: cc.map((a) => a.display) } : {}), ...(bcc.length ? { bcc } : {}), ...(replyTo.length ? { reply_to: replyTo } : {}), + subject: info.subject || '', ...(info.html ? { html: info.html } : {}), text: info.text || (info.html ? undefined : ' '), + ...(Object.keys(custom).length ? { headers: custom } : {}), + }; + if (payload.text === undefined) delete payload.text; + const cidUsed = new Set([...String(info.html || '').matchAll(/\bsrc\s*=\s*["']?cid:([^"'\s>]+)/gi)].map((x) => x[1].replace(/&/g, '&'))); + if (info.attachments.length) payload.attachments = info.attachments.map((a) => ({ + filename: decodeWords(a.filename || '') || 'attachment', content: Buffer.from(a.bytes).toString('base64'), content_type: a.contentType, + // 본문에 cid: 로 끼워 넣은 이미지는 content_id 를 그대로 넘겨 받는 쪽에서도 본문 안에 보이게 합니다. + // Content-Disposition 없이 Content-ID 만 있는 파트(Outlook·Exchange 류)도 본문이 cid: 로 가리키면 인라인입니다(0.9.0 수정, 예전에는 깨진 그림 + 별도 첨부로 갔음). + ...(a.contentId && (a.disposition === 'inline' || cidUsed.has(a.contentId)) ? { content_id: a.contentId } : {}), + })); + // 같은 메일을 메일 프로그램이 다시 보내는 경우(응답을 못 받고 재전송) 두 번 발송되지 않게, 사용자 + 원본 바이트로 멱등 키를 만듭니다. + // 같은 내용을 일부러 다시 보내도 메일 프로그램이 Date·Message-ID 를 새로 만들므로 키가 달라 정상 발송됩니다. + // 키에는 봉투(MAIL FROM, RCPT TO 집합)도 넣습니다. 예전에는 사용자+원본만 써서, 같은 DATA 를 받는 사람만 바꿔 따로 보내는 경우 + // (메일 머지·"개별 발송")에 키가 겹쳐 두 번째 받는 사람에게 가지 않았습니다(0.8.25 수정). + const key = 'smtp-' + createHash('sha256').update(user.id + '\n' + (mailFrom || '') + '\n' + [...rcptSet].sort().join(',') + '\n').update(raw).digest('hex').slice(0, 48); + // 제한 검사(0.9.0): 받는 사람 수·첨부 크기(MIME 기준)·메일함 용량(원본 크기만큼 늘어남)·월간 통수. 발송 전에 하므로 어긋나면 아무것도 보내지 않습니다. + try { await checkOutbound(env, { mailbox: fromAddr, recipients: rcpt.length, attachmentSizes: info.attachments.map((a) => a.bytes.length), bytes: raw.length }); } + catch (e) { const reply = limitReply(e); if (reply) throw reply; throw e; } + + let result; + // 1.0.0: 발송 모듈(Resend / 내장 Haraka / 외부 SMTP 중계)은 설정이 정합니다. 어느 쪽이든 같은 오류 코드로 실패하므로 SMTP 응답 변환(providerReply)은 그대로입니다. + try { result = await sendMail(env, payload, key); } catch (e) { throw providerReply(e); } + + // 4) 보낸 메일함 저장. 메일 프로그램이 IMAP 으로 먼저 올린 사본(같은 Message-ID, 발송 id 없음)이 있으면 그 메일에 발송 id 만 붙입니다. + const stamp = now(); + let id = null; + if (info.messageId) id = (await env.DB.prepare("SELECT id FROM messages WHERE mailbox=? AND folder='sent' AND message_id=? AND provider_id IS NULL").bind(fromAddr, info.messageId).first())?.id || null; + if (!id) id = (await appendMessage(env, 'sent', raw, { flags: ['\\Seen'], internalDate: stamp, mailbox: fromAddr, enforceQuota: false })).id; + // provider_id 를 채워 두면 정기 동기화(ingestSent)가 같은 메일을 다시 가져오지 않습니다. + await env.DB.prepare("UPDATE messages SET provider_id=?,sent_at=?,state='sent',bcc_json=?,updated_at=? WHERE id=?").bind(result.id, stamp, JSON.stringify(bcc), stamp, id).run(); + // 답장이면 원래 메일에 \Answered(답장함) 표시를 남깁니다(웹 발송과 같은 규칙). + if (custom['In-Reply-To']) { const parent = await env.DB.prepare('SELECT id FROM messages WHERE message_id=? AND mailbox=?').bind(custom['In-Reply-To'], fromAddr).first(); if (parent) await setFlag(env, parent.id, '\\Answered', true); } + await audit(env, user.login, 'smtp_send', id, { ip, recipients: rcpt.length }); + return { id, providerId: result.id }; +} + +/** + * DATA 본문 수집기. 받은 바이트를 조각으로 모으며 끝 표시(<CRLF>.<CRLF>)를 찾습니다. + * 큰 첨부(수십 MB)를 받을 때 매번 버퍼를 이어 붙이면 복사량이 제곱으로 늘어나므로, 조각 목록과 마지막 4바이트(tail)만 유지합니다. + * 끝 표시가 조각 경계에 걸쳐도 tail 과 새 조각을 붙여 찾으므로 놓치지 않습니다. + */ +export class DataCollector { + // tail 을 CRLF 로 시작하는 이유: DATA 명령 줄 끝의 CRLF 가 본문 바로 앞에 있으므로, 본문이 비어 있어 + // 첫 줄이 곧바로 "." 인 경우(".\r\n")도 같은 끝 표시(<CRLF>.<CRLF>) 규칙으로 찾을 수 있습니다. 이 CRLF 는 본문에 넣지 않습니다. + constructor(max) { this.parts = []; this.size = 0; this.tail = Buffer.from(CRLF); this.max = max; this.tooBig = false; } + /** + * @param {Buffer} chunk 새로 받은 바이트 + * @returns {null | {message: Buffer|null, rest: Buffer}} 끝 표시를 찾으면 원본(크기 초과면 null)과 그 뒤에 남은 바이트 + */ + feed(chunk) { + const combined = Buffer.concat([this.tail, chunk]); + const idx = combined.indexOf(TERMINATOR); + if (idx < 0) { + this.push(chunk); + this.tail = Buffer.from(combined.subarray(Math.max(0, combined.length - 4))); + return null; + } + // 본문은 끝 표시의 앞 CRLF 까지 포함합니다. bodyEnd 가 tail 안에 있으면(끝 표시가 이전 조각에서 시작) 이미 넣은 바이트를 덜어 냅니다. + const bodyEnd = idx + 2, fromChunk = bodyEnd - this.tail.length; + let drop = 0; + if (fromChunk >= 0) this.push(chunk.subarray(0, fromChunk)); else drop = -fromChunk; + const rest = combined.subarray(idx + TERMINATOR.length); + if (this.tooBig) return { message: null, rest }; + let message = Buffer.concat(this.parts); if (drop) message = message.subarray(0, Math.max(0, message.length - drop)); + return { message, rest }; + } + push(buf) { + this.size += buf.length; + if (this.size > this.max) { this.tooBig = true; this.parts = []; return; } // 크기를 넘으면 더 모으지 않고 끝 표시만 찾습니다. + if (!this.tooBig && buf.length) this.parts.push(Buffer.from(buf)); + } +} +/** 점 스터핑 해제(RFC 5321 §4.5.2): 줄 맨 앞의 ".." 를 "." 로 되돌립니다. 바이트를 바꾸지 않도록 latin1 로 다룹니다. */ +export function dotUnstuff(buffer) { + return Buffer.from(Buffer.from(buffer).toString('latin1').replace(/(^|\r\n)\.\./g, '$1.'), 'latin1'); +} +/** "<user@host>" 경로에서 주소만 꺼냅니다(소문자). 파라미터(SIZE=…)는 따로 돌려줍니다. */ +function parsePath(text) { + const m = String(text).match(/^\s*<([^<>]*)>\s*(.*)$/) || String(text).match(/^\s*([^\s<>]+)\s*(.*)$/); + if (!m) return null; + const params = Object.fromEntries((m[2] || '').split(/\s+/).filter(Boolean).map((p) => { const [k, v = ''] = p.split('='); return [k.toUpperCase(), v]; })); + return { address: m[1].trim().replace(/^@[^:]+:/, '').toLowerCase(), params }; // 소스 라우트(@a,@b:user@host)는 무시 +} + +/** + * 접속 한 개의 상태 기계. + * 상태: greeted(EHLO 여부) → 인증(user) → 트랜잭션(mailFrom, rcpt) → DATA 수집(collector) → 처리 후 트랜잭션 초기화. + * 입력은 pump() 가 한 번에 한 명령씩 순서대로 처리합니다. PIPELINING 으로 여러 명령이 한꺼번에 와도 + * 앞 명령(예: RCPT 확인)이 끝난 뒤 다음 명령(DATA)을 판단해야 하므로 비동기 처리를 직렬화합니다. + */ +class Session { + constructor(server, socket, { secure }) { + this.server = server; this.env = server.env; this.secure = secure; + this.buf = Buffer.alloc(0); // 아직 처리하지 않은 명령 바이트 + this.collector = null; // DATA 수집 중이면 DataCollector + this.pendingData = null; // 수집이 끝나 처리를 기다리는 DATA 결과 + this.pumping = false; this.closed = false; + this.greeted = false; this.user = null; this.failures = 0; + this.sasl = null; // 진행 중인 AUTH 대화 { mech, step, login } + this.reset(); + this.ip = socket.remoteAddress || ''; this.attach(socket); + console.log(`SMTP 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`); + this.send(`220 ${server.hostname} ESMTP ready`); + } + /** 메일 한 통의 트랜잭션 상태(MAIL FROM·RCPT TO)를 비웁니다. 인증 상태는 유지합니다. */ + reset() { this.mailFrom = null; this.rcpt = []; } + attach(socket) { + this.socket = socket; socket.setTimeout(5 * 60 * 1000); + socket.on('data', (chunk) => this.onData(chunk)); + socket.on('timeout', () => this.close('421 4.4.2 Idle timeout')); + socket.on('error', () => { this.closed = true; }); + socket.on('close', () => { this.closed = true; }); + } + send(line) { if (!this.closed) this.socket.write(line + CRLF); } + /** 여러 줄 응답: 마지막 줄만 "250 " 이고 나머지는 "250-" 입니다. */ + sendMulti(code, lines) { lines.forEach((l, i) => this.send(`${code}${i === lines.length - 1 ? ' ' : '-'}${l}`)); } + close(line) { if (line) this.send(line); this.closed = true; try { this.socket.end(); } catch {} } + onData(chunk) { + if (this.closed) return; + if (this.collector) { + // DATA 수집 중: 명령으로 해석하지 않고 수집기에 넘깁니다. + const done = this.collector.feed(chunk); + // pendingData 는 { message } 로 감쌉니다. 크기 초과면 message 가 null 인데, 예전에는 null 자체를 넣어 "처리할 것 없음" 과 구분되지 않아 + // 552 응답이 나가지 않고 세션이 멈췄습니다. + if (done) { this.collector = null; this.pendingData = { message: done.message }; this.buf = Buffer.from(done.rest); this.pump(); } + return; + } + this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : Buffer.from(chunk); + this.pump(); + } + /** 쌓인 입력을 순서대로 처리합니다. 이미 처리 중이면 그 루프가 새 입력까지 이어서 처리합니다. */ + async pump() { + if (this.pumping) return; this.pumping = true; + try { + while (!this.closed) { + if (this.pendingData) { const { message } = this.pendingData; this.pendingData = null; await this.finishData(message); continue; } + if (this.collector) { + // DATA 를 막 시작했고 같은 묶음에 본문이 이미 와 있던 경우 + if (!this.buf.length) break; + const pending = this.buf; this.buf = Buffer.alloc(0); + const done = this.collector.feed(pending); + if (!done) break; + this.collector = null; this.pendingData = { message: done.message }; this.buf = Buffer.from(done.rest); continue; + } + const i = this.buf.indexOf('\r\n'); + if (i < 0) { if (this.buf.length > MAX_LINE) this.close('500 5.5.6 Line too long'); break; } + const line = this.buf.subarray(0, i).toString('utf8'); this.buf = this.buf.subarray(i + 2); + try { await this.handle(line); } catch (e) { console.error('SMTP 오류', e?.stack || e?.message); this.send('451 4.3.0 Internal error'); } + } + } finally { this.pumping = false; } + } + async handle(line) { + if (this.sasl) return this.saslStep(line); + const sp = line.indexOf(' '); const verb = (sp < 0 ? line : line.slice(0, sp)).toUpperCase(); const arg = sp < 0 ? '' : line.slice(sp + 1).trim(); + const loginAllowed = this.secure || this.server.allowPlaintext; + switch (verb) { + case 'EHLO': case 'HELO': { + if (!arg) return this.send('501 5.5.4 Domain required'); + this.greeted = true; this.reset(); + if (verb === 'HELO') return this.send(`250 ${this.server.hostname}`); + const ext = [this.server.hostname, `SIZE ${SMTP_MAX_SIZE}`, '8BITMIME', 'PIPELINING', 'ENHANCEDSTATUSCODES']; + if (!this.secure && this.server.secureContext) ext.push('STARTTLS'); + if (loginAllowed && !this.user) ext.push('AUTH PLAIN LOGIN'); + ext.push('HELP'); + return this.sendMulti(250, ext); + } + case 'STARTTLS': { + if (this.secure) return this.send('503 5.5.1 Already using TLS'); + if (!this.server.secureContext) return this.send('454 4.7.0 TLS not available'); + this.send('220 2.0.0 Ready to start TLS'); + // RFC 3207: STARTTLS 이전 상태(EHLO·인증)는 모두 잊습니다. 평문 구간에 끼워 넣은 명령이 암호화 뒤에 실행되지 않도록 + // 남은 입력도 버립니다(명령 주입 방지). + const plain = this.socket; plain.removeAllListeners('data'); plain.removeAllListeners('timeout'); plain.removeAllListeners('error'); plain.removeAllListeners('close'); + const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext }); + this.secure = true; this.greeted = false; this.reset(); this.buf = Buffer.alloc(0); this.attach(secureSocket); return; + } + case 'AUTH': { + if (!this.greeted) return this.send('503 5.5.1 Send EHLO first'); + if (this.user) return this.send('503 5.5.1 Already authenticated'); + if (!loginAllowed) { console.log(`SMTP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send('530 5.7.0 Must issue a STARTTLS command first'); } + const [mech, initial] = arg.split(/\s+/); const m = String(mech || '').toUpperCase(); + if (m === 'PLAIN') { if (initial && initial !== '=') return this.authPlain(initial); this.sasl = { mech: 'PLAIN' }; return this.send('334 '); } + if (m === 'LOGIN') { + if (initial) { this.sasl = { mech: 'LOGIN', login: b64(initial) }; return this.send('334 UGFzc3dvcmQ6'); } // "Password:" + this.sasl = { mech: 'LOGIN' }; return this.send('334 VXNlcm5hbWU6'); // "Username:" + } + return this.send('504 5.5.4 Unrecognized authentication mechanism'); + } + case 'MAIL': { + if (!this.greeted) return this.send('503 5.5.1 Send EHLO first'); + if (!this.user) return this.send('530 5.7.0 Authentication required'); + if (this.mailFrom !== null) return this.send('503 5.5.1 Nested MAIL command'); + const path = /^FROM:/i.test(arg) ? parsePath(arg.slice(5)) : null; if (!path) return this.send('501 5.5.4 Syntax: MAIL FROM:<address>'); + if (path.params.SIZE && Number(path.params.SIZE) > SMTP_MAX_SIZE) return this.send('552 5.3.4 Message size exceeds fixed limit'); + if (path.address && !(await allowedSenders(this.env, this.user)).includes(path.address)) return this.send(`550 5.7.1 Sender address not allowed for this account (use ${this.user.mailbox})`); + this.mailFrom = path.address; return this.send('250 2.1.0 Sender OK'); + } + case 'RCPT': { + if (this.mailFrom === null) return this.send('503 5.5.1 Need MAIL command first'); + const path = /^TO:/i.test(arg) ? parsePath(arg.slice(3)) : null; if (!path || !isEmail(path.address)) return this.send('501 5.1.3 Bad recipient address syntax'); + // 이미 봉투에 있는 주소(받는 사람과 참조에 같은 주소)는 세지 않고 그대로 받습니다. 예전에는 한도에서 중복 주소가 452 를 받아 메일 프로그램이 발송 전체를 포기했습니다(0.9.3). + if (this.rcpt.includes(path.address)) return this.send('250 2.1.5 Recipient OK'); + if (this.rcpt.length >= (await recipientLimit(this.env))) return this.send('452 4.5.3 Too many recipients'); + this.rcpt.push(path.address); + return this.send('250 2.1.5 Recipient OK'); + } + case 'DATA': { + if (this.mailFrom === null) return this.send('503 5.5.1 Need MAIL command first'); + if (!this.rcpt.length) return this.send('554 5.5.1 No valid recipients'); + this.collector = new DataCollector(SMTP_MAX_SIZE); + return this.send('354 Start mail input; end with <CRLF>.<CRLF>'); + } + case 'BDAT': return this.send('502 5.5.1 CHUNKING not supported'); + case 'RSET': this.reset(); return this.send('250 2.0.0 OK'); + case 'NOOP': return this.send('250 2.0.0 OK'); + case 'VRFY': return this.send('252 2.5.0 Cannot VRFY user'); + case 'HELP': return this.send('214 2.0.0 See https://' + this.server.hostname + '/'); + case 'QUIT': return this.close(`221 2.0.0 ${this.server.hostname} closing connection`); + default: return this.send('500 5.5.2 Command not recognized'); + } + } + /** AUTH 대화의 다음 줄(334 질문에 대한 base64 응답)을 처리합니다. "*" 는 취소입니다. */ + async saslStep(line) { + const s = this.sasl; this.sasl = null; + if (line.trim() === '*') return this.send('501 5.7.0 Authentication cancelled'); + if (s.mech === 'PLAIN') return this.authPlain(line.trim()); + if (s.login === undefined) { this.sasl = { mech: 'LOGIN', login: b64(line.trim()) }; return this.send('334 UGFzc3dvcmQ6'); } + return this.login(s.login, b64(line.trim())); + } + /** AUTH PLAIN: base64("authzid\0authcid\0password"). authcid(로그인 이름)와 비밀번호만 씁니다. */ + authPlain(payload) { + const parts = b64(payload).split('\0'); + if (parts.length < 3) return this.send('501 5.5.2 Invalid AUTH PLAIN response'); + return this.login(parts[1], parts.slice(2).join('\0')); + } + /** + * 웹 메일·IMAP·POP 과 같은 계정으로 인증합니다(verifyUser: www 관리자·별칭 admin·게이트웨이 계정). + * 실패하면 잠시 기다렸다 응답하고, 같은 접속에서 3번 틀리면 끊습니다. IP 당 15분에 20번까지만 시도할 수 있습니다. + */ + async login(login, password) { + if (rate.blocked('smtp:' + this.ip, { limit: 20 })) return this.close('421 4.7.0 Too many authentication attempts'); + // 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 이후 발송·저장(submitMessage)도 그 도메인의 env 로 합니다. + const env = this.server.resolveEnv ? this.server.resolveEnv(login, sniName(this.socket)) : this.env; + const user = env ? await verifyUser(env, login, password) : null; + if (user) this.env = env; + if (!user) rate.hit('smtp:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다. 메일마다 새로 접속하는 메일 프로그램이 성공 횟수로 막히지 않게. + console.log(`SMTP 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login).slice(0, 80)}${this.secure ? '' : ' (plain)'}`); + if (!user) { + this.failures++; await new Promise((r) => setTimeout(r, this.env.SMTP_AUTH_DELAY_MS ?? this.env.POP_AUTH_DELAY_MS ?? 1500)); + if (this.failures >= 3) return this.close('535 5.7.8 Authentication credentials invalid'); + return this.send('535 5.7.8 Authentication credentials invalid'); + } + this.user = user; return this.send('235 2.7.0 Authentication successful'); + } + /** DATA 수집이 끝났을 때: 크기 확인 → 점 스터핑 해제 → 발송·저장 → 결과 응답. 성공·실패와 무관하게 트랜잭션은 비웁니다. */ + async finishData(message) { + const envelope = { mailFrom: this.mailFrom || '', rcpt: this.rcpt.slice() }; this.reset(); + if (message === null) return this.send('552 5.3.4 Message size exceeds fixed limit'); + try { + const r = await submitMessage(this.env, this.user, { ...envelope, eml: dotUnstuff(message), ip: this.ip }); + console.log(`SMTP 발송 ${this.ip} user=${this.user.login} rcpt=${envelope.rcpt.length} id=${r.providerId}`); + this.send(`250 2.0.0 OK queued as ${r.providerId}`); + } catch (e) { + const reply = providerReply(e); + console.log(`SMTP 발송 실패 ${this.ip} user=${this.user.login} ${reply.code} ${e.message}`); + this.send(reply.reply); + } + } +} +/** base64 → UTF-8 문자열(잘못된 입력은 빈 문자열). */ +function b64(s) { try { return Buffer.from(String(s || ''), 'base64').toString('utf8'); } catch { return ''; } } + +/** + * SMTP 제출 서버를 만듭니다. listen() 으로 포트를 열고, 인증서가 갱신되면 setTls() 로 바꿉니다. + * @param {object} env + * @param {object} [options] + * @param {string} [options.hostname] 인사말·EHLO 에 쓸 서버 이름 + * @param {{key, cert}|null} [options.tlsOptions] 인증서 PEM(POP·IMAP 과 같은 파일). 없으면 465 를 열지 않고 587 의 STARTTLS 도 없습니다. + * @param {boolean} [options.allowPlaintext] true 면 TLS 없이도 AUTH 를 받습니다(진단용, 운영에서는 끔). + */ +/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 1.0.7 다중 도메인에서 도메인 없는 로그인 ID 의 도메인을 짐작하는 데 씁니다. */ +const sniName = (socket) => String(socket?.servername || ''); +/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작. */ +export function createSmtp(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = false, resolveEnv = null } = {}) { + const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv }; + const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); }); + // secure 서버는 인증서가 없어도 만들어 두고, 인증서가 없으면 포트를 기억만 합니다(pop3.mjs 와 같은 구조, 0.9.7). + const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); }); + secure.on('tlsClientError', () => {}); + let pendingSecure = null; + const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); })); + return { + plain, secure, + setTls(options) { + shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options); + if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'smtps', []).then(() => console.log(` SMTP TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' SMTP TLS 포트 열기 실패:', e.message)); } + }, + async listen({ plainPort, securePort, host = '0.0.0.0' }) { + const out = []; + if (plainPort) await listenOn(plain, plainPort, host, 'submission', out); + if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'smtps', out); else pendingSecure = { port: securePort, host }; } + return out; + }, + close() { plain.close(); if (secure.listening) secure.close(); }, + }; +} diff --git a/modules/arimail/server/store.mjs b/modules/arimail/server/store.mjs new file mode 100644 index 0000000..b40d8b6 --- /dev/null +++ b/modules/arimail/server/store.mjs @@ -0,0 +1,574 @@ +/** + * 메일 저장소 모듈. + * + * 하는 일 + * - 수신: Resend 웹훅·정기 동기화가 알려 준 메일을 가져와 원본(.eml)·첨부·정제 본문을 SQLite 에 저장합니다. + * - 보낸 메일: 다른 경로(www 관리자 등)로 발송된 메일도 보낸 메일함에 보이게 가져옵니다. + * - 동기화: Resend 목록을 훑어 웹훅이 놓친 메일을 보충합니다(전체 훑기 / 빠른 확인 두 가지 방식). + * - 정리: 만료된 로그인 세션을 지웁니다. + * - 초안·발송, IMAP APPEND/COPY 저장. + * + * 보관 정책(0.8.0~) + * 메일은 기한 없이 보관합니다. 사용자가 휴지통에서 "영구 삭제"할 때만 지워지며, 그때 남기는 tombstone 이 + * 동기화가 같은 메일을 다시 가져오지 않게 막습니다. 예전의 30일 자동 삭제(RETENTION_DAYS)는 없앴습니다. + * messages.expires_at 열은 이전 릴리스로 되돌릴 때 스키마가 맞도록 남겨 두었지만 더는 쓰지 않습니다(항상 NULL). + */ +import { resend, download } from './providers/resend.mjs'; +import { sendMail, inboundMode, outboundMode } from './providers/index.mjs'; +import { sanitizeHtml, textToHtml } from './sanitize.mjs'; +import { buildEml, normalizeCrlf } from './mime.mjs'; +import { now, uid, clean, isEmail, addresses, address, formatSender, nameOf } from './util.mjs'; +import { parseMime, summarize } from './imap-mime.mjs'; +import { knownMailboxes } from './auth.mjs'; +import { autoProcess } from './automation.mjs'; +import { checkOutbound, checkInbound, attachmentLimit, mimeSize, mailboxQuota, mailboxUsage, systemLimits, totalUsage } from './limits.mjs'; +import { checkLargeFileTerms, finalizeLargeFiles } from './files.mjs'; + +/** 기본 메일함 주소(환경 변수 MAILBOX, 소문자). 수신자가 비어 있는 메일과 계정 없는 발송의 기본값입니다. */ +export const mailboxOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase(); +/** 발신 표시 이름의 기본값. 계정 이름이 없을 때 "이름 <주소>" 의 이름 부분으로 씁니다. */ +const senderName = (env) => String(env.MAIL_FROM_NAME || 'Ari Hak Dang'); +// ATTACHMENT_LIMIT: 수신 첨부 한 개를 내려받는 최대 크기, RAW_LIMIT: 원본 .eml 최대 크기. 넘으면 바이트 없이 목록만 남깁니다. +const ATTACHMENT_LIMIT = 25 * 1024 * 1024, RAW_LIMIT = 40 * 1024 * 1024; +// 받은 메일·초안 HTML 의 정제 한도(글자 수). 뉴스레터는 인라인 CSS 때문에 수백 KB 가 흔해 화면 입력 한도(40만 자)보다 크게 둡니다. +const STORED_HTML_LIMIT = 5 * 1000 * 1000; + +/** + * 저장용 본문 정제. HTML 이 한도를 넘어도 예외를 던지지 않고 텍스트 본문을 HTML 로 바꿔 대신 씁니다. + * 왜: 예전에는 40만 자가 넘는 HTML 메일에서 sanitizeHtml 이 예외를 던져 수신 저장 전체가 실패했습니다. + * 웹훅은 500 을 돌려 Resend 가 계속 재시도하고, 동기화도 매번 실패해 그 메일은 영영 메일함에 들어오지 못했습니다. + * @param {string|null} html 원본 HTML(없으면 텍스트를 씀) + * @param {string} text 텍스트 본문(HTML 이 없거나 너무 클 때 대신 표시) + */ +export function safeContent(html, text) { + // 텍스트만 있는 메일도 같은 한도(STORED_HTML_LIMIT)를 씁니다. 예전에는 기본 한도(40만 자)로 정제해 40만 자가 넘는 텍스트 메일(로그 첨부 본문 등)의 저장이 실패했습니다(0.9.0 수정). + if (!html) { try { return sanitizeHtml(textToHtml(text || ''), { limit: STORED_HTML_LIMIT }); } catch { return sanitizeHtml(textToHtml(String(text || '').slice(0, STORED_HTML_LIMIT / 2)), { limit: Infinity }); } } + try { return sanitizeHtml(html, { limit: STORED_HTML_LIMIT }); } + catch { return sanitizeHtml(textToHtml(String(text || '').slice(0, STORED_HTML_LIMIT / 2)), { limit: Infinity }); } +} + +/** + * 시각 문자열을 ISO 8601 UTC(예: 2026-09-28T06:20:26.514Z)로 맞춥니다. + * + * 왜 필요한가: Resend 의 보낸 메일 목록은 created_at 을 "2026-09-28 06:20:26.514000+00" 처럼 공백과 짧은 + * 시간대 표기로 돌려줍니다. 이 값을 그대로 received_at 에 넣으면, 다른 메일(ISO 형식)과 같은 날짜일 때 + * 문자열 정렬(ORDER BY received_at)에서 공백(0x20)이 'T'(0x54)보다 앞서 시각과 무관하게 순서가 뒤섞였습니다. + * 저장 전에 한 형식으로 맞추면 목록·POP·IMAP 의 날짜순 정렬이 실제 시각 순서와 같아집니다. + * + * @param {string|undefined|null} value Resend 등에서 받은 시각 문자열 + * @param {string} [fallback] 해석할 수 없을 때 쓸 값(기본: 지금) + * @returns {string} ISO 8601 UTC 문자열 + */ +export function isoTime(value, fallback = now()) { + if (!value) return fallback; + // "+00" 처럼 분이 빠진 시간대는 Date.parse 가 환경에 따라 거부할 수 있어 "+00:00" 으로 보완합니다. + // 시각 부분(HH:MM[:SS[.fff]]) 바로 뒤에 붙은 경우만 고쳐, "2026-09-28" 같은 날짜만 있는 값의 "-28" 을 건드리지 않습니다. + const text = String(value).trim().replace(/(\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)([+-]\d{2})$/, '$1$2:00'); + const ms = Date.parse(text); + return Number.isFinite(ms) ? new Date(ms).toISOString() : fallback; +} + +function recipientsOf(full) { return addresses([...(full.to || []), ...(full.cc || []), ...(full.bcc || []), ...(full.received_for || [])]); } + +/** 원본(.eml)을 message_raw 에 넣고 messages.raw_status/raw_size 를 갱신합니다. automation.mjs(자동 응답의 보낸 메일 원본)도 씁니다. */ +export async function storeRaw(env, messageId, eml, status) { + await env.DB.batch([ + env.DB.prepare('INSERT OR REPLACE INTO message_raw(message_id,eml,stored_at) VALUES(?,?,?)').bind(messageId, eml, now()), + env.DB.prepare('UPDATE messages SET raw_status=?,raw_size=?,updated_at=? WHERE id=?').bind(status, eml.length, now(), messageId), + ]); +} +async function attachmentRows(env, messageId) { + return (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,bytes FROM attachments WHERE message_id=? ORDER BY rowid').bind(messageId).all()).results; +} +export async function buildStoredEml(env, message) { + const rows = await attachmentRows(env, message.id); + return buildEml({ + from: message.sender, to: JSON.parse(message.recipients_json || '[]'), cc: JSON.parse(message.cc_json || '[]'), bcc: JSON.parse(message.bcc_json || '[]'), replyTo: JSON.parse(message.reply_to_json || '[]'), + subject: message.subject, date: message.received_at, messageId: message.message_id || undefined, headers: JSON.parse(message.headers_json || '{}'), + text: message.text, html: message.original_html || message.html || '', + attachments: rows.filter((a) => a.bytes).map((a) => ({ filename: a.filename, contentType: a.content_type, contentId: a.content_id, disposition: a.disposition, bytes: Buffer.from(a.bytes) })), + }); +} + +/** + * 수신 메일 한 통을 Resend 에서 가져와 본문·첨부·원본까지 저장합니다. + * + * 건너뛰는 경우(반환값 skipped): 'tombstone' = 사용자가 영구 삭제한 메일, 'exists' = 원본까지 이미 저장한 메일, + * 'other_mailbox' = 수신자 중 이 게이트웨이가 다루는 메일함이 없는 메일. 동기화의 빠른 확인 방식은 + * 'exists'/'tombstone' 을 "여기부터는 이미 확인함" 표시로 씁니다. + * + * @param {object} env + * @param {string} providerId Resend 수신 메일 id + * @param {object} [options] + * @param {'sync'|'webhook'} [options.source] 어떤 경로로 알게 됐는지(설정 화면 통계용) + * @param {object|null} [options.summary] 목록·웹훅이 준 요약(상세 응답에 값이 없을 때 대신 씁니다) + * @param {string[]|null} [options.known] 알려진 메일함 주소 목록(반복 호출 시 DB 조회를 아끼려고 넘깁니다) + */ +export async function ingestReceived(env, providerId, { source = 'sync', summary = null, known = null } = {}) { + known = known || await knownMailboxes(env); + if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' }; + const existing = await env.DB.prepare('SELECT id,raw_status FROM messages WHERE provider_id=?').bind(providerId).first(); + if (existing && existing.raw_status !== 'none') return { skipped: 'exists', id: existing.id }; + const full = await resend(env, '/emails/receiving/' + encodeURIComponent(providerId)); + const recipients = recipientsOf(full); + // 메일함 라우팅: 수신자 중 알려진 메일함(기본 메일함 + 계정 메일함) 첫 번째가 주 저장본. 수신자가 없으면 기본 메일함. + // 수신자에 우리 메일함이 둘 이상이면(예: info@ 와 admin@ 에 함께 보낸 메일) 나머지 메일함에도 사본을 둡니다(0.8.24, 예전에는 첫 메일함에만 저장). + const locals = [...new Set(recipients.filter((r) => known.includes(r)))]; + const mailbox = locals[0] || (recipients.length ? null : mailboxOf(env)); + if (!mailbox) return { skipped: 'other_mailbox' }; + // 메일함·전체 용량 제한(0.9.0): 가득 찬 메일함에는 저장하지 않고 'quota_exceeded' / 'storage_full' 로 건너뜁니다(웹훅 기록·동기화 상세에 남음). + // 메일은 발송 서비스 쪽에 남아 있으므로 사용자가 메일을 지워 공간이 생기면 다음 동기화가 다시 가져옵니다. 원본만 없던 메일(existing)은 다시 채웁니다. + if (!existing) { const blocked = await checkInbound(env, mailbox); if (blocked) return { skipped: blocked, mailbox }; } + const content = safeContent(full.html, full.text); + // 수신 시각은 ISO 형식으로 맞춰 저장합니다(isoTime 설명 참고). 보관 만료일(expires_at)은 두지 않습니다. + const receivedAt = isoTime(full.created_at || summary?.created_at), stamp = now(); + const id = existing?.id || uid(); + const values = [full.from || summary?.from || '', JSON.stringify(full.to || summary?.to || []), JSON.stringify(full.cc || []), JSON.stringify(full.bcc || []), JSON.stringify(full.reply_to || []), clean(full.subject || summary?.subject, 500), content.html, full.text || content.text, full.html || null, JSON.stringify(full.headers || {}), full.message_id || null, receivedAt, source, stamp, mailbox]; + // 원본을 받지 못했던 메일(raw_status='none')은 같은 id 로 내용을 다시 채우고, 처음이면 새로 넣습니다. + if (existing) await env.DB.prepare('UPDATE messages SET sender=?,recipients_json=?,cc_json=?,bcc_json=?,reply_to_json=?,subject=?,html=?,text=?,original_html=?,headers_json=?,message_id=?,received_at=?,source=?,updated_at=?,mailbox=?,expires_at=NULL WHERE id=?').bind(...values, id).run(); + else { + // 웹훅과 정기 동기화가 같은 메일을 거의 동시에 만나면 둘 다 위의 "이미 있나" 확인을 통과한 뒤 INSERT 에서 provider_id UNIQUE 에 걸립니다(운영 heart 10/12·10/13 의 + // 동기화 '일부 실패' 기록). 먼저 넣은 쪽이 원본·첨부까지 처리하므로 늦은 쪽은 오류가 아니라 '이미 있음' 으로 조용히 끝냅니다(1.0.11). + try { await env.DB.prepare(`INSERT INTO messages(sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,message_id,received_at,source,updated_at,mailbox,id,provider_id,direction,folder,state,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'inbound','inbox','received',?)`).bind(...values, id, providerId, stamp).run(); } + catch (e) { if (/UNIQUE constraint failed: messages\.provider_id/.test(String(e.message))) { const race = await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first(); return { skipped: 'exists', id: race?.id }; } throw e; } + } + // 첨부: 목록에서 다운로드 주소를 받아 바이트까지 보관합니다. + const declared = full.attachments || summary?.attachments || []; + if (declared.length) { + let list = []; + try { list = (await resend(env, '/emails/receiving/' + encodeURIComponent(providerId) + '/attachments?limit=100')).data || []; } catch (e) { console.error('첨부 목록 실패', providerId, e.message); } + await env.DB.prepare('DELETE FROM attachments WHERE message_id=?').bind(id).run(); + // disposition(0.9.9): Content-ID 가 있는 inline 만 'inline'(본문에 끼운 그림)으로 저장하고, 나머지는 모두 'attachment' 입니다. IMAP 으로 올린 메일(imap-mime.mjs)과 같은 규칙입니다. + // 예전에는 Resend 가 준 "inline" 을 그대로 저장해, Apple Mail 이 "inline; filename=x.pdf" 로 보낸 PDF 가 읽기 창에는 보이면서 전달·첨부 수(📎)·"첨부" 필터에서는 빠졌습니다. + for (const item of list.length ? list : declared) { + let bytes = null; + if (item.download_url && Number(item.size || 0) <= ATTACHMENT_LIMIT) { try { bytes = await download(env, item.download_url, { maxBytes: ATTACHMENT_LIMIT }); } catch (e) { console.error('첨부 다운로드 실패', providerId, item.id, e.message); } } + await env.DB.prepare('INSERT INTO attachments(id,message_id,provider_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)') + .bind(uid(), id, item.id || null, clean(item.filename, 255) || 'attachment', clean(item.content_type, 120) || 'application/octet-stream', item.content_id ? String(item.content_id).replace(/^<|>$/g, '') : null, /^inline/i.test(String(item.content_disposition || item.disposition || '')) && item.content_id ? 'inline' : 'attachment', Number(item.size || bytes?.length || 0), bytes, stamp).run(); + } + } + // 원본: Resend 가 서명한 다운로드 주소를 주면 그대로 저장하고, 없으면 저장한 부품으로 재구성합니다. + let eml = null, status = 'built'; + if (full.raw?.download_url) { try { eml = await download(env, full.raw.download_url, { maxBytes: RAW_LIMIT }); status = 'stored'; } catch (e) { console.error('원본 다운로드 실패', providerId, e.message); } } + if (!eml) eml = await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first()); + await storeRaw(env, id, eml, status); + // 저장이 끝난 뒤 메일함 설정에 따라 전달·자동 응답을 합니다(0.8.16~). 실패해도 수신 저장은 성공으로 끝냅니다 + // (실패는 auto_actions 에 남고 메일 상세에 보입니다). 처리 전에 저장을 마치므로 전달에 첨부·원본 HTML 을 그대로 쓸 수 있습니다. + let auto = null; + try { auto = await autoProcess(env, id); } catch (e) { console.error('자동 처리 실패', id, e.message); } + // 다른 우리 메일함에도 받은 메일: 주 저장본을 복제(원본·첨부 포함)해 그 메일함의 받은 메일함에 둡니다. provider_id 는 UNIQUE 라 + // "<provider id>#<메일함>" 으로 구분해 두어, 동기화가 다시 만나도(주 저장본이 exists) 중복되지 않고 영구 삭제 시 tombstone 도 따로 남습니다. + const copies = []; + for (const other of locals.slice(1)) { + const copyPid = `${providerId}#${other}`; + if (await env.DB.prepare('SELECT 1 AS x FROM messages WHERE provider_id=? UNION SELECT 1 FROM tombstones WHERE provider_id=?').bind(copyPid, copyPid).first()) continue; + if (await checkInbound(env, other)) continue; // 사본을 받을 메일함이 가득 찼으면 건너뜁니다(다음 동기화가 다시 시도). + const copyId = await copyMessage(env, id, 'inbox'); + await env.DB.prepare('UPDATE messages SET mailbox=?, provider_id=? WHERE id=?').bind(other, copyPid, copyId).run(); + try { await autoProcess(env, copyId); } catch (e) { console.error('자동 처리 실패', copyId, e.message); } + copies.push({ id: copyId, mailbox: other }); + } + return { id, imported: true, raw: status, ...(auto ? { auto } : {}), ...(copies.length ? { copies } : {}) }; +} + +/** + * 내장 Haraka(로컬 SMTP 수신)가 넘긴 원본(.eml)을 받은 메일함에 저장합니다(1.0.0, 수신 방식 haraka). + * + * Resend 경로(ingestReceived)와 같은 규칙을 지킵니다: provider_id 로 중복·영구 삭제(tombstone) 확인, 메일함 라우팅(수신자 중 우리 메일함 첫 번째가 + * 주 저장본, 나머지 우리 메일함에는 사본), 메일함·전체 용량 검사, 저장 뒤 자동 처리(전달·자동 응답). 원본은 받은 바이트 그대로 보관합니다(raw_status 'stored'). + * provider_id 는 'haraka:<Haraka 트랜잭션 uuid>' 입니다. source 열의 CHECK 제약(webhook|sync|web)을 바꾸려면 messages 표를 다시 만들어야 하므로 + * "밖에서 밀어 넣어 준 메일" 이라는 뜻이 같은 'webhook' 을 쓰고, 통계는 provider_id 접두어로 Haraka 수신을 구분합니다. + * + * @param {Buffer} eml 원본 바이트 + * @param {object} o + * @param {string} o.id Haraka 트랜잭션 id(중복 전달 방지 키) + * @param {string[]} [o.rcpt] 봉투 받는 사람(RCPT TO). 헤더 To/Cc 보다 우선해 메일함을 정합니다(Bcc 로 받은 메일도 올바른 메일함에). + * @param {string} [o.from] 봉투 보낸 사람(기록용) + * @param {string[]} [o.known] 알려진 메일함 목록(반복 호출 시 재사용) + * @returns {Promise<{id?:string, imported?:boolean, skipped?:string, mailbox?:string, copies?:object[]}>} + */ +export async function ingestRaw(env, eml, { id: transactionId, rcpt = [], from = '', known = null } = {}) { + known = known || await knownMailboxes(env); + const providerId = 'haraka:' + String(transactionId || uid()).slice(0, 100); + if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' }; + const existing = await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first(); + if (existing) return { skipped: 'exists', id: existing.id }; + eml = normalizeCrlf(eml); + const info = summarize(parseMime(eml)); + // 봉투 수신자(RCPT TO) → 헤더 수신자 순서로 우리 메일함을 찾습니다. 봉투가 비어 있는 경우(시험·수동 호출)만 헤더를 봅니다. + const envelope = addresses(rcpt), header = addresses([...(info.to || []), ...(info.cc || []), ...(info.bcc || [])]); + const locals = [...new Set([...envelope, ...header].filter((r) => known.includes(r)))]; + const mailbox = locals[0]; + if (!mailbox) return { skipped: 'other_mailbox' }; + const blocked = await checkInbound(env, mailbox); if (blocked) return { skipped: blocked, mailbox }; + // appendMessage 가 해석·정제·첨부·원본 저장을 모두 합니다. 수신 시각은 도착 시각(now)입니다 — Date 헤더는 보낸 쪽이 적는 값이라 정렬이 흔들립니다. + const { id } = await appendMessage(env, 'inbox', eml, { mailbox, enforceQuota: false, internalDate: now() }); + await env.DB.prepare("UPDATE messages SET provider_id=?, source='webhook', updated_at=? WHERE id=?").bind(providerId, now(), id).run(); + let auto = null; + try { auto = await autoProcess(env, id); } catch (e) { console.error('자동 처리 실패', id, e.message); } + const copies = []; + for (const other of locals.slice(1)) { + const copyPid = `${providerId}#${other}`; + if (await env.DB.prepare('SELECT 1 AS x FROM messages WHERE provider_id=? UNION SELECT 1 FROM tombstones WHERE provider_id=?').bind(copyPid, copyPid).first()) continue; + if (await checkInbound(env, other)) continue; + const copyId = await copyMessage(env, id, 'inbox'); + await env.DB.prepare('UPDATE messages SET mailbox=?, provider_id=? WHERE id=?').bind(other, copyPid, copyId).run(); + try { await autoProcess(env, copyId); } catch (e) { console.error('자동 처리 실패', copyId, e.message); } + copies.push({ id: copyId, mailbox: other }); + } + console.log(`Haraka 수신 저장 ${mailbox} from=${from || info.from || '-'} id=${id}${copies.length ? ' 사본 ' + copies.length : ''}`); + return { id, imported: true, mailbox, ...(auto ? { auto } : {}), ...(copies.length ? { copies } : {}) }; +} + +/** + * 다른 경로(www 관리자 등)로 발송된 메일도 보낸 메일함에 보이게 가져옵니다. + * 발신 주소가 알려진 메일함이 아니면 'other_sender' 로 건너뜁니다. 원본은 저장한 부품으로 재구성합니다. + */ +export async function ingestSent(env, providerId, { summary = null, known = null } = {}) { + known = known || await knownMailboxes(env); + if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' }; + if (await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first()) return { skipped: 'exists' }; + // 자동 전달(0.8.16)로 나간 메일은 보낸 메일함에 쌓지 않습니다. 예전에는 동기화가 Resend 보낸 목록에서 전달 사본을 다시 가져와 + // 받은 메일 한 통마다 보낸 메일함에 "Sender (via admin@…)" 메일이 생겼습니다(0.8.24). auto_actions 의 전달 기록(detail 이 발송 id 로 시작)으로 알아봅니다. + if (await env.DB.prepare("SELECT 1 AS x FROM auto_actions WHERE kind='forward' AND status='sent' AND (detail = ? OR detail LIKE ? || ' %')").bind(providerId, providerId).first()) return { skipped: 'auto_forward' }; + const full = await resend(env, '/emails/' + encodeURIComponent(providerId)); + const mailbox = address(full.from); + if (!known.includes(mailbox)) return { skipped: 'other_sender' }; + const content = safeContent(full.html, full.text); + // Resend 보낸 메일 목록의 created_at 은 "YYYY-MM-DD HH:MM:SS.ffffff+00" 형식이라 ISO 로 바꿔 저장합니다. + const sentAt = isoTime(full.created_at || summary?.created_at), stamp = now(), id = uid(); + await env.DB.prepare(`INSERT INTO messages(id,provider_id,direction,folder,sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,'outbound','sent',?,?,?,?,?,?,?,?,?,?,'sync','sent',?,?,?,?,?,?)`) + .bind(id, providerId, full.from, JSON.stringify(full.to || []), JSON.stringify(full.cc || []), JSON.stringify(full.bcc || []), JSON.stringify(full.reply_to || []), clean(full.subject, 500), content.html, full.text || content.text, full.html || null, JSON.stringify(full.headers || {}), sentAt, stamp, stamp, sentAt, sentAt, mailbox).run(); + await storeRaw(env, id, await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first()), 'built'); + return { id, imported: true }; +} + +// 동기화는 도메인(env)마다 한 번에 하나만 돕니다. 웹 화면 여러 개가 동시에 요청해도 Resend 호출이 겹치지 않게 합니다. +// 1.0.7: 예전의 모듈 전역 플래그는 도메인이 여럿이면 한 도메인의 동기화가 다른 도메인을 막으므로 env 단위(SYNCING)로 바꿨습니다. +/** 지금 이 도메인의 동기화가 진행 중인지(화면의 "동기화 중" 표시용). env 가 없으면 false. */ +export const isSyncing = (env) => !!env?.SYNCING; +// 한 번의 동기화가 훑는 최대 페이지 수(페이지당 50건). 받은 메일 500건, 보낸 메일 200건까지 거슬러 올라갑니다. +// 예전에는 "보관 기간(30일)보다 오래된 메일"에서 멈췄지만 보관 기한이 없어졌으므로 이 페이지 한도가 유일한 상한입니다. +const SYNC_PAGES = { inbound: 10, outbound: 4 }; + +/** + * Resend 목록(최신순)을 훑어 웹훅이 놓친 메일을 보충합니다. + * + * 두 가지 방식이 있습니다. + * - 전체 훑기(quick=false): 시작 직후·1시간 타이머·설정 화면의 "지금 동기화". 페이지 한도(SYNC_PAGES)까지 모두 + * 확인해 중간에 빠진 메일(예: 일시 오류로 실패한 웹훅)까지 채웁니다. + * - 빠른 확인(quick=true): 웹 메일함 목록이 1분마다 자동으로 부르는 경우. 목록은 최신순이므로, 이미 저장했거나 + * 영구 삭제(tombstone)한 메일을 처음 만나면 그보다 오래된 메일은 이전 동기화가 확인했다고 보고 멈춥니다. + * 예전에는 30일 기준 시각에서 멈췄기 때문에 매분 호출해도 가벼웠는데, 기준 시각이 사라진 뒤에도 매분 수백 건을 + * 다시 훑지 않도록 이 방식을 둡니다. 빠진 메일이 있어도 다음 전체 훑기(최대 1시간 뒤)가 채웁니다. + * + * @param {object} env 실행 환경(DB, RESEND_API_KEY 등) + * @param {object} [options] + * @param {string} [options.trigger] 기록용 실행 계기: 'startup' | 'timer' | 'manual' | 'poll' + * @param {boolean} [options.quick] true 면 빠른 확인 방식 + * @returns {Promise<{imported:number, errors:number}|{busy:true}>} + */ +export async function syncMailbox(env, { trigger = 'timer', quick = false } = {}) { + if (env.SYNCING) return { busy: true }; + env.SYNCING = true; let imported = 0; const errors = []; let run = null; + try { + // 실행 기록과 메일함 목록 조회도 try 안에 둡니다. 예전에는 밖에 있어서 여기서 예외(예: SQLITE_BUSY)가 나면 + // finally 가 돌지 않아 syncing 이 true 로 남고, 프로세스를 재시작할 때까지 모든 동기화가 busy 로 끝났습니다. + run = await env.DB.prepare('INSERT INTO sync_runs(trigger,started_at) VALUES(?,?) RETURNING id').bind(trigger, now()).first(); + // known: 이 게이트웨이가 다루는 메일함 주소 목록(기본 메일함 + 계정 메일함). 다른 주소의 메일은 건너뜁니다. + const known = await knownMailboxes(env); + // 1.0.0: Resend 를 쓰는 방향만 훑습니다. 수신이 Haraka 면 받은 메일 목록은 보지 않고(Resend 에 없음), 발신이 SMTP 중계면 보낸 메일 목록도 보지 않습니다. + const directions = ['inbound', 'outbound'].filter((d) => (d === 'inbound' ? inboundMode(env) === 'resend' : outboundMode(env) === 'resend')); + for (const direction of directions) { + // stop: 빠른 확인에서 이미 아는 메일을 만나 이 방향의 훑기를 끝낼 때 true. + const endpoint = direction === 'inbound' ? '/emails/receiving' : '/emails'; let cursor = ''; let stop = false; + for (let page = 0; page < SYNC_PAGES[direction] && !stop; page++) { + const list = await resend(env, endpoint + '?limit=50' + (cursor ? '&after=' + encodeURIComponent(cursor) : '')); + for (const summary of list.data || []) { + try { + let r; + // 미리 거르기는 웹훅과 같은 주소 집합(to·cc·bcc·received_for)으로 합니다. 예전에는 to 만 봐서 우리 메일함이 참조(Cc)에만 있는 메일을 동기화가 영영 가져오지 않았습니다(0.9.0 수정). + if (direction === 'inbound') { if (!addresses([...(summary.to || []), ...(summary.cc || []), ...(summary.bcc || []), ...(summary.received_for || [])]).some((a) => known.includes(a))) continue; r = await ingestReceived(env, summary.id, { source: 'sync', summary, known }); } + else { if (!known.includes(address(summary.from))) continue; r = await ingestSent(env, summary.id, { summary, known }); } + if (r.imported) imported++; + // 이미 저장했거나 영구 삭제한 메일 = 이전 동기화(또는 웹훅)가 여기까지 확인했다는 표시입니다. + if (quick && (r.skipped === 'exists' || r.skipped === 'tombstone')) { stop = true; break; } + } catch (e) { errors.push(summary.id + ':' + e.message); if (['mail_auth_failed', 'mail_forbidden', 'mail_not_configured'].includes(e.message)) throw e; } + } + if (stop || !list.has_more || !list.data?.length) break; + cursor = list.data.at(-1).id; + } + } + await env.DB.prepare("UPDATE sync_runs SET finished_at=?,imported=?,status=?,detail=? WHERE id=?").bind(now(), imported, errors.length ? 'partial' : 'ok', errors.slice(0, 20).join('\n') || null, run.id).run(); + return { imported, errors: errors.length }; + } catch (e) { + if (run) await env.DB.prepare("UPDATE sync_runs SET finished_at=?,imported=?,status='failed',detail=? WHERE id=?").bind(now(), imported, (e.message + (e.providerMessage ? ' ' + e.providerMessage : '')).slice(0, 500), run.id).run(); + throw e; + } finally { env.SYNCING = false; } +} +/** + * API 요청에서 동기화를 시작하고 끝나기를 기다리지 않고 바로 돌아옵니다(화면은 몇 초 뒤 목록을 다시 읽습니다). + * @param {object} env + * @param {string} [trigger] 기록용 실행 계기('manual' = 설정 화면 버튼, 'poll' = 목록 자동 새로 고침) + * @param {{quick?: boolean}} [options] quick: 빠른 확인 방식(syncMailbox 설명 참고) + * @returns {{started:true}|{busy:true}} + */ +export function startSync(env, trigger = 'manual', { quick = false } = {}) { + if (env.SYNCING) return { busy: true }; + syncMailbox(env, { trigger, quick }).catch((e) => console.error('메일 동기화 실패', e.message, e.providerMessage || '')); + return { started: true }; +} + +/** + * 주기 정리 작업: 만료된 로그인 세션을 지웁니다. + * + * 0.8.0 이전에는 같은 자리에서 "보관 기간(30일)이 지난 메일"도 원본·첨부와 함께 삭제했습니다(enforceRetention). + * 메일 보관 기한을 없애면서 메일 삭제는 빼고 세션 정리만 남겼습니다. 메일은 사용자가 영구 삭제할 때만 지워집니다. + * + * @returns {Promise<{sessions:number}>} 지운 세션 수 + */ +export async function cleanupSessions(env) { + const result = await env.DB.prepare('DELETE FROM sessions WHERE expires_at < ?').bind(now()).run(); + return { sessions: result.meta.changes }; +} + +// ------------------------------------------------------------------ 초안·발송 +export async function saveDraft(env, body, id, user = null) { + const mailbox = user?.mailbox || mailboxOf(env), fromName = user?.name || senderName(env); + // 보내는 사람 표시 이름은 formatSender 로 만듭니다(0.9.0): 쉼표·따옴표·괄호가 든 이름("Lee, Director")을 그대로 넣으면 RFC 5322 에 어긋나 발송 서비스가 거부하거나 주소가 깨졌습니다. + const existing = id ? await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first() : null; + if (id && !existing) throw new Error('not_found'); + if (existing && (existing.folder !== 'drafts' || existing.state !== 'draft')) throw new Error('mail_locked'); + // 초안은 아직 완성되지 않은 주소("bob", "bob@example" 등)도 그대로 저장합니다. 주소 검사는 발송(sendDraft) 때 합니다. + // 왜: 예전에는 여기서 422 를 던져 자동 저장이 조용히 실패했고, 창을 닫으면 작성 중인 본문이 통째로 사라졌습니다. + // 부분 수정(0.9.5): 본문에 없는 키는 기존 초안 값을 유지합니다. 예전에는 PATCH { subject } 처럼 일부만 보내도 받는 사람·참조·본문이 빈 값으로 덮였습니다(화면은 늘 전체를 보내 드러나지 않았음). + // bcc(0.9.5~): 웹 작성 창의 숨은 참조. 예전에는 IMAP 으로 올린 초안의 Bcc 만 저장·발송됐고 웹에서는 입력할 수 없었습니다. + const keep = (key, col) => (body[key] !== undefined ? addresses(body[key]).slice(0, 100) : existing ? JSON.parse(existing[col] || '[]') : []); + const to = keep('to', 'recipients_json'), cc = keep('cc', 'cc_json'), bcc = keep('bcc', 'bcc_json'); + const subject = body.subject !== undefined ? clean(body.subject, 500) : existing ? existing.subject : '', stamp = now(); + const content = body.html != null ? sanitizeHtml(body.html, { limit: STORED_HTML_LIMIT }) : body.text != null || !existing ? sanitizeHtml(textToHtml(body.text || '')) : { html: existing.html, text: existing.text }; + // 답장이면 In-Reply-To / References 를 남겨 스레드와 \Answered 표시를 유지합니다. + const inReplyTo = clean(body.inReplyTo, 300); const headers = inReplyTo && /^<[^\s<>]+>$/.test(inReplyTo) ? { 'In-Reply-To': inReplyTo, References: inReplyTo } : null; + if (existing) { + const updated = await env.DB.prepare("UPDATE messages SET recipients_json=?,cc_json=?,bcc_json=?,subject=?,html=?,text=?,received_at=?,updated_at=?,headers_json=COALESCE(?,headers_json) WHERE id=? AND state='draft' AND folder='drafts'").bind(JSON.stringify(to), JSON.stringify(cc), JSON.stringify(bcc), subject, content.html, content.text, stamp, stamp, headers ? JSON.stringify(headers) : null, id).run(); + if (!updated.meta.changes) throw new Error('mail_locked'); + await invalidateRaw(env, id); + } else { + id = uid(); + await env.DB.prepare(`INSERT INTO messages(id,direction,folder,sender,recipients_json,cc_json,bcc_json,subject,html,text,source,state,received_at,created_at,updated_at,read_at,mailbox,headers_json) VALUES(?,'outbound','drafts',?,?,?,?,?,?,?,'web','draft',?,?,?,?,?,?)`) + .bind(id, formatSender(fromName, mailbox), JSON.stringify(to), JSON.stringify(cc), JSON.stringify(bcc), subject, content.html, content.text, stamp, stamp, stamp, stamp, mailbox, JSON.stringify(headers || {})).run(); + } + return id; +} +/** + * 초안의 저장된 원본(.eml)을 버립니다. IMAP APPEND 로 올린 초안은 원본을 그대로 보관하는데, 웹에서 제목·본문·첨부를 고치면 + * 그 원본이 옛 내용으로 남아 IMAP/POP/"원본 내려받기" 가 수정 전 내용을 보여 줬습니다(0.8.16 수정). raw_status 를 'none' 으로 돌리면 + * IMAP 은 저장된 행과 첨부로 메시지를 다시 만들고, 발송 때는 sendDraft 가 새 원본을 저장합니다. + */ +async function invalidateRaw(env, id) { + await env.DB.batch([env.DB.prepare('DELETE FROM message_raw WHERE message_id=?').bind(id), env.DB.prepare("UPDATE messages SET raw_status='none',raw_size=0 WHERE id=? AND raw_status<>'none'").bind(id)]); +} +// 초안 첨부의 합계·개수 상한(발송 서비스의 메일 전체 40MB 한도에서 나온 내장값). 첨부 한 개의 상한은 0.9.0 부터 관리자·시스템 설정(limits.attachmentLimit, MIME 기준)을 따릅니다. +const DRAFT_TOTAL_LIMIT = 20 * 1024 * 1024, DRAFT_FILE_COUNT = 10; +/** 첨부 한 개의 크기 검사: 파일 바이트를 MIME(base64) 크기로 바꿔 적용 중인 상한과 비교합니다. 넘으면 attachment_too_large 에 limit(MIME 바이트)을 붙여 던집니다. */ +async function checkAttachmentSize(env, bytes) { + const limit = await attachmentLimit(env); + if (mimeSize(bytes) > limit) { const e = new Error('attachment_too_large'); e.limit = limit; throw e; } +} +export async function addAttachment(env, draftId, { filename, contentType, bytes, inline = false }) { + const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first(); + if (!draft) throw new Error('mail_locked'); + if (!bytes?.length) throw new Error('attachment_too_large'); + await checkAttachmentSize(env, bytes.length); + const current = await env.DB.prepare('SELECT COUNT(*) n, COALESCE(SUM(size),0) total FROM attachments WHERE message_id=?').bind(draftId).first(); + if (current.n >= DRAFT_FILE_COUNT || current.total + bytes.length > DRAFT_TOTAL_LIMIT) throw new Error('attachment_too_large'); + const id = uid(), isInline = inline && /^image\//.test(String(contentType || '')); + await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?)") + .bind(id, draftId, clean(filename, 255).replace(/[\r\n"\\/]/g, '_') || 'attachment', clean(contentType, 120) || 'application/octet-stream', isInline ? `${id}@${mailboxOf(env).split('@')[1]}` : null, isInline ? 'inline' : 'attachment', bytes.length, bytes, now()).run(); + await invalidateRaw(env, draftId); + return id; +} +export async function removeAttachment(env, draftId, attachmentId) { + const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first(); + if (!draft) throw new Error('mail_locked'); + await env.DB.prepare('DELETE FROM attachments WHERE id=? AND message_id=?').bind(attachmentId, draftId).run(); + await invalidateRaw(env, draftId); +} +/** + * 초안을 발송합니다. + * @param {object} [options.largeFiles] 대용량 첨부파일(0.9.3)의 발송 조건 { days, downloads }. 초안에 대용량 파일이 있으면 필수이며 관리자 상한 이내여야 합니다(large_file_terms). + * 발송 직전에 파일마다 링크를 만들어 본문 끝에 "대용량 첨부파일" 블록을 덧붙이고, 발송에 실패하면 링크를 되돌립니다. + */ +export async function sendDraft(env, id, { largeFiles = null } = {}) { + const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); + if (!mail) throw new Error('not_found'); + if (mail.state === 'sent') return mail; + if (mail.direction !== 'outbound' || mail.folder !== 'drafts') throw new Error('mail_locked'); + // bcc: IMAP 으로 올린 초안(메일 프로그램이 Bcc 헤더를 남김)의 숨은 참조. 예전에는 저장·발송에서 빠져 받는 사람이 조용히 사라졌습니다(0.8.25 수정). + // 같은 주소는 한 번만(0.9.9): 받는 사람·참조·숨은 참조를 통틀어 소문자 기준으로 중복을 지웁니다(받는 사람 > 참조 > 숨은 참조 순으로 남김). + // 예전에는 전체 답장에서 대소문자만 다른 주소가 받는 사람과 참조에 함께 들어가면 Resend 에 그대로 넘어가 같은 사람이 두 통을 받았습니다. + const seen = new Set(); const uniq = (list) => list.filter((a) => { const k = String(a).toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; }); + const to = uniq(JSON.parse(mail.recipients_json)), cc = uniq(JSON.parse(mail.cc_json)), bcc = uniq(JSON.parse(mail.bcc_json || '[]')); + // 받는 사람 주소 검사는 여기서 합니다(초안 저장은 미완성 주소도 허용). 잘못된 주소는 invalid_email, 그 밖의 빈 값은 validation. + if ([...to, ...cc, ...bcc].some((a) => !isEmail(a))) throw new Error('invalid_email'); + // 대용량 파일이 있으면 발송 조건(기한·횟수)을 먼저 검사합니다. 본문이 비어 있어도 대용량 파일이 있으면 보낼 수 있습니다(링크 블록이 본문이 됨). + const hasLarge = await checkLargeFileTerms(env, id, largeFiles); // 대용량 파일이 있는지(재시도면 조건 생략 가능) + if (!to.length || !mail.subject || !mail.text.trim() && !mail.html?.includes('<img') && !hasLarge) throw new Error('validation'); + if (mail.state === 'sending' && Date.parse(mail.send_started_at) > Date.now() - 120000) throw new Error('mail_sending'); + // 제한 검사(0.9.0): 받는 사람 수(예전 20명 고정 → 관리자·시스템 설정), 첨부 한 개 크기, 메일함·전체 용량, 월간 통수. 발송을 시작(claim)하기 전에 검사해 + // 어긋나도 초안이 '발송 실패' 상태가 되지 않고 그대로 고칠 수 있게 합니다. 저장될 원본 크기는 본문 + 첨부의 MIME 크기 + 헤더 여유로 어림합니다. + const sizes = (await env.DB.prepare('SELECT size FROM attachments WHERE message_id=?').bind(id).all()).results.map((a) => Number(a.size || 0)); + await checkOutbound(env, { mailbox: mail.mailbox || mailboxOf(env), recipients: to.length + cc.length + bcc.length, attachmentSizes: sizes, bytes: String(mail.html || '').length + String(mail.text || '').length + sizes.reduce((n, s) => n + mimeSize(s), 0) + 2000 }); + const key = mail.send_key || 'gw-' + uid(), stamp = now(); + const claim = await env.DB.prepare("UPDATE messages SET state='sending',send_key=?,send_started_at=?,updated_at=? WHERE id=? AND (state IN ('draft','failed') OR (state='sending' AND send_started_at<?))").bind(key, stamp, stamp, id, new Date(Date.now() - 120000).toISOString()).run(); + if (!claim.meta.changes) throw new Error('mail_sending'); + try { + // 답장·전달 인용문에 들어 있는 "원래 메일의" 인라인 이미지(/api/mail/<원래 id>/attachments/<aid>)를 이 초안의 인라인 첨부로 복사합니다. + // 왜: 예전에는 초안 자신의 첨부만 cid: 로 바꿔, 인용한 원래 메일의 이미지는 받는 사람에게 상대 주소(깨진 이미지)로 갔습니다. + // 같은 메일함의 메일만 복사합니다(다른 메일함의 첨부 id 를 끼워 넣어 빼내지 못하게). + let source = String(mail.html || ''); + for (const [, mid, aid] of [...source.matchAll(/<img\b[^>]*\bsrc="\/api\/mail\/([^"/?]+)\/attachments\/([^"/?]+)(?:\?[^"]*)?"/g)]) { + const originId = decodeURIComponent(mid), attachmentId = decodeURIComponent(aid); if (originId === id) continue; + const orig = await env.DB.prepare('SELECT a.filename,a.content_type,a.size,a.bytes,m.mailbox FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=? AND a.message_id=?').bind(attachmentId, originId).first(); + if (!orig?.bytes || orig.mailbox !== mail.mailbox) continue; + const copyId = uid(); + await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,'inline',?,?,?)").bind(copyId, id, orig.filename, orig.content_type, `${copyId}@${mailboxOf(env).split('@')[1]}`, orig.size, orig.bytes, now()).run(); + source = source.split(`/api/mail/${mid}/attachments/${aid}`).join(`/api/mail/${encodeURIComponent(id)}/attachments/${copyId}`); + } + if (source !== String(mail.html || '')) await env.DB.prepare('UPDATE messages SET html=? WHERE id=?').bind(source, id).run(); + let files = await attachmentRows(env, id); + // 편집기가 넣은 인라인 이미지(/api/mail/<id>/attachments/<aid>?inline=1)는 cid: 로 바꿔 보냅니다. + const used = new Set(); + const html = source.replace(/(<img\b[^>]*\bsrc=")\/api\/mail\/[^"/]+\/attachments\/([^"?]+)(?:\?[^"]*)?("[^>]*>)/g, (m, a, aid, b) => { const f = files.find((x) => x.id === aid && x.content_id); if (!f) return m; used.add(f.id); return `${a}cid:${f.content_id}${b}`; }); + // IMAP 으로 올린 초안(메일 프로그램에 그림을 넣은 경우)은 본문이 처음부터 cid: 를 가리킵니다. 그 첨부도 "쓰는 중" 으로 표시합니다. + // 예전에는 이 첨부가 고아로 잡혀 지워지고 발송에서도 빠져, 받는 사람에게 깨진 그림이 갔습니다(0.8.16 수정). + for (const [, cid] of html.matchAll(/<img\b[^>]*\bsrc="cid:([^"]+)"/g)) { const f = files.find((x) => x.content_id && x.content_id === cid.replace(/&/g, '&')); if (f) used.add(f.id); } + // 본문에서 지운 인라인 이미지는 보내지 않고 초안에서도 지웁니다. 첨부 칩 목록에는 인라인 파일이 보이지 않아 + // 사용자가 직접 지울 방법이 없는데, 예전에는 이런 이미지가 일반 첨부로 함께 나갔습니다. + // Content-ID 가 없는 'inline' 첨부(Apple Mail 등이 PDF·문서를 inline; filename= 으로 올림)는 본문이 가리킬 수 없으므로 고아가 아니라 일반 첨부입니다. + // 예전에는 이런 첨부가 고아로 잡혀 조용히 지워지고 발송에서도 빠졌습니다(0.8.25 수정). + const orphans = files.filter((a) => a.disposition === 'inline' && a.content_id && !used.has(a.id)); + for (const a of orphans) await env.DB.prepare('DELETE FROM attachments WHERE id=?').bind(a.id).run(); + files = files.filter((a) => !orphans.includes(a)); + // 보내는 주소는 항상 초안이 속한 메일함입니다. 저장된 sender 는 표시 이름을 쓰기 위해서만 보고, 주소가 메일함과 다르면 버립니다. + // 왜: IMAP APPEND 로 올린 초안은 From 헤더를 그대로 sender 에 저장하므로, 예전에는 일반 계정이 From 을 다른 주소(예: 관리자 메일함)로 + // 적은 초안을 올린 뒤 웹에서 보내기를 누르면 그 주소로 발송됐습니다(SMTP 제출은 allowedSenders 로 막고 있었음). 0.8.16 수정. + const mailbox = mail.mailbox || mailboxOf(env); + const sender = address(mail.sender) === mailbox ? formatSender(nameOf(mail.sender), mailbox) : formatSender(senderName(env), mailbox); + // 발송에 넘기는 사용자 지정 헤더는 답장 스레드용 두 개뿐입니다. IMAP 으로 올린 초안(appendMessage)은 원본의 모든 헤더 + // (Content-Type, MIME-Version, Date, Message-ID …)를 headers_json 에 갖고 있어, 그대로 보내면 발송 서비스가 거부하거나 MIME 이 깨집니다. + const savedHeaders = JSON.parse(mail.headers_json || '{}'); const headers = {}; + for (const name of ['In-Reply-To', 'References']) { const k = Object.keys(savedHeaders).find((x) => x.toLowerCase() === name.toLowerCase()); if (k && savedHeaders[k]) headers[name] = String(savedHeaders[k]); } + // 대용량 첨부파일(0.9.3): 링크·기한·횟수를 확정하고 본문(HTML·텍스트) 끝에 블록을 덧붙입니다. 저장본에도 같은 본문을 남겨 보낸 메일함에서 링크를 볼 수 있게 합니다. + // 링크의 호스트(1.0.8): 경로 접두사 아래의 웹 메일이면 "호스트/mail" 처럼 접두사까지 붙여 https://host/mail/dl/… 이 됩니다. + const large = await finalizeLargeFiles(env, id, largeFiles, (env.PUBLIC_HOST || 'mail.arihakdang.com') + (env.PUBLIC_PATH || ''), env.STRINGS || {}); + const finalHtml = html + large.html, finalText = String(mail.text || '') + large.text; + const payload = { from: sender, to, ...(cc.length ? { cc } : {}), ...(bcc.length ? { bcc } : {}), subject: mail.subject, html: finalHtml, text: finalText, ...(Object.keys(headers).length ? { headers } : {}) }; + if (files.length) payload.attachments = files.map((a) => ({ filename: a.filename, content: Buffer.from(a.bytes).toString('base64'), content_type: a.content_type, ...(used.has(a.id) ? { content_id: a.content_id } : {}) })); + // 발송 모듈은 설정(outbound)에 따라 Resend 또는 SMTP 중계(내장 Haraka·외부 서버)입니다(1.0.0, providers/index.mjs). + const result = await sendMail(env, payload, key); + const sentAt = now(); + // 발송이 끝나면 보낸 메일함으로 옮깁니다. 보관 기한이 없으므로 expires_at 은 채우지 않습니다. + // 저장본 html 은 인라인 이미지 복사를 반영한 source(위에서 갱신) 기준입니다. 0.9.3 은 복사 전 값(mail.html)을 다시 써서 원본 메일을 지우면 보낸 메일의 그림이 깨졌습니다(0.9.5 수정). + await env.DB.prepare("UPDATE messages SET state='sent',folder='sent',provider_id=?,sent_at=?,received_at=?,updated_at=?,read_at=COALESCE(read_at,?),original_html=?,html=?,text=? WHERE id=?").bind(result.id, sentAt, sentAt, sentAt, sentAt, finalHtml, source + large.html, finalText, id).run(); + const stored = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); + await storeRaw(env, id, await buildStoredEml(env, stored), 'built'); + return stored; + } catch (e) { + await env.DB.prepare("UPDATE messages SET state='failed',updated_at=? WHERE id=? AND state='sending'").bind(now(), id).run(); + // 대용량 파일의 링크는 되돌리지 않습니다: 다시 보낼 때 같은 토큰·같은 본문으로 보내야 멱등 키가 맞고, 첫 시도가 실제로 전달됐을 때 링크가 살아 있습니다(0.9.5). + throw e; + } +} + +/** + * 메일 한 통의 IMAP 영구 플래그(\Flagged 중요, \Answered 답장함 등)를 켜거나 끕니다. + * 웹 API(별표·답장 표시)와 SMTP 제출(답장 발송 시 원래 메일에 \Answered) 이 함께 쓰도록 여기에 둡니다. + * @param {string} id 메일 id + * @param {string} flag 플래그 이름(예: '\Flagged') + * @param {boolean} on true 면 켜고 false 면 끕니다 + */ +export async function setFlag(env, id, flag, on) { + const row = await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first(); + const flags = new Set(JSON.parse(row?.flags || '[]')); if (on) flags.add(flag); else flags.delete(flag); + await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(id, JSON.stringify([...flags]), now()).run(); +} + +// ------------------------------------------------------------------ IMAP APPEND / COPY +/** + * 클라이언트가 올린 원본(.eml)을 메일함에 저장합니다(보낸 메일 복사본·초안 등). 원본은 그대로 보관합니다. + * @param {boolean} [options.enforceQuota] 메일함 최대 크기(0.9.0)를 검사할지. IMAP APPEND 는 검사하고(넘으면 quota_exceeded), + * SMTP 제출은 발송 전에 이미 검사했으므로 false 로 넘깁니다(발송된 메일의 보낸 메일함 저장이 실패하면 안 되므로). + */ +export async function appendMessage(env, folder, eml, { flags = [], internalDate = null, mailbox = null, enforceQuota = true } = {}) { + if (!['inbox', 'sent', 'drafts', 'trash'].includes(folder)) throw new Error('not_found'); + // 줄 끝을 CRLF 로 맞춘 뒤 해석합니다. 일부 프로그램·스크립트는 LF 만 쓴 메시지를 올리는데, 해석기는 CRLF 경계만 알아 + // 예전에는 본문이 빈 메일로 저장됐습니다(0.8.16 수정). 원본도 정규화한 바이트로 보관해 IMAP/POP 이 같은 것을 돌려줍니다. + eml = normalizeCrlf(eml); + const parsed = parseMime(eml), info = summarize(parsed); + // 보낸 메일함 중복 방지: 메일 프로그램은 SMTP 로 보낸 뒤 같은 메일을 IMAP 으로 보낸 메일함에 다시 올립니다(APPEND). + // SMTP 제출(smtp.mjs)이 이미 같은 Message-ID 로 저장했다면 새로 만들지 않고 그 메일을 돌려줍니다(반대 순서는 smtp.mjs 가 처리). + // 용량 검사보다 먼저 합니다: 발송으로 메일함이 가득 찬 직후 올라오는 사본이 OVERQUOTA 로 거부되면 메일 프로그램이 "보낸 편지함 복사 실패" 를 반복했습니다(0.9.3). + if (folder === 'sent' && info.messageId) { + const same = await env.DB.prepare("SELECT id FROM messages WHERE mailbox=? AND folder='sent' AND message_id=?").bind(mailbox || mailboxOf(env), info.messageId).first(); + if (same) return { id: same.id, duplicate: true }; + } + if (enforceQuota) { + const box = mailbox || mailboxOf(env), quota = await mailboxQuota(env, box); + if (quota > 0 && (await mailboxUsage(env, box)) + eml.length > quota) { const e = new Error('quota_exceeded'); e.limit = quota; throw e; } + // 전체 저장 용량(시스템)도 봅니다. 예전에는 APPEND 만 이 제한을 비켜 가 메일 프로그램으로 저장소를 상한 너머로 키울 수 있었습니다(0.9.3). + const sys = systemLimits(env); + if (sys.totalBytes > 0 && (await totalUsage(env)) + eml.length > sys.totalBytes) { const e = new Error('storage_full'); e.limit = sys.totalBytes; throw e; } + } + const content = safeContent(info.html, info.text); + const receivedAt = internalDate || (Date.parse(info.date) ? new Date(Date.parse(info.date)).toISOString() : now()), stamp = now(), id = uid(); + mailbox = mailbox || mailboxOf(env); const inbound = folder === 'inbox' || (folder === 'trash' && address(info.from) !== mailbox); + const direction = inbound ? 'inbound' : 'outbound', state = folder === 'drafts' ? 'draft' : inbound ? 'received' : 'sent'; + const seen = flags.some((f) => f.toLowerCase() === '\\seen') || !inbound; + // bcc_json: 원본의 Bcc 헤더(메일 프로그램이 초안에 남겨 둠). 발송(sendDraft)이 숨은 참조로 씁니다(0.8.25~). + await env.DB.prepare(`INSERT INTO messages(id,message_id,direction,folder,sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'web',?,?,?,?,?,?,?)`) + .bind(id, info.messageId || null, direction, folder, info.from, JSON.stringify(info.to), JSON.stringify(info.cc), JSON.stringify(info.bcc || []), JSON.stringify(info.replyTo), clean(info.subject, 500), content.html, info.text || content.text, info.html || null, JSON.stringify(info.headers), state, receivedAt, stamp, stamp, inbound ? null : receivedAt, seen ? stamp : null, mailbox).run(); + for (const a of info.attachments) await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?)").bind(uid(), id, clean(a.filename, 255) || 'attachment', clean(a.contentType, 120), a.contentId, a.disposition, a.bytes.length, a.bytes, stamp).run(); + const persisted = flags.filter((f) => ['\\Flagged', '\\Answered', '\\Deleted'].some((x) => x.toLowerCase() === f.toLowerCase())).map((f) => f[0] + f[1].toUpperCase() + f.slice(2).toLowerCase()); + if (persisted.length) await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?)').bind(id, JSON.stringify(persisted), stamp).run(); + await storeRaw(env, id, Buffer.from(eml), 'stored'); + return { id }; +} +/** IMAP COPY: 메시지·원본·첨부를 다른 메일함에 복제합니다. */ +/** + * 다른 메일의 첨부(일반 첨부만)를 초안으로 복사합니다(0.8.25~). 웹의 "전달" 이 원본 첨부를 함께 보내기 위한 것으로, + * 예전에는 전달 메일에 인용 본문의 그림만 따라가고 PDF 같은 일반 첨부는 조용히 빠졌습니다. + * 초안 상태의 메일에만, 초안의 첨부 개수·용량 한도(DRAFT_FILE_COUNT / DRAFT_TOTAL_LIMIT) 안에서 복사합니다. 원본이 같은 메일함인지는 API 가 확인합니다. + * @returns {Promise<string[]>} 새 첨부 id 목록 + */ +export async function copyAttachments(env, draftId, originId, ids) { + const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first(); + if (!draft) throw new Error('mail_locked'); + const out = []; + for (const aid of ids) { + // content_id 가 없는 inline(예전 규칙으로 저장된 행)도 파일 첨부로 보고 복사합니다(0.9.9). + const a = await env.DB.prepare("SELECT filename,content_type,size,bytes FROM attachments WHERE id=? AND message_id=? AND (disposition='attachment' OR content_id IS NULL)").bind(aid, originId).first(); + if (!a?.bytes) continue; + await checkAttachmentSize(env, a.bytes.length); + const current = await env.DB.prepare('SELECT COUNT(*) n, COALESCE(SUM(size),0) total FROM attachments WHERE message_id=?').bind(draftId).first(); + if (current.n >= DRAFT_FILE_COUNT || current.total + a.bytes.length > DRAFT_TOTAL_LIMIT) throw new Error('attachment_too_large'); + const id = uid(); + await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,NULL,'attachment',?,?,?)").bind(id, draftId, a.filename, a.content_type, a.bytes.length, a.bytes, now()).run(); + out.push(id); + } + if (out.length) await invalidateRaw(env, draftId); + return out; +} +export async function copyMessage(env, id, folder) { + const m = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); if (!m) throw new Error('not_found'); + const newId = uid(), stamp = now(); + // 복제할 열 목록. expires_at 은 더 쓰지 않으므로 빼서 복사본은 항상 NULL 이 됩니다. + const cols = ['message_id','direction','sender','recipients_json','cc_json','bcc_json','reply_to_json','subject','html','text','original_html','headers_json','source','state','received_at','sent_at','read_at','raw_status','raw_size','mailbox']; + await env.DB.prepare(`INSERT INTO messages(id,folder,created_at,updated_at,provider_id,${cols.join(',')}) VALUES(?,?,?,?,NULL,${cols.map(() => '?').join(',')})`).bind(newId, folder, stamp, stamp, ...cols.map((c) => m[c] ?? null)).run(); + await env.DB.prepare('INSERT INTO message_raw(message_id,eml,stored_at) SELECT ?,eml,? FROM message_raw WHERE message_id=?').bind(newId, stamp, id).run(); + const files = (await env.DB.prepare('SELECT * FROM attachments WHERE message_id=?').bind(id).all()).results; + for (const a of files) await env.DB.prepare('INSERT INTO attachments(id,message_id,provider_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)').bind(uid(), newId, a.provider_id, a.filename, a.content_type, a.content_id, a.disposition, a.size, a.bytes, stamp).run(); + const f = await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first(); + if (f) await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?)').bind(newId, f.flags, stamp).run(); + return newId; +} diff --git a/modules/arimail/server/usage.mjs b/modules/arimail/server/usage.mjs new file mode 100644 index 0000000..e0c59e6 --- /dev/null +++ b/modules/arimail/server/usage.mjs @@ -0,0 +1,224 @@ +/** + * 메일 사용량 정보 제공 API(1.0.9, 요구사항 1) — 웹메이커 같은 외부 관리 시스템이 디스크 사용량·발송량 등을 통합 관리할 수 있게 읽기 전용 통계를 내줍니다. + * + * 주소(어느 호스트로 와도 같음, nginx 뒤 HTTPS): + * GET /api/usage 모든 도메인의 요약(기본 도메인 먼저) + * GET /api/usage/<도메인> 그 도메인의 상세(기본으로 계정 목록·일별 추이 포함) + * 쿼리: days=30(일별 추이·계정별 기간 통수의 기간, 1~366) · accounts=0|1(계정 목록 포함, 요약은 기본 0·상세는 기본 1) · series=0|1(일별 추이, 상세는 기본 1) + * + * 보안(요구사항: webmaker 의 해당 기능에서만 접근할 수 있게) + * 1) 비밀 키: 전체 설정 화면의 "사용량 API" 카드에서 만든 키(USAGE_API_KEY, 기본 도메인의 설정 파일에만 저장, 32바이트 난수)를 `Authorization: Bearer <키>`(또는 X-Usage-Key)로 + * 보내야 합니다. 키가 없으면 API 자체가 꺼진 상태(404)이고, 비교는 상수 시간(safeEqual)입니다. 키는 만들 때 한 번만 보여 주며 설정 화면에는 앞뒤 일부만 보입니다. 언제든 다시 만들거나(교체) 지울 수 있습니다. + * 2) 접속 IP 제한(선택): USAGE_API_ALLOW 에 웹메이커 서버의 IP 나 CIDR(쉼표 구분)을 적으면 그 밖의 주소는 키가 맞아도 403. nginx 가 넘긴 X-Forwarded-For 의 첫 주소(또는 CF-Connecting-IP)를 봅니다. + * 3) 시도 제한: 키가 틀린 요청은 IP 마다 15분에 10회(그 뒤 429), 성공 요청도 IP 마다 1분에 120회(무차별 조회 방지). 모두 메모리 제한(env.RATE)입니다. + * 4) 읽기 전용·비밀값 없음: 메일 내용·주소록·비밀번호·API 키는 절대 들어가지 않고, 수(통·바이트)·제한·계정 이름(로그인 ID·메일함 주소·역할·상태)만 나갑니다. 호출은 감사 기록(audit_log)에 남습니다. + * + * 응답(도메인 하나) + * domain, primary, enabled, hosts, authMode, mailbox, inbound, outbound, timezone, generatedAt + * accounts : { local, external, total, limit, percent } — 게이트웨이 계정 수 / 웹메이커 등 외부 계정 수 / 시스템 제한(0 = 없음) + * storage : { bytes, limit, percent, largeFiles:{count, bytes, sent} } — 모든 메일함의 사용량(MIME 기준, 원본 .eml 크기) / 대용량 첨부파일(별도 저장) + * messages : { total, inbound, outbound, drafts, failed } — 저장된 메일 수(폴더 무관, 휴지통 포함; 영구 삭제한 것은 없음) + * traffic : { day, week, month: { sent, received, total, sentBytes, receivedBytes, start, end }, monthlyLimit, monthlyPercent } — 통계 시간대 기준(오늘·이번 주(월~)·이달) + * daily : [ { date:'YYYY-MM-DD', sent, received, sentBytes, receivedBytes } ] (최근 days 일, 오래된 날부터) + * accountList : [ { login, name, mailbox, role, source:'local'|'www'|'webmaker', status, messages, bytes, limit, percent, lastLoginAt, sent, received, sentBytes, receivedBytes } ] (기간 = days) + * limits : { system:{…}, admin:{…} } — 설치 시 시스템 제한과 관리자 제한(안내용) + */ +import { json, bad, safeEqual, now } from './util.mjs'; +import { systemLimits, readAdminLimits, mailboxUsage, mailboxQuota, totalUsage, accountCount, statsTimezone, periodBounds, countMail, percentOf } from './limits.mjs'; +import { listUsers, externalUsers, authMode, audit, mailboxOwner, standalone } from './auth.mjs'; +import { largeFileStats } from './files.mjs'; +import { inboundMode, outboundMode } from './providers/index.mjs'; +import { mailDomain } from './config.mjs'; + +export const USAGE_FAIL_LIMIT = 10, USAGE_RATE_LIMIT = 120; +const DAY_MS = 86400000; + +// ------------------------------------------------------------------ 접속 IP 허용 목록 +/** "1.2.3.4, 10.0.0.0/8, 2001:db8::1" 같은 문자열을 항목 배열로. 빈 문자열이면 제한 없음(빈 배열). */ +export function parseAllowList(text) { + return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter(Boolean); +} +const ipv4ToInt = (ip) => { const m = String(ip).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!m) return null; const parts = m.slice(1).map(Number); if (parts.some((n) => n > 255)) return null; return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; }; +/** IPv6 주소를 소문자 전개형(8그룹 16진수)으로. 잘못된 주소는 null. "::ffff:1.2.3.4" 꼴은 IPv4 로 비교하도록 호출자가 먼저 벗깁니다. */ +function expandIpv6(ip) { + const s = String(ip).toLowerCase(); if (!/^[0-9a-f:]+$/.test(s) || s.split('::').length > 2) return null; + const [head, tail = ''] = s.split('::'); const h = head ? head.split(':') : [], t = tail ? tail.split(':') : []; + if (h.length + t.length > 8 || (s.indexOf('::') < 0 && h.length !== 8)) return null; + const groups = [...h, ...Array(8 - h.length - t.length).fill('0'), ...t]; + if (groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null; + return groups.map((g) => parseInt(g, 16)); +} +/** + * 접속 IP 가 허용 목록 안인지. 목록이 비어 있으면 모두 허용. IPv4 는 CIDR(/8~/32) 지원, IPv6 는 정확히 같은 주소 또는 /n 접두사. + * IPv4 주소가 "::ffff:1.2.3.4" 로 들어오면(IPv6 소켓) IPv4 로 봅니다. + */ +export function ipAllowed(ip, list) { + if (!list || !list.length) return true; + let addr = String(ip || '').trim().toLowerCase(); if (addr.startsWith('::ffff:') && addr.includes('.')) addr = addr.slice(7); + const v4 = ipv4ToInt(addr), v6 = v4 === null ? expandIpv6(addr) : null; + if (v4 === null && !v6) return false; + for (const entry of list) { + const [net, bitsRaw] = entry.split('/'); const bits = bitsRaw === undefined ? null : Number(bitsRaw); + if (v4 !== null) { + const n = ipv4ToInt(net); if (n === null) continue; + const b = bits === null ? 32 : bits; if (!Number.isInteger(b) || b < 0 || b > 32) continue; + const mask = b === 0 ? 0 : (~0 << (32 - b)) >>> 0; if (((v4 & mask) >>> 0) === ((n & mask) >>> 0)) return true; + } else { + const n = expandIpv6(net); if (!n) continue; + const b = bits === null ? 128 : bits; if (!Number.isInteger(b) || b < 0 || b > 128) continue; + let ok = true; for (let i = 0; i < 8 && ok; i++) { const take = Math.max(0, Math.min(16, b - i * 16)); if (!take) break; const m = take === 16 ? 0xffff : (0xffff << (16 - take)) & 0xffff; if ((v6[i] & m) !== (n[i] & m)) ok = false; } + if (ok) return true; + } + } + return false; +} + +/** + * 허용 목록 항목 검사: 각 항목이 IPv4[/0~32] 또는 IPv6[/0~128] 이어야 합니다. 잘못된 항목 목록을 돌려줍니다(비어 있으면 통과). + * 저장 때 검사하지 않으면 오타 하나로 모든 요청이 403 이 되고 아무 데서도 알 수 없습니다. + */ +export function invalidAllowEntries(text) { + const bad = []; + for (const entry of parseAllowList(text)) { + const [net, bitsRaw, extra] = entry.split('/'); if (extra !== undefined) { bad.push(entry); continue; } + const v4 = ipv4ToInt(net), v6 = v4 === null ? expandIpv6(net) : null; if (v4 === null && !v6) { bad.push(entry); continue; } + if (bitsRaw !== undefined) { const b = Number(bitsRaw); if (!/^\d{1,3}$/.test(bitsRaw) || b > (v4 !== null ? 32 : 128)) bad.push(entry); } + } + return bad; +} +/** + * 요청의 접속 IP(허용 목록·시도 제한용). 클라이언트가 마음대로 넣을 수 있는 헤더를 믿지 않습니다: + * 1) X-Real-IP(nginx 템플릿 1.0.9 부터 $remote_addr 로 넣음) → 2) X-Forwarded-For 의 **마지막** 주소(nginx 가 $proxy_add_x_forwarded_for 로 실제 접속 주소를 끝에 덧붙임; + * 첫 주소는 클라이언트가 보낸 값일 수 있음) → 3) 소켓 주소. CF-Connecting-IP 는 Cloudflare 뒤에서만 참이므로 USAGE_TRUST_CF=1 일 때만 봅니다. + */ +export function clientIp(headers, remote = '') { + const get = (k) => String(headers.get(k) || '').trim(); + if (process.env.USAGE_TRUST_CF === '1' && get('cf-connecting-ip')) return get('cf-connecting-ip'); + if (get('x-real-ip')) return get('x-real-ip'); + const xff = get('x-forwarded-for').split(',').map((s) => s.trim()).filter(Boolean); + if (xff.length) return xff[xff.length - 1]; + return String(remote || '').trim(); +} + +// ------------------------------------------------------------------ 인증 +/** + * 사용량 API 인증. 키는 기본 도메인(primaryEnv)의 설정에서만 읽습니다(도메인별 키가 아니라 서버 하나에 한 키 — 웹메이커가 모든 도메인을 한 번에 봄). + * @returns {{ok:true}|{ok:false, status:number, error:string}} + */ +export function authorizeUsage(primaryEnv, request, ip) { + const key = String(primaryEnv.USAGE_API_KEY || '').trim(); + if (!key) return { ok: false, status: 404, error: 'usage_api_disabled' }; + const rate = primaryEnv.RATE; + if (rate?.blocked('usage-fail:' + ip, { limit: USAGE_FAIL_LIMIT })) return { ok: false, status: 429, error: 'rate_limited' }; + const auth = request.headers.get('authorization') || ''; + const given = (auth.match(/^Bearer\s+(.+)$/i)?.[1] || request.headers.get('x-usage-key') || '').trim(); + if (!given || !safeEqual(given, key)) { rate?.hit('usage-fail:' + ip, { limit: USAGE_FAIL_LIMIT, windowSec: 900 }); return { ok: false, status: 401, error: 'unauthenticated' }; } + if (!ipAllowed(ip, parseAllowList(primaryEnv.USAGE_API_ALLOW))) return { ok: false, status: 403, error: 'forbidden_ip' }; + // 성공 요청 빈도(IP 마다 1분 120회). hit() 가 false 를 돌려주면 한도를 넘은 것. + if (rate && !rate.hit('usage:' + ip, { limit: USAGE_RATE_LIMIT, windowSec: 60 })) return { ok: false, status: 429, error: 'rate_limited' }; + return { ok: true }; +} + +// ------------------------------------------------------------------ 통계 +/** 통계 시간대 기준의 날짜 문자열(YYYY-MM-DD). */ +const dateKey = (iso, tz) => { try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso)); } catch { return String(iso).slice(0, 10); } }; +/** + * 최근 days 일의 일별 수·발신 통수와 바이트, 메일함별 기간 합계. 한 번의 조회로 기간 안의 메일(발신은 발송 완료 시각, 수신은 받은 시각)을 읽어 JS 에서 날짜별로 묶습니다 + * (시간대 변환을 SQL 로 하기 어렵고, 메일 수가 많지 않아 충분히 빠름). 기간 시작은 오늘 0시(통계 시간대)에서 days-1 일을 뺀 시각입니다. + */ +async function dailySeries(env, days, tz) { + // 시작 = (오늘 0시 - (days-1)일) 이 속한 날의 0시. 24시간 단위로 빼기만 하면 서머타임 경계에서 한 시간이 밀리므로 그 날의 정오를 잡아 다시 0시를 구합니다. + const today = periodBounds('day', tz); const approx = Date.parse(today.start) - (days - 1) * DAY_MS; + const start = periodBounds('day', tz, new Date(approx + DAY_MS / 2)).start, startMs = Date.parse(start), end = today.end; + const rows = (await env.DB.prepare(`SELECT direction, mailbox, raw_size, sent_at, received_at FROM messages WHERE (direction='inbound' AND received_at>=? AND received_at<?) OR (direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?)`).bind(start, end, start, end).all()).results; + const byDate = new Map(); for (let i = 0; i < days; i++) { const d = dateKey(new Date(startMs + i * DAY_MS + DAY_MS / 2).toISOString(), tz); byDate.set(d, { date: d, sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }); } + const byMailbox = new Map(); + for (const r of rows) { + const at = r.direction === 'outbound' ? r.sent_at : r.received_at; const d = dateKey(at, tz); const bucket = byDate.get(d); const size = Number(r.raw_size || 0); + const mb = byMailbox.get(r.mailbox) || { sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }; byMailbox.set(r.mailbox, mb); + if (r.direction === 'outbound') { if (bucket) { bucket.sent++; bucket.sentBytes += size; } mb.sent++; mb.sentBytes += size; } + else { if (bucket) { bucket.received++; bucket.receivedBytes += size; } mb.received++; mb.receivedBytes += size; } + } + return { start, end, daily: [...byDate.values()], byMailbox }; +} +/** 기간 통계에 바이트 합계를 더한 countMail. */ +async function periodTraffic(env, bounds) { + const c = await countMail(env, bounds); + const s = await env.DB.prepare("SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?").bind(bounds.start, bounds.end).first(); + const r = await env.DB.prepare("SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='inbound' AND received_at>=? AND received_at<?").bind(bounds.start, bounds.end).first(); + return { ...c, sentBytes: Number(s?.b || 0), receivedBytes: Number(r?.b || 0) }; +} +/** + * 도메인(테넌트 env) 하나의 사용량 보고서. + * @param {object} o days: 일별 추이·계정별 기간의 길이, accounts: 계정 목록 포함 여부, series: 일별 추이 포함 여부, entry: 등록부 항목(hosts·enabled·primary) + */ +export async function domainUsage(env, { days = 30, accounts = true, series = true, entry = null } = {}) { + const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env); + const counts = await env.DB.prepare("SELECT COUNT(*) total, SUM(CASE WHEN direction='inbound' THEN 1 ELSE 0 END) inbound, SUM(CASE WHEN direction='outbound' AND state='sent' THEN 1 ELSE 0 END) outbound, SUM(CASE WHEN state IN ('draft','sending') THEN 1 ELSE 0 END) drafts, SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) failed FROM messages").first(); + const total = await totalUsage(env), local = await accountCount(env), ext = await externalUsers(env); + const traffic = {}; for (const kind of ['day', 'week', 'month']) traffic[kind] = await periodTraffic(env, periodBounds(kind, tz)); + const out = { + domain: mailDomain(env), primary: !env.TENANTS || env.TENANTS.primary.env === env, enabled: entry ? entry.enabled !== false : true, hosts: entry?.hosts || [env.PUBLIC_HOST].filter(Boolean), + authMode: authMode(env), mailbox: String(env.MAILBOX || ''), inbound: inboundMode(env), outbound: outboundMode(env), timezone: tz, generatedAt: now(), + accounts: { local, external: ext.accounts.length, total: local + ext.accounts.length, limit: sys.accounts, percent: percentOf(local, sys.accounts), externalSource: ext.accounts.length || authMode(env) === 'webmaker' ? { kind: authMode(env), ok: ext.ok !== false, error: ext.error || null } : null }, + storage: { bytes: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes), largeFiles: await largeFileStats(env) }, + messages: { total: Number(counts?.total || 0), inbound: Number(counts?.inbound || 0), outbound: Number(counts?.outbound || 0), drafts: Number(counts?.drafts || 0), failed: Number(counts?.failed || 0) }, + traffic: { ...traffic, monthlyLimit: sys.monthlyMessages, monthlyPercent: percentOf(traffic.month.total, sys.monthlyMessages) }, + limits: { system: sys, admin }, + }; + if (series || accounts) { + const d = await dailySeries(env, days, tz); + out.period = { days, start: d.start, end: d.end }; + if (series) out.daily = d.daily; + if (accounts) { + const rows = []; + const push = async (u) => { const bytes = await mailboxUsage(env, u.mailbox), limit = await mailboxQuota(env, u.mailbox), n = await env.DB.prepare('SELECT COUNT(*) AS n FROM messages WHERE mailbox=?').bind(u.mailbox).first(); const t = d.byMailbox.get(u.mailbox) || { sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }; rows.push({ login: u.login, name: u.name || '', mailbox: u.mailbox, role: u.role, source: u.source, status: u.status, messages: Number(n?.n || 0), bytes, limit, percent: percentOf(bytes, limit), lastLoginAt: u.lastLoginAt || null, ...t }); }; + for (const u of await listUsers(env)) await push({ ...u, source: 'local', lastLoginAt: u.last_login_at }); + for (const a of ext.accounts) await push({ ...a, source: 'webmaker' }); + // www 연동의 기본 메일함(관리자 계정 행이 없음): 메일함 주인(www 관리자) 이름으로 한 행. + if (!standalone(env) && !rows.some((r) => r.mailbox === String(env.MAILBOX || '').toLowerCase())) { let owner = null; try { owner = mailboxOwner(env); } catch { owner = null; } await push({ login: owner?.login_id || owner?.email || 'admin', name: owner?.name || '', mailbox: String(env.MAILBOX || '').toLowerCase(), role: 'admin', source: 'www', status: owner?.status || 'active', lastLoginAt: null }); } + // 계정 행이 없는데 메일이 있는 메일함(지운 계정): 주소만으로 한 행(사용량이 사라지지 않게). 메일이 있는 메일함 목록은 messages 표에서 직접 뽑습니다. + const withMail = (await env.DB.prepare('SELECT mailbox, COUNT(*) AS n FROM messages GROUP BY mailbox').all()).results; + for (const m of withMail) if (m.n && !rows.some((r) => r.mailbox === m.mailbox)) await push({ login: null, name: '', mailbox: m.mailbox, role: null, source: 'orphan', status: 'deleted', lastLoginAt: null }); + out.accountList = rows; + } + } + return out; +} + +// ------------------------------------------------------------------ HTTP 처리(main.mjs 가 /api/usage 로 부름) +const clampDays = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 1 && n <= 366 ? n : 30; }; +const flag = (v, d) => (v === undefined || v === null || v === '' ? d : !(v === '0' || v === 'false' || v === 'no')); +/** + * GET /api/usage[/<도메인>]. 인증·IP·빈도 검사 뒤 보고서를 JSON 으로 돌려줍니다. 감사 기록은 기본 도메인 DB 에 남깁니다. + * @param {object} tenants domains.mjs 의 테넌트 관리자 + * @param {Request} request + * @param {{path:string[], ip:string, query:URLSearchParams}} o path: 'usage' 뒤의 조각 + */ +export async function usageApi(tenants, request, { path = [], ip = '', query = new URLSearchParams() } = {}) { + const primary = tenants.primary.env; + if (request.method !== 'GET') return bad('method_not_allowed', 405); + const auth = authorizeUsage(primary, request, ip); + // 키가 없어 꺼진 상태(404)만 빼고 모든 거부(틀린 키·허용 밖 IP·빈도 초과)를 기록합니다 — 맞는 키로 허용 밖에서 부르는 것이 가장 중요한 신호. + if (!auth.ok) { if (auth.status !== 404) console.warn(`사용량 API 거부 ${ip}: ${auth.error}(${auth.status})`); return bad(auth.error, auth.status); } + const days = clampDays(query.get('days')); + const headers = { 'Cache-Control': 'no-store' }; + let domain = ''; try { domain = path[0] ? decodeURIComponent(path[0]).trim().toLowerCase() : ''; } catch { return bad('domain_not_found', 404); } + try { + if (domain) { + const t = tenants.byDomain(domain); if (!t) return bad('domain_not_found', 404); + const report = await domainUsage(t.env, { days, accounts: flag(query.get('accounts'), true), series: flag(query.get('series'), true), entry: t.entry }); + await audit(primary, 'usage-api', 'usage_read', domain, { ip, days }); + return json({ ok: true, version: primary.APP_VERSION || null, generatedAt: now(), ...report }, 200, headers); + } + // 도메인 하나가 깨져도(DB 오류 등) 나머지 도메인의 수치는 돌려줍니다. 깨진 도메인은 { domain, error } 로 표시하고 합계에서는 뺍니다. + const domains = []; + for (const t of tenants.all()) { + try { domains.push(await domainUsage(t.env, { days, accounts: flag(query.get('accounts'), false), series: flag(query.get('series'), false), entry: t.entry })); } + catch (e) { console.error(`사용량 API 도메인 오류(${t.entry.domain})`, e.message); domains.push({ domain: t.entry.domain, primary: t.entry.domain === tenants.registry.primary, enabled: t.entry.enabled !== false, error: 'server_error' }); } + } + await audit(primary, 'usage-api', 'usage_read', null, { ip, days, domains: domains.length }); + const sum = (k, f) => domains.reduce((n, d) => n + (d.error ? 0 : f(d)), 0); + return json({ ok: true, version: primary.APP_VERSION || null, generatedAt: now(), primary: tenants.registry.primary, totals: { domains: domains.length, accounts: sum('a', (d) => d.accounts.total), storageBytes: sum('s', (d) => d.storage.bytes), largeFileBytes: sum('l', (d) => d.storage.largeFiles.bytes), messages: sum('m', (d) => d.messages.total), sentMonth: sum('sm', (d) => d.traffic.month.sent), receivedMonth: sum('rm', (d) => d.traffic.month.received) }, domains }, 200, headers); + } catch (e) { console.error('사용량 API 오류', e.message); return bad('server_error', 500); } +} diff --git a/modules/arimail/server/user-cli.mjs b/modules/arimail/server/user-cli.mjs new file mode 100644 index 0000000..4989570 --- /dev/null +++ b/modules/arimail/server/user-cli.mjs @@ -0,0 +1,81 @@ +/** + * 게이트웨이 계정 명령줄 도구(0.8.18~). + * + * 독립 실행(www 사이트 DB 없이 설치)에서는 관리자 페이지에 들어갈 첫 관리자 계정을 화면에서 만들 수 없습니다 + * (관리자 페이지는 관리자 로그인이 있어야 열리므로). 이 도구가 그 첫 계정을 만들고, 이후에도 비밀번호 재설정 같은 + * 운영 작업을 서버 셸에서 할 수 있게 합니다. 서비스가 돌고 있는 중에도 쓸 수 있습니다(SQLite WAL, 짧은 쓰기). + * + * 사용법(서비스 계정으로, 환경 파일을 읽어서): + * DB_FILE=... MAILBOX=admin@example.com node server/user-cli.mjs add <로그인ID> [--admin] [--name "이름"] + * DB_FILE=... node server/user-cli.mjs passwd <로그인ID> + * DB_FILE=... node server/user-cli.mjs list + * + * 비밀번호는 (1) 환경 변수 PASSWORD, (2) --password-stdin(표준 입력 한 줄), (3) 단말 입력(글자 숨김) 순으로 받습니다. + * 비밀번호 규칙은 관리자 페이지의 비밀번호 정책(기본 6자 이상)을 따릅니다(0.8.24~). 독립 실행의 첫 관리자는 서버가 시작할 때 admin / adminpasswd 로 자동 생성되므로 이 도구는 추가 계정·비밀번호 재설정에 씁니다. + * 계정 수 제한(LIMIT_ACCOUNTS, 0.9.0)은 환경 변수로 주면 여기서도 적용됩니다(서비스의 환경 파일을 그대로 넘기면 됨). + * 명령줄 인수로는 받지 않습니다(ps·셸 이력에 남기 때문). + * MAILBOX 는 계정의 메일함 주소(로그인ID@도메인)를 정하는 데 필요하므로 add 때는 꼭 서비스와 같은 값을 주세요. + */ +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { openDb, d1, migrate } from './db.mjs'; +import { createUser, updateUser, listUsers, mailDomain, readPasswordPolicy } from './auth.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const [command, login, ...rest] = process.argv.slice(2); +const flag = (name) => rest.includes(name); +const option = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; }; + +function usage(code = 1) { + console.error('사용법: node server/user-cli.mjs add <login> [--admin] [--name 이름] | passwd <login> | list\n비밀번호: 환경 변수 PASSWORD, --password-stdin, 또는 단말 입력'); + process.exit(code); +} + +/** 표준 입력 전체(첫 줄)를 비밀번호로 읽습니다 — 스크립트에서 `printf '%s' "$PW" | node ... --password-stdin` 형태로 씁니다. */ +async function readStdinLine() { + let data = ''; for await (const chunk of process.stdin) data += chunk; + return data.split(/\r?\n/)[0]; +} +/** 단말에서 글자를 숨기고 비밀번호를 받습니다. 단말이 아니면(파이프) 표준 입력 한 줄을 읽습니다. */ +async function prompt(label) { + if (!process.stdin.isTTY) return readStdinLine(); + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + // 입력 글자를 화면에 쓰지 않도록 출력 함수를 가로챕니다. 질문 글은 먼저 직접 써 둡니다. + process.stdout.write(label); + rl._writeToOutput = () => {}; + const answer = await new Promise((resolve) => rl.question('', resolve)); + rl.close(); process.stdout.write('\n'); + return answer; +} +/** 정책을 한 줄 안내문으로: "6자 이상, 대·소문자 혼용, 숫자 포함, 특수문자 포함" */ +function policyText(p) { return [`${p.minLength}자 이상`, p.mixedCase && '대·소문자 혼용', p.digit && '숫자 포함', p.special && '특수문자 포함'].filter(Boolean).join(', '); } +async function password() { + if (process.env.PASSWORD) return process.env.PASSWORD; + if (flag('--password-stdin')) return readStdinLine(); + const a = await prompt(`비밀번호(${policyText(await readPasswordPolicy(env))}): `); + if (process.stdin.isTTY) { const b = await prompt('비밀번호 확인: '); if (a !== b) { console.error('두 입력이 다릅니다'); process.exit(1); } } + return a; +} + +if (!command || !['add', 'passwd', 'list'].includes(command) || (command !== 'list' && !login)) usage(); +const file = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite'); +const db = openDb(file); migrate(db, join(ROOT, 'migrations')); +const env = { ...process.env, DB: d1(db) }; +try { + if (command === 'list') { + for (const u of await listUsers(env)) console.log(`${u.login}\t${u.mailbox}\t${u.role}\t${u.status}\t${u.name || ''}`); + } else if (command === 'add') { + const id = await createUser(env, { login, name: option('--name') || '', password: await password(), role: flag('--admin') ? 'admin' : 'user' }, 'user-cli'); + console.log(`계정을 만들었습니다: ${login} → ${login}@${mailDomain(env)} (${flag('--admin') ? '관리자' : '사용자'}, id ${id})`); + } else { + const row = db.prepare('SELECT id FROM mail_users WHERE login = ?').get(String(login).toLowerCase()); + if (!row) { console.error(`계정이 없습니다: ${login}`); process.exit(1); } + await updateUser(env, row.id, { password: await password() }); + console.log(`비밀번호를 바꿨습니다: ${login} (웹 세션은 모두 끝났습니다)`); + } +} catch (e) { + // createUser/updateUser 의 오류 코드를 사람이 읽을 말로 바꿉니다. + const messages = { invalid_login: '로그인 ID 는 영문 소문자·숫자·._- 2~64자여야 하고(기본 메일함 별칭은 독립 실행에서만 가능)', weak_password: `비밀번호가 정책에 맞지 않습니다(${e.policy ? policyText(e.policy) : '글자 수 부족'})`, login_taken: '이미 있는 로그인 ID 입니다', account_limit_reached: `계정 수가 시스템 제한(LIMIT_ACCOUNTS=${e.limit})에 이르렀습니다` }; + console.error('실패:', messages[e.message] || e.message); process.exitCode = 1; +} finally { db.close(); } diff --git a/modules/arimail/server/util.mjs b/modules/arimail/server/util.mjs new file mode 100644 index 0000000..e0556aa --- /dev/null +++ b/modules/arimail/server/util.mjs @@ -0,0 +1,76 @@ +export const json = (obj, status = 200, headers = {}) => new Response(JSON.stringify(obj), { + status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }, +}); +export const bad = (error, status = 400, extra = {}) => json({ ok: false, error, ...extra }, status); +export const now = () => new Date().toISOString(); +export const plus = (ms) => new Date(Date.now() + ms).toISOString(); +export const uid = () => crypto.randomUUID(); +export const clean = (v, max = 500) => String(v ?? '').trim().slice(0, max); +export const isEmail = (v) => /^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(v); +export const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +export function address(value) { const s = String(value || '').trim().toLowerCase(); return (s.match(/<([^<>]+)>$/)?.[1] || s).trim(); } +/** + * 주소 목록 문자열("A <a@x>, b@y; c@z")을 소문자 주소 배열로 바꿉니다(중복 제거). + * 쉼표·세미콜론·줄바꿈으로 나누되, 따옴표 안("Kim, Minsu" <k@x>)과 꺾쇠 안의 구분자는 무시합니다. + * 예전에는 따옴표 안의 쉼표에서도 잘라 '"Kim' 과 'Minsu" <k@x>' 두 개로 나뉘어 저장·발송이 실패했습니다. + */ +export function addresses(value) { + if (Array.isArray(value)) return [...new Set(value.map(address).filter(Boolean))]; + const list = []; let current = '', quoted = false, angle = false; + for (const ch of String(value || '')) { + if (ch === '"') quoted = !quoted; else if (ch === '<' && !quoted) angle = true; else if (ch === '>' && !quoted) angle = false; + if (!quoted && !angle && /[,;\n]/.test(ch)) { list.push(current); current = ''; } else current += ch; + } + list.push(current); + return [...new Set(list.map(address).filter(Boolean))]; +} +export function secret(bytes = 32) { + return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('base64url'); +} +export async function sha256(text) { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Buffer.from(buf).toString('hex'); +} +export function safeEqual(a, b) { + if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false; + let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; +} +/** + * 프로세스 메모리 요청 제한. 단일 프로세스 전제입니다. + * - hit(): 시도를 1회 세고, 한도 안이면 true. 로그인 실패처럼 "세어야 할 사건"에만 부릅니다. + * - blocked(): 세지 않고 지금 막혀 있는지만 봅니다. 메일 서버(POP/IMAP/SMTP)는 로그인 전에 blocked() 로 확인하고 실패했을 때만 hit() 합니다. + * 예전에는 성공한 로그인까지 세어, 메일마다 새로 접속하는 메일 프로그램이 15분에 20통을 넘기면 정상 사용자도 차단됐습니다. + */ +export function memoryRateStore() { + const map = new Map(); + return { + blocked(key, { limit = 5 } = {}) { const hit = map.get(key); return !!hit && hit.until >= Date.now() && hit.count >= limit; }, + hit(key, { limit = 5, windowSec = 900 } = {}) { + const t = Date.now(); const hit = map.get(key); + if (!hit || hit.until < t) { map.set(key, { count: 1, until: t + windowSec * 1000 }); return true; } + if (hit.count >= limit) return false; + hit.count++; return true; + }, + }; +} + +/** "이름 <주소>" 에서 표시 이름만(따옴표 제거). 이름이 없으면 ''. */ +export function nameOf(value) { const s = String(value || '').trim(); const m = s.match(/^(.*?)\s*<[^<>]+>$/); return m ? m[1].trim().replace(/^"(.*)"$/, '$1').replace(/\\(["\\])/g, '$1') : ''; } +/** + * 보내는 사람 "이름 <주소>" 를 RFC 5322 에 맞게 만듭니다(0.9.0~). 줄바꿈·따옴표·꺾쇠는 이름에서 지우고, 쉼표·괄호·@ 같은 특수문자가 있으면 + * 이름을 따옴표로 감쌉니다. 이름이 비어 있으면 주소만. 예전에는 계정 이름을 그대로 이어 붙여 "Lee, Director <a@x>" 처럼 깨진 헤더가 나갔습니다. + */ +export function formatSender(name, addr) { + const n = String(name || '').replace(/[\r\n"<>\\]/g, '').trim(); + if (!n) return addr; + return /[,()@;:.\[\]]/.test(n) ? `"${n}" <${addr}>` : `${n} <${addr}>`; +} +/** + * 메일 도메인에서 화면 왼쪽 위에 보일 서비스 이름을 만듭니다(0.8.25~). 계정@ 바로 뒤의 첫 이름표를 대문자로 쓰고 " 메일" 을 붙입니다. + * heart-m.com → "HEART-M 메일", cafe.heart-m.com → "CAFE 메일", arihakdang.com → "ARIHAKDANG 메일" + * 예전에는 lang/ko.json 의 appName("아리학당 메일")이 모든 설치에 그대로 보였습니다. 도메인이 비어 있으면 기본 문구(fallback)를 돌려줍니다. + */ +export function siteName(domain, fallback = '메일') { + const label = String(domain || '').trim().toLowerCase().split('.')[0]; + return label ? `${label.toUpperCase()} 메일` : fallback; +} diff --git a/modules/arimail/server/webhook.mjs b/modules/arimail/server/webhook.mjs new file mode 100644 index 0000000..52b55b9 --- /dev/null +++ b/modules/arimail/server/webhook.mjs @@ -0,0 +1,24 @@ +/** + * Resend 웹훅 서명 검증(Svix 방식). 서명 대상은 "{svix-id}.{svix-timestamp}.{원문 본문}" 이고 + * 비밀키는 "whsec_" 뒤의 base64 를 풀어 HMAC-SHA256 키로 씁니다. 헤더에는 "v1,<base64>" 가 공백으로 여러 개 올 수 있습니다. + */ +import { createHmac, timingSafeEqual } from 'node:crypto'; +export function verifySvix(secret, headers, rawBody, { toleranceSec = 300, nowMs = Date.now() } = {}) { + const id = headers['svix-id'], ts = headers['svix-timestamp'], sig = headers['svix-signature']; + if (!secret || !id || !ts || !sig) return { ok: false, error: 'missing_signature' }; + const t = Number(ts); if (!Number.isFinite(t) || Math.abs(nowMs / 1000 - t) > toleranceSec) return { ok: false, error: 'timestamp_out_of_range' }; + const key = Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64'); + const expected = createHmac('sha256', key).update(`${id}.${ts}.`).update(rawBody).digest(); + for (const candidate of String(sig).split(/\s+/)) { + const [version, value] = candidate.split(','); + if (version !== 'v1' || !value) continue; + const given = Buffer.from(value, 'base64'); + if (given.length === expected.length && timingSafeEqual(given, expected)) return { ok: true }; + } + return { ok: false, error: 'bad_signature' }; +} +/** 테스트·문서용: 같은 규칙으로 서명을 만듭니다. */ +export function signSvix(secret, id, timestamp, rawBody) { + const key = Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64'); + return 'v1,' + createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest('base64'); +} diff --git a/modules/arimail/server/webmaker-snapshot.mjs b/modules/arimail/server/webmaker-snapshot.mjs new file mode 100644 index 0000000..6fd9aab --- /dev/null +++ b/modules/arimail/server/webmaker-snapshot.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * 웹메이커(WebHosting_AI_agent, 이하 webmaker) 데이터 디렉터리를 읽기 전용으로 읽어 "계정 스냅샷" 하나로 만듭니다(1.0.8, 요구사항 1). + * + * 이 파일은 두 가지로 쓰입니다. + * 1) 모듈: server/webmaker.mjs 가 readSnapshotSync(dir) 를 불러 같은 프로세스에서 바로 읽습니다(파일을 읽을 권한이 있을 때, 시험에서도 이 길). + * 2) 명령: `node webmaker-snapshot.mjs <DATA_ROOT>` 가 스냅샷을 JSON 으로 표준 출력에 씁니다. webmaker 의 데이터는 보통 그 서비스 계정(예: master)만 + * 읽을 수 있는 0600/0700 이라, AriMail 실행 계정은 sudo 로 그 계정이 되어 이 명령을 부릅니다(deploy/webmaker-access.sh 가 root 소유 사본과 sudoers 규칙을 설치). + * 그래서 이 파일은 다른 모듈을 import 하지 않고 node 내장 모듈만 씁니다(root 소유 사본 하나로 혼자 돌아가야 함). + * + * webmaker 의 저장 형식(그 저장소 lib/auth.js · lib/store.js · lib/modules.js · lib/domains.js 기준) + * - <DATA_ROOT>/users.json : { <아이디>: { id, name, salt, passHash, role:'admin'|'user', accountType, … } } — 플랫폼 회원. 비밀번호는 scryptSync(비밀번호, salt, 64) 의 16진수. + * - <DATA_ROOT>/sites/<32hex>.json : { id, ownerId, title, domains:[…], webmail:{ enabled, domain }, … } — 사이트. domains 는 사이트에 연결한 도메인 목록. + * - <DATA_ROOT>/modules/<32hex>.json : { members:[ { id, loginId?, email?, name, role:'admin'|'member', approved, salt, hash, mail, mailAddress } ], … } — 사이트 회원. + * loginId 가 있는 회원이 "사이트 관리자 계정"(사이트 관리자 페이지 › 계정 추가)이고, 비밀번호는 같은 scrypt 형식(hash 필드). + * - <DATA_ROOT>/domains.json : { domains:[ { name, type:'subdomain'|'registered', siteId, userId, … } ] } — 서브 도메인·구입 도메인 등록부(사이트 연결 정보). + * + * 스냅샷에는 인증에 필요한 값(salt·해시)이 들어 있으므로 파일로 남기지 않고 메모리에서만 씁니다. 게시글·문의 같은 다른 자료는 읽지 않습니다. + */ +import { readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const SNAPSHOT_FORMAT = 1; +const ID_RE = /^[a-f0-9]{32}$/; +const str = (v, max = 200) => String(v ?? '').trim().slice(0, max); + +/** + * JSON 파일 읽기. 파일이 없으면 null(아직 만들지 않은 것), 그 밖의 실패(권한 없음, 쓰는 중이라 반쪽인 JSON 등)는 예외입니다. + * 왜 구분하나: 깨진 파일을 "회원 없음" 으로 읽으면 그 순간 모든 웹메이커 사용자의 세션이 끊깁니다. 예외로 두면 webmaker.mjs 가 마지막 정상 스냅샷으로 버팁니다. + */ +function readJson(file) { + let text; try { text = readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return null; throw new Error(`${e.code || 'read_error'}: ${file}`); } + try { return JSON.parse(text); } catch { throw new Error(`invalid_json: ${file}`); } +} +/** 디렉터리의 <32hex>.json 파일 이름(확장자 뺀 id)들. 디렉터리가 없으면 빈 목록. */ +function idsIn(dir) { + try { return readdirSync(dir).filter((f) => f.endsWith('.json') && ID_RE.test(f.slice(0, -5))).map((f) => f.slice(0, -5)); } catch { return []; } +} + +/** + * webmaker 데이터 디렉터리를 읽어 스냅샷을 만듭니다. + * @param {string} dir webmaker 의 DATA_ROOT(예: /data/webmaker) + * @returns {{format:number, readAt:string, dir:string, users:object, sites:object, members:object, domainSites:object}} + * users : 아이디 → { id, name, role, salt, passHash, mustChangePassword } + * sites : 사이트 id → { id, ownerId, title, domains:string[], webmailDomain:string, webmailEnabled:boolean } + * members : 사이트 id → [ { id, loginId, email, name, role, approved, salt, hash, mail, mailAddress } ] (loginId 또는 email 이 있는 회원만) + * domainSites : 도메인 이름 → 사이트 id (domains.json 에서 사이트에 연결된 것만) + */ +export function readSnapshotSync(dir) { + const root = String(dir || '').trim(); if (!root) throw new Error('webmaker_dir_missing'); + const users = {}; + const rawUsers = readJson(join(root, 'users.json')); + if (rawUsers && typeof rawUsers === 'object') { + for (const [id, u] of Object.entries(rawUsers)) { + if (!u || typeof u !== 'object' || !u.salt || !u.passHash) continue; + const key = str(u.id || id, 64).toLowerCase(); + users[key] = { id: key, name: str(u.name, 80), role: u.role === 'admin' ? 'admin' : 'user', salt: String(u.salt), passHash: String(u.passHash), mustChangePassword: !!u.mustChangePassword }; + } + } + const sites = {}, members = {}; + for (const id of idsIn(join(root, 'sites'))) { + const s = readJson(join(root, 'sites', id + '.json')); if (!s || typeof s !== 'object') continue; + const domains = (Array.isArray(s.domains) ? s.domains : []).map((d) => str(d, 253).toLowerCase().replace(/^www\./, '')).filter(Boolean); + sites[id] = { id, ownerId: str(s.ownerId, 64).toLowerCase(), title: str(s.title, 120), domains, webmailDomain: str(s.webmail?.domain, 253).toLowerCase(), webmailEnabled: !!(s.webmail && s.webmail.enabled) }; + const m = readJson(join(root, 'modules', id + '.json')); + const list = m && Array.isArray(m.members) ? m.members : []; + members[id] = list.filter((x) => x && typeof x === 'object' && (x.loginId || x.email) && x.salt && x.hash).map((x) => ({ + id: str(x.id, 64), loginId: str(x.loginId, 64).toLowerCase(), email: str(x.email, 200).toLowerCase(), name: str(x.name, 80), role: x.role === 'admin' ? 'admin' : 'member', + approved: x.approved !== false, salt: String(x.salt), hash: String(x.hash), mail: !!x.mail, mailAddress: str(x.mailAddress, 253).toLowerCase(), + })); + } + const domainSites = {}; + const rawDomains = readJson(join(root, 'domains.json')); + for (const d of Array.isArray(rawDomains?.domains) ? rawDomains.domains : []) { + const name = str(d?.name, 253).toLowerCase(), siteId = str(d?.siteId, 32); + if (name && ID_RE.test(siteId)) domainSites[name] = siteId; + } + return { format: SNAPSHOT_FORMAT, readAt: new Date().toISOString(), dir: root, users, sites, members, domainSites }; +} + +// 명령줄로 직접 실행했을 때(sudo 도우미): 첫 인수가 데이터 디렉터리. 실패하면 종료 코드 2 와 오류 한 줄(표준 오류). +const samePath = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return a === b; } }; +if (process.argv[1] && samePath(fileURLToPath(import.meta.url), process.argv[1])) { + try { process.stdout.write(JSON.stringify(readSnapshotSync(process.argv[2]))); } + catch (e) { process.stderr.write(`webmaker-snapshot: ${e.message}\n`); process.exit(2); } +} diff --git a/modules/arimail/server/webmaker.mjs b/modules/arimail/server/webmaker.mjs new file mode 100644 index 0000000..ebe163e --- /dev/null +++ b/modules/arimail/server/webmaker.mjs @@ -0,0 +1,207 @@ +/** + * webmaker 인증 모드(1.0.8, 요구사항 1) — 웹메이커(WebHosting_AI_agent)의 사용자 계정과 인증을 AriMail 이 그대로 씁니다. + * + * 왜 + * 웹메이커는 사이트(예: site.ariweb.net)를 만들고 그 사이트의 관리자 계정·회원을 둡니다. 그 사용자들에게 메일을 열어 주려면 예전에는 웹메이커가 AriMail 관리자 + * API 로 계정을 따로 만들고 메일 비밀번호를 따로 정해야 했습니다(웹메이커는 scrypt 해시만 갖고 있어 원문 비밀번호를 넘길 수 없음). 이제 AriMail 이 웹메이커의 + * 사용자 파일을 **읽기 전용**으로 참조해 같은 방식(scrypt)으로 비밀번호를 검사하므로, 웹메이커 계정 그대로 웹 메일·IMAP·POP·SMTP 에 로그인합니다. + * + * 도메인 ↔ 웹메이커 계정 대응(accountsFor) + * 도메인(테넌트)의 메일 도메인(MAILBOX 의 @ 뒤)으로 웹메이커의 사이트를 찾습니다: 등록부 항목의 siteId 가 있으면 그 사이트, 없으면 domains.json(서브 도메인 등록) → + * 사이트의 domains 목록 → 사이트의 메일 도메인(webmail.domain) 순으로 같은 이름을 찾습니다. + * - 사이트가 있으면: 그 사이트의 회원 중 로그인 ID(loginId)가 있는 계정(사이트 관리자 페이지에서 추가한 "사용자") → `loginId@도메인`(역할 user), + * 사이트 소유자(users.json 의 ownerId) → `소유자ID@도메인`(역할 user; 소유자가 웹메이커 관리자이기도 하면 admin), 웹메이커 관리자(role=admin, 보통 admin) → `admin@도메인`(역할 admin). + * 같은 로그인 ID 가 겹치면 사이트 회원 → 소유자 → 플랫폼 관리자 순으로 먼저 찾은 쪽이 그 주소를 씁니다. + * - 사이트가 없으면(플랫폼 도메인 자체 등): 웹메이커의 모든 회원 → `아이디@도메인`(관리자는 admin, 나머지는 user). + * 요구사항의 예: 웹메이커에서 site.ariweb.net 을 만들고 메일을 허용하면 admin@site.ariweb.net(웹메이커 admin)으로 수·발신하고, 사이트에 사용자를 추가하면 + * user@site.ariweb.net 도 같은 비밀번호로 씁니다. 이 규칙은 모든 도메인(기본·추가)에 똑같이 적용됩니다. + * + * 파일 읽기(snapshot) + * 웹메이커 데이터(users.json 0600, DATA_ROOT 0700)는 보통 웹메이커 서비스 계정만 읽을 수 있습니다. 같은 계정이거나 읽기 권한이 있으면 이 프로세스가 바로 읽고(readSnapshotSync), + * 아니면 등록부의 webmakerUser(예: master)가 적힌 도메인은 "읽기 도우미" 로 JSON 을 받습니다(deploy/webmaker-access.sh 가 설치): + * 1) systemd 소켓(/run/arimail-webmaker/<디렉터리 슬러그>.sock): 접속마다 그 계정으로 webmaker-snapshot.mjs 를 띄워 결과를 소켓에 씁니다(Accept=yes). + * AriMail 서비스는 NoNewPrivileges·CapabilityBoundingSet 로 잠겨 있어 sudo 를 쓸 수 없으므로 이것이 기본 경로입니다(ProtectSystem=strict 에서도 소켓 connect 는 됨). + * 2) sudo 도우미(`sudo -n -u <계정> /usr/local/lib/arimail/webmaker-snapshot <디렉터리>`): systemd 가 없는 서버용 대안(소켓 파일이 없을 때). + * 스냅샷은 3초 캐시하고, 로그인 실패 때는 한 번 다시 읽어 + * 방금 바뀐 비밀번호·새 계정을 바로 반영합니다. 읽기에 실패하면 마지막 성공 스냅샷으로 계속 동작하며 오류를 기록합니다(웹메이커 쪽 일시 장애로 메일이 멈추지 않게). + * + * 주체(principal) id: 'wm:u:<아이디>'(플랫폼 회원), 'wm:m:<사이트 id>:<회원 id>'(사이트 회원). 세션·설정(prefs)·메일함 크기(quota)는 이 id 로 저장되며, 비밀번호·이름·역할은 + * 웹메이커가 주인이므로 AriMail 에서 바꿀 수 없습니다(관리자 페이지에는 읽기 전용 행으로 보이고 POP/IMAP 허용·메일함 크기만 바꿉니다). + */ +import { execFile } from 'node:child_process'; +import { connect } from 'node:net'; +import { existsSync } from 'node:fs'; +import { scrypt, timingSafeEqual, randomBytes } from 'node:crypto'; +import { readSnapshotSync } from './webmaker-snapshot.mjs'; + +/** sudo 도우미 경로(deploy/webmaker-access.sh 가 설치). sudoers 규칙은 이 경로 + 데이터 디렉터리 인수 하나만 허용합니다. */ +export const WEBMAKER_HELPER = '/usr/local/lib/arimail/webmaker-snapshot'; +/** systemd 소켓 도우미의 소켓 파일: 데이터 디렉터리마다 하나(/data/webmaker → /run/arimail-webmaker/data-webmaker.sock). webmaker-access.sh 와 같은 규칙. */ +export const webmakerSocketPath = (dir) => '/run/arimail-webmaker/' + String(dir || '').replace(/^\/+/, '').replace(/[^A-Za-z0-9]/g, '-') + '.sock'; +const CACHE_MS = 3000, FRESH_MIN_MS = 2000; +/** 메일 주소의 @ 앞부분으로 쓸 수 있는 로그인 ID(웹메이커 lib/mailsys.js 의 LOCAL_RE 와 같은 규칙). */ +const LOCAL_RE = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/; + +/** 이 env(도메인)가 webmaker 인증 모드인지. LOGIN_MODE=webmaker 와 데이터 디렉터리(WEBMAKER_DIR)가 모두 있어야 합니다. */ +export const isWebmaker = (env) => String(env?.LOGIN_MODE || '') === 'webmaker' && !!env?.WEBMAKER_DIR; +const mailDomainOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase().split('@')[1] || ''; + +// ------------------------------------------------------------------ 비밀번호(scrypt) +/** + * 웹메이커와 같은 방식으로 비밀번호를 검사합니다: scrypt(비밀번호, salt 문자열, 64바이트, 기본 매개변수 N=16384·r=8·p=1) 의 16진수를 저장값과 상수 시간 비교. + * 웹메이커(lib/auth.js hashPassword / lib/modules.js hashPw)는 crypto.scryptSync(pw, salt, 64) 를 쓰며 salt 는 16진수 "문자열" 그대로 입력합니다(디코딩하지 않음). + */ +export function verifyScrypt(password, salt, storedHex) { + return new Promise((resolve) => { + if (!salt || !storedHex) return resolve(false); + scrypt(String(password ?? ''), String(salt), 64, (err, key) => { + if (err) return resolve(false); + let stored; try { stored = Buffer.from(String(storedHex), 'hex'); } catch { return resolve(false); } + resolve(stored.length === key.length && timingSafeEqual(stored, key)); + }); + }); +} +// 없는 계정의 로그인에도 실제 scrypt 를 한 번 돌려 걸리는 시간을 맞춥니다(계정 존재 여부가 응답 시간으로 새지 않게). +const DUMMY_SALT = randomBytes(16).toString('hex'); +const dummyVerify = () => verifyScrypt(randomBytes(8).toString('hex'), DUMMY_SALT, '00'.repeat(64)); + +// ------------------------------------------------------------------ 스냅샷 읽기(캐시·sudo 도우미) +const cache = new Map(); // "<dir>|<user>" → { at, data, loading } +/** sudo 도우미로 스냅샷을 받습니다(JSON 표준 출력). 실패 사유(권한 없음·도우미 없음·JSON 아님)는 오류 메시지에 담습니다. */ +function viaHelper(dir, user) { + return new Promise((resolve, reject) => { + execFile('sudo', ['-n', '-u', user, WEBMAKER_HELPER, dir], { timeout: 15000, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) return reject(new Error(`webmaker_helper_failed: ${String(stderr || err.message).trim().split('\n').pop()}`)); + try { resolve(JSON.parse(stdout)); } catch { reject(new Error('webmaker_helper_bad_output')); } + }); + }); +} +/** systemd 소켓 도우미로 스냅샷을 받습니다: 접속하면 도우미가 JSON 을 쓰고 끊습니다(끝까지 읽어 파싱). */ +function viaSocket(path) { + return new Promise((resolve, reject) => { + const sock = connect(path); let buf = ''; + sock.setEncoding('utf8'); sock.setTimeout(15000, () => sock.destroy(new Error('timeout'))); + sock.on('data', (d) => { buf += d; }); + sock.on('end', () => { try { resolve(JSON.parse(buf)); } catch { reject(new Error('webmaker_socket_bad_output')); } }); + sock.on('error', (e) => reject(new Error(`webmaker_socket_failed: ${e.message}`))); + }); +} +async function fetchSnapshot(env) { + if (env.WEBMAKER_USER) { + // 소켓 도우미(시험에서는 WEBMAKER_SOCKET 으로 경로 지정)가 있으면 그것, 없으면 sudo 도우미. + const sock = env.WEBMAKER_SOCKET || webmakerSocketPath(env.WEBMAKER_DIR); + if (existsSync(sock)) return viaSocket(sock); + return viaHelper(env.WEBMAKER_DIR, env.WEBMAKER_USER); + } + return readSnapshotSync(env.WEBMAKER_DIR); +} +/** + * 스냅샷(캐시 3초). fresh 면 캐시를 무시하고 다시 읽습니다(로그인 실패 뒤 재확인용). + * 읽기에 실패했는데 예전 스냅샷이 있으면 그것을 돌려주고 경고를 남깁니다(한 번만). 예전 것도 없으면 오류를 던집니다 → 호출자는 "웹메이커 계정 없음" 으로 처리. + */ +export async function snapshot(env, { fresh = false } = {}) { + const key = `${env.WEBMAKER_DIR}|${env.WEBMAKER_USER || ''}`; + let c = cache.get(key); if (!c) { c = { at: 0, data: null, loading: null, warned: false, freshAt: 0 }; cache.set(key, c); } + // fresh(로그인 실패 뒤 재확인)는 디렉터리마다 FRESH_MIN_MS 에 한 번만 실제로 다시 읽습니다 — 틀린 비밀번호를 퍼부어 파일 읽기(sudo 도우미 실행)를 되풀이시키지 못하게. + if (fresh && Date.now() - c.freshAt < FRESH_MIN_MS) fresh = false; + if (!fresh && c.data && Date.now() - c.at < CACHE_MS) return c.data; + // 다시 읽기를 요구했는데 이미 읽는 중이면 그 결과(변경 전일 수 있음)를 기다린 뒤 한 번 더 읽습니다. + if (fresh && c.loading) { try { await c.loading; } catch { /* 아래에서 다시 시도 */ } } + if (fresh) c.freshAt = Date.now(); + if (!c.loading) { + c.loading = fetchSnapshot(env).then((d) => { c.data = d; c.at = Date.now(); c.warned = false; return d; }).catch((e) => { + if (c.data) { if (!c.warned) { c.warned = true; console.error(` ⚠️ [${env.DOMAIN || mailDomainOf(env)}] 웹메이커 계정 파일을 읽지 못해 마지막 스냅샷을 씁니다: ${e.message}`); } c.at = Date.now(); return c.data; } + throw e; + }).finally(() => { c.loading = null; }); + } + return c.loading; +} +/** 시험·재시작용: 캐시 비우기. */ +export function clearSnapshotCache() { cache.clear(); } + +// ------------------------------------------------------------------ 도메인의 계정 목록 +/** 도메인에 대응하는 웹메이커 사이트를 찾습니다(없으면 null). env.WEBMAKER_SITE(등록부의 siteId)가 우선입니다. */ +export function siteFor(snap, domain, siteId = '') { + if (siteId && snap.sites[siteId]) return snap.sites[siteId]; + const d = String(domain || '').toLowerCase(); + if (snap.domainSites[d] && snap.sites[snap.domainSites[d]]) return snap.sites[snap.domainSites[d]]; + for (const s of Object.values(snap.sites)) if (s.domains.includes(d)) return s; + for (const s of Object.values(snap.sites)) if (s.webmailDomain === d) return s; + return null; +} +/** + * 도메인의 웹메이커 계정 목록(해시 포함 — 모듈 안에서만 씀). 같은 로그인 ID 는 먼저 넣은 쪽이 이깁니다(사이트 회원 → 소유자 → 플랫폼 관리자/회원). + * @returns {Array<{id,login,name,role,mailbox,kind,salt,hash,approved}>} kind: member(사이트 회원) | owner(사이트 소유자) | admin(웹메이커 관리자) | user(플랫폼 회원) + */ +export function accountsFor(env, snap) { + const domain = mailDomainOf(env), site = siteFor(snap, domain, env.WEBMAKER_SITE || ''); + const out = [], seen = new Set(); + const push = (a) => { if (!LOCAL_RE.test(a.login) || seen.has(a.login)) return; seen.add(a.login); out.push({ ...a, mailbox: `${a.login}@${domain}` }); }; + // 역할: 웹메이커 관리자(role=admin)만 이 도메인의 관리자(/admin·/setup — 발신 모듈·DNS 인증 정보까지 다룸). 사이트 소유자·사용자·일반 회원은 일반 계정입니다(최소 권한). + const platform = (u, kind) => push({ id: 'wm:u:' + u.id, login: u.id, name: u.name || u.id, role: u.role === 'admin' ? 'admin' : 'user', kind, salt: u.salt, hash: u.passHash, approved: true }); + if (site) { + for (const m of snap.members[site.id] || []) if (m.loginId) push({ id: `wm:m:${site.id}:${m.id}`, login: m.loginId, name: m.name || m.loginId, role: 'user', kind: 'member', salt: m.salt, hash: m.hash, approved: m.approved !== false }); + if (site.ownerId && snap.users[site.ownerId]) platform(snap.users[site.ownerId], 'owner'); + for (const u of Object.values(snap.users)) if (u.role === 'admin') platform(u, 'admin'); + } else { + for (const u of Object.values(snap.users)) platform(u, 'user'); + } + return out; +} +/** 로그인 입력(아이디 또는 아이디@이 도메인)을 로그인 ID 로. 다른 도메인의 주소는 null. */ +function loginKey(env, login) { + const key = String(login || '').trim().toLowerCase(); if (!key) return null; + if (!key.includes('@')) return key; + const [local, dom] = key.split('@'); return dom === mailDomainOf(env) ? local : null; +} +const principal = (a) => ({ id: a.id, source: 'webmaker', login: a.login, email: a.mailbox, name: a.name, role: a.role, mailbox: a.mailbox, kind: a.kind }); + +/** + * 로그인 이름과 비밀번호로 웹메이커 계정을 검사합니다. 맞으면 주체(principal), 아니면 null. + * 캐시된 스냅샷에서 실패하면 파일을 다시 읽어 한 번 더 검사합니다(웹메이커에서 방금 바꾼 비밀번호·새 계정 반영). 스냅샷을 읽을 수 없으면 null(기록만). + */ +export async function verifyWebmaker(env, login, password) { + const key = loginKey(env, login); if (!key || !password) return null; + let snap; try { snap = await snapshot(env); } catch (e) { console.error(` ✖ [${env.DOMAIN || mailDomainOf(env)}] 웹메이커 계정 파일을 읽을 수 없습니다: ${e.message}`); await dummyVerify(); return null; } + // 계정이 있든 없든(승인 취소 포함) 시도마다 scrypt 를 꼭 한 번 돌려, 걸리는 시간으로 계정 존재 여부가 새지 않게 합니다(두 시도 모두 같은 횟수). + const check = async (a) => (a && a.approved ? verifyScrypt(password, a.salt, a.hash) : dummyVerify().then(() => false)); + let a = accountsFor(env, snap).find((x) => x.login === key); + if (await check(a)) return principal(a); + // 두 번째 시도: 방금 바뀐 파일. 캐시가 아주 새것이어도(3초) 비밀번호 변경 직후의 로그인을 놓치지 않게 한 번은 다시 읽습니다. + try { snap = await snapshot(env, { fresh: true }); } catch { await dummyVerify(); return null; } + a = accountsFor(env, snap).find((x) => x.login === key); + if (await check(a)) return principal(a); + return null; +} +/** + * 주체 id('wm:…')로 계정을 찾습니다(세션 확인용). 웹메이커에서 지워졌거나 승인이 풀린 계정은 null → 세션 종료. + * 파일을 읽지 못한 경우(예전 스냅샷도 없음)는 예외를 그대로 던집니다 — auth.currentUser 가 "일시 장애" 로 보고 세션을 지우지 않습니다. + */ +export async function webmakerPrincipalById(env, id) { + const snap = await snapshot(env); + const a = accountsFor(env, snap).find((x) => x.id === id && x.approved); + return a ? principal(a) : null; +} +/** 메일함 주소로 웹메이커 계정을 찾습니다(자동 처리·수신 판단용). */ +export async function webmakerByMailbox(env, mailbox) { + let snap; try { snap = await snapshot(env); } catch { return null; } + const a = accountsFor(env, snap).find((x) => x.mailbox === String(mailbox || '').toLowerCase() && x.approved); + return a ? principal(a) : null; +} +/** 웹메이커 계정의 메일함 주소 목록(수신 허용 판단). 승인이 풀린 회원의 메일함도 넣습니다(정지된 로컬 계정과 같은 취급: 메일은 받아 둠). */ +export async function webmakerMailboxes(env) { + let snap; try { snap = await snapshot(env); } catch { return []; } + return accountsFor(env, snap).map((a) => a.mailbox); +} +/** 관리자 페이지·설정 화면용 목록(해시 없음). status 는 승인 여부로 active / suspended. */ +export async function webmakerAccounts(env) { + let snap; try { snap = await snapshot(env); } catch (e) { return { ok: false, error: e.message, accounts: [] }; } + const site = siteFor(snap, mailDomainOf(env), env.WEBMAKER_SITE || ''); + return { ok: true, readAt: snap.readAt, site: site ? { id: site.id, title: site.title, ownerId: site.ownerId } : null, accounts: accountsFor(env, snap).map((a) => ({ id: a.id, login: a.login, name: a.name, mailbox: a.mailbox, role: a.role, kind: a.kind, status: a.approved ? 'active' : 'suspended', source: 'webmaker' })) }; +} +/** 로그인 ID 가 웹메이커 계정과 겹치는지(로컬 계정을 만들 때 login_taken 으로 막기 위해). */ +export async function webmakerHasLogin(env, login) { + let snap; try { snap = await snapshot(env); } catch { return false; } + const key = String(login || '').toLowerCase(); + return accountsFor(env, snap).some((a) => a.login === key); +} diff --git a/modules/arimail/test/_helpers.mjs b/modules/arimail/test/_helpers.mjs new file mode 100644 index 0000000..9e6408c --- /dev/null +++ b/modules/arimail/test/_helpers.mjs @@ -0,0 +1,45 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { openDb, d1, migrate } from '../server/db.mjs'; +import { hashPassword } from '../server/auth.mjs'; +import { memoryRateStore } from '../server/util.mjs'; +const HERE = dirname(fileURLToPath(import.meta.url)); +export async function makeWwwDb({ login = 'arihakdang', password = 'admin-secret-12', email = 'admin@arihakdang.com', role = 'admin', status = 'active' } = {}) { + const db = openDb(':memory:'); + db.exec('CREATE TABLE users(id TEXT PRIMARY KEY,email TEXT UNIQUE,login_id TEXT,name TEXT,role TEXT,status TEXT,password_hash TEXT)'); + db.prepare('INSERT INTO users VALUES(?,?,?,?,?,?,?)').run('admin-1', email, login, '관리자', role, status, await hashPassword(password, 1000)); + db.prepare('INSERT INTO users VALUES(?,?,?,?,?,?,?)').run('student-1', 'student@example.test', 'student', '학생', 'student', 'active', await hashPassword(password, 1000)); + return db; +} +export async function makeEnv(over = {}) { + const db = openDb(':memory:'); migrate(db, join(HERE, '..', 'migrations'), { quiet: true }); + return { DB: d1(db), RATE: memoryRateStore(), WWW_DB: await makeWwwDb(), MAILBOX: 'admin@arihakdang.com', RESEND_API_KEY: 'test', POP_AUTH_DELAY_MS: 0, ...over }; +} +export function runner() { + let pass = 0, fail = 0; + const t = async (name, fn) => { try { await fn(); console.log(' ok ' + name); pass++; } catch (e) { console.log(' FAIL ' + name + ' — ' + (e.stack || e.message)); fail++; } }; + const done = () => { console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); }; + return { t, done }; +} +export const eq = (a, b, m) => { if (a !== b) throw new Error(`${m}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`); }; +export const ok = (v, m) => { if (!v) throw new Error(m); }; +/** Resend 흉내: 수신 목록/상세/첨부/원본 다운로드/발송. */ +export function fakeResend({ received = [], sent = [], raw = {}, attachments = {}, calls = [], failStatus = 0 } = {}) { + return async (url, options = {}) => { + const u = new URL(url), path = u.pathname; const method = options.method || 'GET'; + calls.push({ path, method, body: options.body ? JSON.parse(options.body) : undefined, key: options.headers?.['Idempotency-Key'] }); + if (failStatus) return Response.json({ message: 'API key is invalid' }, { status: failStatus }); + if (path === '/domains') return Response.json({ data: [{ name: 'arihakdang.com', status: 'verified' }] }); + if (path === '/emails' && method === 'POST') { const id = 'sent-' + calls.length; sent.push({ id, ...JSON.parse(options.body), created_at: new Date().toISOString() }); return Response.json({ id }); } + if (path === '/emails/receiving') return Response.json({ data: received.map(({ html, text, headers, ...s }) => s), has_more: false }); + if (path === '/emails') return Response.json({ data: sent, has_more: false }); + let m; + if ((m = path.match(/^\/emails\/receiving\/([^/]+)\/attachments$/))) return Response.json({ data: (attachments[m[1]] || []), has_more: false }); + if ((m = path.match(/^\/emails\/receiving\/([^/]+)$/))) { const item = received.find((r) => r.id === m[1]); return item ? Response.json({ ...item, raw: raw[item.id] ? { download_url: 'https://files.test/raw/' + item.id, expires_at: '2099-01-01' } : null }) : Response.json({ message: 'not found' }, { status: 404 }); } + if ((m = path.match(/^\/emails\/([^/]+)$/))) { const item = sent.find((r) => r.id === m[1]); return item ? Response.json(item) : Response.json({}, { status: 404 }); } + if (u.hostname === 'files.test') { const id = path.split('/').pop(); if (path.startsWith('/raw/')) return new Response(raw[id]); const a = Object.values(attachments).flat().find((x) => x.id === id); return new Response(a?.bytes || 'file-bytes'); } + return Response.json({ data: [], has_more: false }); + }; +} +export const request = (path, cookie, method = 'GET', body, extraHeaders = {}) => new Request('https://mail.arihakdang.com/api/' + path, { method, headers: { ...(cookie ? { Cookie: cookie } : {}), ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...extraHeaders }, body: body instanceof FormData ? body : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body) }); +export const params = (path, ip = '127.0.0.1') => ({ path: path.split('?')[0].split('/').filter(Boolean), ip }); diff --git a/modules/arimail/test/config.test.mjs b/modules/arimail/test/config.test.mjs new file mode 100644 index 0000000..fff5be9 --- /dev/null +++ b/modules/arimail/test/config.test.mjs @@ -0,0 +1,185 @@ +/** + * 1.0.0 시험: 설정 파일(config.mjs)·발송 모듈 선택(providers)·SMTP 중계 클라이언트·내장 Haraka 수신 전달 API·초기 설정 API. + * 실행: node test/config.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다) + */ +import net from 'node:net'; +import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs'; +import { handle } from '../server/api.mjs'; +import { loadConfig, saveConfig, publicConfig, applyConfig, configFile, setupToken, generateDkim, dnsRecords, coerceValue, CONFIG_SCHEMA, maskSecret } from '../server/config.mjs'; +import { sendMail, outboundMode, inboundMode, relaySettings, outboundStatus, usesResend, usesHaraka } from '../server/providers/index.mjs'; +import { smtpSend, smtpProbe, smtpErrorCode } from '../server/providers/smtp-client.mjs'; +import { payloadToEml } from '../server/providers/smtp.mjs'; +import { setupApi, createSetupServer, harakaStatus } from '../server/setup.mjs'; +import { sendDraft, saveDraft } from '../server/store.mjs'; +import { createSession } from '../server/auth.mjs'; + +const { t, done } = runner(); +const api = (env, path, cookie, method = 'GET', body, extraHeaders = {}) => { + const headers = { ...(cookie ? { cookie } : {}), ...extraHeaders }; + let payload = body; + if (body !== undefined && !(body instanceof FormData) && typeof body !== 'string' && !Buffer.isBuffer(body)) { payload = JSON.stringify(body); headers['content-type'] = 'application/json'; } + return handle(env, new Request('https://mail.arihakdang.com/api/' + path, { method, headers, body: payload }), { path: path.split('?')[0].split('/').filter(Boolean), ip: '127.0.0.1' }); +}; +const adminLogin = async (env) => { const r = await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' }, { 'x-mail-scope': 'admin' }); return r.headers.get('set-cookie').split(';')[0]; }; +const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-cfg-')); + +/** + * 시험용 SMTP 서버: EHLO/STARTTLS 없음/AUTH PLAIN/MAIL/RCPT/DATA 를 받아 기록합니다. + * @param {object} o rejectRcpt: 거부할 주소, authUser/authPass: 인증 요구, dataCode: DATA 끝 응답 코드 + */ +function fakeSmtp({ rejectRcpt = [], authUser = '', authPass = '', dataCode = 250 } = {}) { + const sessions = []; + const server = net.createServer((socket) => { + const s = { from: null, rcpt: [], data: '', auth: null }; sessions.push(s); let inData = false, buf = ''; + socket.write('220 fake.test ESMTP\r\n'); + socket.on('data', (chunk) => { + buf += chunk.toString('latin1'); + while (true) { + const i = buf.indexOf('\r\n'); if (i < 0) break; const line = buf.slice(0, i); buf = buf.slice(i + 2); + if (inData) { if (line === '.') { inData = false; socket.write(dataCode === 250 ? '250 2.0.0 Ok: queued as FAKE123\r\n' : `${dataCode} rejected by fake\r\n`); } else s.data += (line.startsWith('..') ? line.slice(1) : line) + '\r\n'; continue; } + const cmd = line.split(' ')[0].toUpperCase(); + if (cmd === 'EHLO') socket.write('250-fake.test\r\n250-SIZE 10485760\r\n' + (authUser ? '250-AUTH PLAIN LOGIN\r\n' : '') + '250 8BITMIME\r\n'); + else if (cmd === 'AUTH') { const [, user, pass] = Buffer.from(line.split(' ')[2] || '', 'base64').toString('utf8').split('\0'); s.auth = user; socket.write(user === authUser && pass === authPass ? '235 2.7.0 ok\r\n' : '535 5.7.8 authentication failed\r\n'); } + else if (cmd === 'MAIL') { s.from = line.match(/<([^>]*)>/)?.[1]; socket.write('250 ok\r\n'); } + else if (cmd === 'RCPT') { const a = line.match(/<([^>]*)>/)?.[1]; if (rejectRcpt.includes(a)) socket.write('550 5.1.1 no such user\r\n'); else { s.rcpt.push(a); socket.write('250 ok\r\n'); } } + else if (cmd === 'DATA') { inData = true; socket.write('354 go ahead\r\n'); } + else if (cmd === 'QUIT') { socket.write('221 bye\r\n'); socket.end(); } + else socket.write('250 ok\r\n'); + } + }); + }); + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, sessions, close: () => server.close() }))); +} + +await t('1.0.0 설정 파일: 첫 시작에 환경 변수 값을 가져와 만들고, 파일 값이 환경 변수보다 우선하며, 저장은 즉시 env 에 적용되고 비밀값은 가려서 보여 준다', async () => { + const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), RESEND_API_KEY: 're_oldkey_from_env_1234', MAIL_FROM_NAME: '예전 이름', STATS_TIMEZONE: 'Asia/Seoul' }); + const cfg = loadConfig(env); + ok(existsSync(configFile(env)) && configFile(env) === join(dir, 'config.json'), '설정 파일이 DB 옆에 생김'); + eq(cfg.RESEND_API_KEY, 're_oldkey_from_env_1234', '환경 변수 값 가져오기'); eq(cfg.inbound, 'resend', '기본 수신 resend'); eq(cfg.outbound, 'resend', '기본 발신 resend'); + ok(cfg.HARAKA_TOKEN && cfg.HARAKA_TOKEN.length > 20, '내부 토큰 자동 생성'); ok(env.CONFIG_CREATED, '새로 만듦 표시'); + ok(JSON.parse(readFileSync(configFile(env), 'utf8'))._meta.importedFromEnv.includes('MAIL_FROM_NAME'), '가져온 키 기록'); + // 저장 → 즉시 적용 + 알림 + let notified = 0; env.ON_CONFIG_CHANGE = () => notified++; + const pub = saveConfig(env, { RESEND_API_KEY: 're_newkey_abcdefgh_9876', outbound: 'smtp', SMTP_RELAY_HOST: 'smtp.example.test', SMTP_RELAY_PORT: '2525', SITE_TITLE: '새 제목' }); + eq(env.RESEND_API_KEY, 're_newkey_abcdefgh_9876', '저장 즉시 env 반영'); eq(env.MAIL_OUTBOUND, 'smtp', '발신 방식'); eq(env.SITE_TITLE, '새 제목', '제목'); eq(notified, 1, '변경 알림'); + eq(pub.values.RESEND_API_KEY, maskSecret('re_newkey_abcdefgh_9876'), '비밀값 가림'); ok(!pub.values.RESEND_API_KEY.includes('abcdefgh'), '중간 글자 숨김'); + eq(pub.sources.RESEND_API_KEY, 'config', '출처 config'); eq(pub.values.SMTP_RELAY_PORT, 2525, '숫자 변환'); eq(pub.present.HARAKA_TOKEN, true, 'hidden 항목은 있음/없음만'); ok(!('HARAKA_TOKEN' in pub.values), 'hidden 값 비노출'); + // 다시 읽으면 파일이 기준(환경 변수에 옛 값이 있어도) + const env2 = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), RESEND_API_KEY: 're_oldkey_from_env_1234', SITE_TITLE: '환경 제목' }); + loadConfig(env2); eq(env2.RESEND_API_KEY, 're_newkey_abcdefgh_9876', '파일 값이 환경 변수보다 우선'); eq(env2.SITE_TITLE, '새 제목', '제목도 파일 우선'); ok(!env2.CONFIG_CREATED, '다시 만들지 않음'); + // 비우기: 파일에 '' 로 저장되면 환경 변수 값이 되살아나지 않음 + saveConfig(env2, { SITE_TITLE: '' }); eq(env2.SITE_TITLE, '', '비운 값은 빈 값'); eq(publicConfig(env2).sources.SITE_TITLE, 'default', '비운 값은 환경 변수로 되살아나지 않음(출처 default)'); + // 검증 + let err = null; try { saveConfig(env2, { outbound: 'pigeon' }); } catch (e) { err = e; } eq(err?.message, 'validation', '모르는 발신 방식 거부'); eq(err?.field, 'outbound', '필드 이름'); + err = null; try { saveConfig(env2, { HARAKA_PORT: '25a' }); } catch (e) { err = e; } eq(err?.message, 'validation', '숫자 아님 거부'); + eq(coerceValue(CONFIG_SCHEMA.find((i) => i.key === 'SETUP_DONE'), '1'), true, 'bool 변환'); + // 숨김 항목은 일반 저장으로 바꿀 수 없음 + saveConfig(env2, { HARAKA_TOKEN: 'hacked' }); ok(env2.HARAKA_TOKEN !== 'hacked', 'hidden 항목 보호'); + // 토큰 파일 + const tok = setupToken(env2); eq(setupToken(env2), tok, '토큰 고정'); ok(existsSync(join(dir, 'setup-token')), '토큰 파일'); + // DKIM + const dns = generateDkim(env2); ok(dns.hasDkim && dns.records.some((r) => r.name.startsWith('arimail._domainkey.') && r.value.startsWith('v=DKIM1; k=rsa; p=')), 'DKIM 레코드'); + ok(env2.HARAKA_DKIM_PRIVATE.includes('BEGIN RSA PRIVATE KEY'), '비밀키 저장'); const pub1 = env2.HARAKA_DKIM_PUBLIC; generateDkim(env2); eq(env2.HARAKA_DKIM_PUBLIC, pub1, 'force 없이는 유지'); generateDkim(env2, { force: true }); ok(env2.HARAKA_DKIM_PUBLIC !== pub1, 'force 로 교체'); + ok(dnsRecords(env2).records.find((r) => r.kind === 'MX').value.includes('mail.arihakdang.com'), 'MX 는 호스트 이름'); + rmSync(dir, { recursive: true, force: true }); +}); + +await t('1.0.0 발송 모듈 선택: resend 는 Resend API, smtp 는 중계 서버(봉투에 Bcc, 헤더에는 없음, 점 스터핑, 일부 거부·전체 거부·인증), haraka 는 로컬 제출 포트', async () => { + const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }); + applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' }); + eq(outboundMode(env), 'resend', '기본 resend'); ok(usesResend(env) && !usesHaraka(env), '사용 여부'); + const payload = { from: 'Admin <admin@arihakdang.com>', to: ['a@example.test'], cc: ['c@example.test'], bcc: ['b@example.test'], subject: '제목 한글', text: '본문\n.점 줄\n끝', html: '<p>본문</p>', attachments: [{ filename: '첨부.txt', content: Buffer.from('hello').toString('base64'), content_type: 'text/plain' }] }; + const r1 = await sendMail(env, payload, 'k1'); ok(r1.id && calls.some((c) => c.path === '/emails'), 'Resend 호출'); + // smtp 중계 + const fake = await fakeSmtp({ authUser: 'relayuser', authPass: 'relaypass' }); + applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: 'relayuser', SMTP_RELAY_PASS: 'relaypass', HARAKA_TOKEN: 'x' }); + eq(relaySettings(env).port, fake.port, '중계 설정'); const before = calls.length; + const r2 = await sendMail(env, payload, 'k2'); eq(calls.length, before, 'Resend 는 부르지 않음'); ok(r2.id.includes('@arihakdang.com'), 'Message-ID 가 id'); + const s = fake.sessions.at(-1); eq(s.auth, 'relayuser', 'AUTH PLAIN'); eq(s.from, 'admin@arihakdang.com', '봉투 보낸 사람'); eq(JSON.stringify(s.rcpt), JSON.stringify(['a@example.test', 'c@example.test', 'b@example.test']), '봉투 받는 사람(To·Cc·Bcc)'); + ok(s.data.includes('Subject: =?UTF-8?') && s.data.includes('To: a@example.test') && s.data.includes('Cc: c@example.test') && !/^Bcc:/mi.test(s.data), '헤더에 Bcc 없음'); ok(!/\r\n\.\./.test(s.data), '점 스터핑이 풀린 뒤 이중 점 없음'); ok(s.data.includes('Content-Disposition: attachment'), '첨부 포함'); + const st = await outboundStatus(env, { force: true }); eq(st.mode, 'smtp', '상태 모드'); eq(st.provider, 'ok', '연결 확인'); eq(st.relay.auth, true, '인증 표시'); + // 일부 거부 → 성공(나머지), 전체 거부 → mail_rejected + const fake2 = await fakeSmtp({ rejectRcpt: ['b@example.test'] }); + applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake2.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: '', SMTP_RELAY_PASS: '', HARAKA_TOKEN: 'x' }); + const r3 = await sendMail(env, payload); eq(r3.rejected.length, 1, '일부 거부 기록'); eq(r3.accepted.length, 2, '나머지 발송'); + let err = null; try { await sendMail(env, { ...payload, to: ['b@example.test'], cc: [], bcc: [] }); } catch (e) { err = e; } eq(err?.message, 'mail_rejected', '전체 거부'); ok(err.providerMessage.includes('550'), '서버 응답 보존'); + // 인증 실패 / 접속 실패 / DATA 거부 + const fake3 = await fakeSmtp({ authUser: 'u', authPass: 'p' }); + err = null; try { await smtpSend({ host: '127.0.0.1', port: fake3.port, security: 'none', user: 'u', pass: 'wrong', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('Subject: x\r\n\r\nbody\r\n') }); } catch (e) { err = e; } eq(err?.message, 'mail_auth_failed', '인증 실패 코드'); + err = null; try { await smtpSend({ host: '127.0.0.1', port: 1, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('x\r\n'), timeoutMs: 2000 }); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '접속 실패 코드'); + const dotFake = await fakeSmtp(); await smtpSend({ host: '127.0.0.1', port: dotFake.port, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('Subject: x\r\n\r\n.dot line\r\nend\r\n') }); ok(dotFake.sessions[0].data.includes('.dot line\r\nend'), '점으로 시작하는 줄 전송(점 스터핑)'); dotFake.close(); + const fake4 = await fakeSmtp({ dataCode: 552 }); err = null; try { await smtpSend({ host: '127.0.0.1', port: fake4.port, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('x\r\n') }); } catch (e) { err = e; } eq(err?.message, 'mail_rejected', 'DATA 거부'); + eq(smtpErrorCode(451, 'data'), 'mail_rate_limited', '4xx 는 일시 오류'); eq((await smtpProbe({ host: '127.0.0.1', port: 1, security: 'none', timeoutMs: 1000 })).ok, false, '연결 확인 실패'); + // haraka 모드: 127.0.0.1:HARAKA_RELAY_PORT + const fake5 = await fakeSmtp(); applyConfig(env, { inbound: 'resend', outbound: 'haraka', HARAKA_RELAY_PORT: fake5.port, HARAKA_TOKEN: 'x' }); + eq(relaySettings(env).host, '127.0.0.1', '로컬 고정'); await sendMail(env, payload); eq(fake5.sessions.length, 1, 'Haraka 제출 포트로 전달'); eq(fake5.sessions[0].auth, null, '인증 없음'); + // payloadToEml: 인라인 그림 cid + const { eml } = payloadToEml({ from: 'a@x.test', to: ['b@x.test'], subject: 's', html: '<img src="cid:pic">', attachments: [{ filename: 'p.png', content: Buffer.from('PNG').toString('base64'), content_type: 'image/png', content_id: 'pic' }] }, 'x.test'); + ok(eml.toString().includes('Content-ID: <pic>') && eml.toString().includes('multipart/related'), '인라인 첨부'); + // 웹 발송(sendDraft)도 같은 길을 씁니다 + const fake6 = await fakeSmtp(); applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake6.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: '', SMTP_RELAY_PASS: '', HARAKA_TOKEN: 'x' }); + const draftId = await saveDraft(env, { to: 'friend@example.test', subject: '웹 발송', html: '<p>안녕</p>' }); const sent = await sendDraft(env, draftId); + eq(sent.state, 'sent', '발송 완료'); eq(fake6.sessions.length, 1, '중계 서버로'); ok(sent.provider_id.includes('@arihakdang.com'), 'provider_id 는 Message-ID'); + for (const f of [fake, fake2, fake3, fake4, fake5, fake6]) f.close(); +}); + +await t('1.0.0 Haraka 수신 전달 API: 토큰·수신 방식 확인, RCPT 조회, 원본 저장(봉투 수신자로 메일함 결정)·중복·사본·용량, 상태 기록', async () => { + const env = await makeEnv({}); applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'tok-secret-1234567890' }); + const hdr = (extra = {}) => ({ 'x-arimail-token': 'tok-secret-1234567890', ...extra }); + eq((await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, hdr())).status, 404, '수신 방식이 resend 면 닫힘'); + applyConfig(env, { inbound: 'haraka', outbound: 'resend', HARAKA_TOKEN: 'tok-secret-1234567890' }); + eq((await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, { 'x-arimail-token': 'wrong' })).status, 401, '토큰 틀림'); + eq((await (await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, hdr())).json()).local, true, '우리 메일함'); + eq((await (await api(env, 'inbound/haraka/rcpt?addr=nobody@arihakdang.com', '', 'GET', undefined, hdr())).json()).local, false, '없는 사용자'); + const eml = 'From: Someone <someone@outside.test>\r\nTo: hidden@elsewhere.test\r\nSubject: =?UTF-8?B?' + Buffer.from('하라카').toString('base64') + '?=\r\nMessage-ID: <h1@outside.test>\r\nDate: Tue, 14 Oct 2026 10:00:00 +0900\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n하라카 본문\r\n'; + const post = (id, rcpt, body = eml) => api(env, 'inbound/haraka', '', 'POST', Buffer.from(body), hdr({ 'content-type': 'message/rfc822', 'x-arimail-id': id, 'x-arimail-from': 'someone@outside.test', 'x-arimail-rcpt': JSON.stringify(rcpt) })); + const r1 = await (await post('tx-1', ['admin@arihakdang.com'])).json(); + ok(r1.ok && r1.imported && r1.mailbox === 'admin@arihakdang.com', '봉투 수신자(Bcc 로 받은 메일)로 메일함 결정 — ' + JSON.stringify(r1)); + const row = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(r1.id).first(); + eq(row.folder, 'inbox', '받은 메일함'); eq(row.direction, 'inbound', '수신'); eq(row.provider_id, 'haraka:tx-1', 'provider_id'); eq(row.subject, '하라카', '제목 디코딩'); eq(row.raw_status, 'stored', '원본 그대로'); ok(!row.read_at, '읽지 않음'); eq(row.sender, 'Someone <someone@outside.test>', '보낸 사람'); + const r2 = await (await post('tx-1', ['admin@arihakdang.com'])).json(); eq(r2.skipped, 'exists', '같은 트랜잭션 중복 저장 안 함'); + eq((await (await post('tx-2', ['stranger@other.test'])).json()).skipped, 'other_mailbox', '우리 메일함 없음'); + // 사본: 두 메일함에 함께 온 메일 + const { createUser } = await import('../server/auth.mjs'); await createUser(env, { login: 'staff', name: '직원', password: 'staff-pass-123', role: 'member' }); + const r3 = await (await post('tx-3', ['admin@arihakdang.com', 'staff@arihakdang.com'])).json(); eq(r3.copies?.length, 1, '다른 메일함 사본 — ' + JSON.stringify(r3)); eq(r3.copies[0].mailbox, 'staff@arihakdang.com', '사본 메일함'); + // 상태 기록과 관리자 상태의 via_haraka + const events = (await env.DB.prepare("SELECT status, COUNT(*) n FROM webhook_events WHERE type='haraka.received' GROUP BY status ORDER BY status").all()).results; + eq(JSON.stringify(events), JSON.stringify([{ status: 'skipped', n: 1 }, { status: 'stored', n: 2 }]), '수신 기록(중복은 같은 id 로 덮어씀)'); + const admin = await adminLogin(env); const st = await (await api(env, 'admin/status', admin, 'GET', undefined, { 'x-mail-scope': 'admin' })).json(); + eq(st.storage.via_haraka, 3, 'Haraka 수신 통계(사본 포함)'); eq(st.mail.inbound, 'haraka', '상태의 수신 방식'); eq(st.mail.outbound, 'resend', '상태의 발신 방식'); + eq((await api(env, 'inbound/haraka', '', 'POST', Buffer.alloc(0), hdr({ 'x-arimail-id': 'tx-4', 'x-arimail-rcpt': '["admin@arihakdang.com"]' }))).status, 400, '빈 본문 거부'); +}); + +await t('1.0.0 초기 설정 API: 관리자 세션(/api/setup)과 전용 포트(토큰) 모두 같은 응답, 저장·연결 확인·DKIM·재시작 불가 안내, 토큰 오류 제한', async () => { + const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), APP_VERSION: '1.0.0-test', PUBLIC_HOST: 'mail.arihakdang.com' }); loadConfig(env); + // 관리자 세션 없이·웹 메일 세션으로는 403 + eq((await api(env, 'setup/config')).status, 401, '로그인 없음'); + const admin = await adminLogin(env); const A = { 'x-mail-scope': 'admin' }; + const d = await (await api(env, 'setup/config', admin, 'GET', undefined, A)).json(); + ok(d.ok && d.values && d.schema.length && d.meta.host === 'mail.arihakdang.com' && d.meta.webhookUrl.endsWith('/api/webhooks/resend'), '설정 응답'); eq(d.meta.inbound, 'resend', '메타 수신'); ok(d.meta.dns.records.length >= 3, 'DNS 안내'); ok(d.meta.haraka && d.meta.haraka.installed === false, 'Haraka 미설치 표시'); + const saved = await (await api(env, 'setup/config', admin, 'PUT', { values: { MAIL_FROM_NAME: '새 발신자', SETUP_DONE: true } }, A)).json(); + eq(saved.values.MAIL_FROM_NAME, '새 발신자', '저장'); eq(saved.values.SETUP_DONE, true, '완료 표시'); eq(env.MAIL_FROM_NAME, '새 발신자', '즉시 적용'); + const bad = await api(env, 'setup/config', admin, 'PUT', { values: { HARAKA_PORT: 'abc' } }, A); eq(bad.status, 422, '검증 실패'); eq((await bad.json()).field, 'HARAKA_PORT', '필드'); + const chk = await (await api(env, 'setup/check', admin, 'POST', { what: 'outbound' }, A)).json(); eq(chk.outboundStatus.mode, 'resend', '연결 확인 응답'); + const dk = await (await api(env, 'setup/dkim', admin, 'POST', {}, A)).json(); ok(dk.dns.hasDkim, 'DKIM 생성'); + eq((await api(env, 'setup/restart', admin, 'POST', {}, A)).status, 409, 'systemd 아닐 때 재시작 불가'); + // 전용 포트 서버(토큰) + const webDir = join(dir, 'web'); writeFileSync; const { mkdirSync } = await import('node:fs'); mkdirSync(webDir); writeFileSync(join(webDir, 'setup.html'), '<!doctype html><title>__TITLE__v__VERSION__ __HOST__'); writeFileSync(join(webDir, 'setup.js'), '//'); writeFileSync(join(webDir, 'setup.css'), ''); + const server = createSetupServer(env, { webDir, tlsOptions: null }); const bound = await server.listen({ port: 0, host: '127.0.0.1' }); + const base = `http://127.0.0.1:${bound.port}`; const token = setupToken(env); + const page = await (await fetch(base + '/')).text(); ok(page.includes('data-mode="token"') && page.includes('v1.0.0-test'), '화면 자리표시자 치환'); + eq((await fetch(base + '/api/setup/config')).status, 401, '토큰 없음'); + eq((await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': 'nope' } })).status, 401, '토큰 틀림'); + const ok1 = await (await fetch(base + '/api/setup/config?token=' + encodeURIComponent(token))).json(); eq(ok1.values.MAIL_FROM_NAME, '새 발신자', '주소 토큰으로 읽기'); + const put = await (await fetch(base + '/api/setup/config', { method: 'PUT', headers: { 'X-Setup-Token': token, 'Content-Type': 'application/json' }, body: JSON.stringify({ values: { SITE_TITLE: '토큰 저장' } }) })).json(); eq(put.values.SITE_TITLE, '토큰 저장', '헤더 토큰으로 저장'); + for (let i = 0; i < 10; i++) await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': 'guess' + i } }); + eq((await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': token } })).status, 429, '틀린 시도 10회 뒤 차단(맞는 토큰도)'); + ok(server.urls(token)[0].includes(':' + bound.port + '/?token='), '접속 주소'); server.close(); + ok(harakaStatus(env).installed === false && harakaStatus(env).status === null, 'Haraka 상태 파일 없음'); + rmSync(dir, { recursive: true, force: true }); +}); + +done(); diff --git a/modules/arimail/test/domains.test.mjs b/modules/arimail/test/domains.test.mjs new file mode 100644 index 0000000..20d9802 --- /dev/null +++ b/modules/arimail/test/domains.test.mjs @@ -0,0 +1,226 @@ +/** + * 1.0.7 시험: 다중 도메인(server/domains.mjs) — 등록부 생성, 호스트·로그인 ID 로 테넌트 고르기, 도메인 추가(자기 DB·설정·초기 관리자)·중지·삭제, + * 전체 설정 API(/api/setup/domains, ?domain=), IMAP 로그인의 도메인 분리, Haraka 수신 전달의 도메인 나누기, 안내 화면, Haraka 관리 프로세스의 다중 도메인 설정 생성. + * 실행: node test/domains.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다) + */ +import net from 'node:net'; +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeEnv, runner, eq, ok, request, params } from './_helpers.mjs'; +import { handle } from '../server/api.mjs'; +import { loadConfig, saveConfig, configFile, setupToken } from '../server/config.mjs'; +import { openTenants, loadRegistry, registryFile, normalizeEntry, noServicePage, isLocalHost, COPY_KEYS } from '../server/domains.mjs'; +import { createImap } from '../server/imap.mjs'; +import { updateUser } from '../server/auth.mjs'; +import { createSetupServer, setupApi } from '../server/setup.mjs'; + +const { t, done } = runner(); +const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..'); +const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-dom-')); +const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path)); +/** 기본 도메인 env: 파일 DB 위치(DB_FILE)만 있으면 되고 DB 자체는 메모리입니다(main.mjs 처럼 loadConfig 를 먼저 부릅니다). */ +async function base(over = {}) { + const dir = tmp(); + const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', RESEND_API_KEY: 're_primary_key_123456', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIA', DNS_AWS_SECRET_ACCESS_KEY: 'sekret', ...over }); + loadConfig(env); + return { env, dir }; +} +const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } }); + +await t('등록부: 첫 시작에 환경 파일의 도메인으로 domains.json 을 만들고, 호스트·IP·localhost·하위 호스트·모르는 호스트를 규칙대로 고른다', async () => { + const { env, dir } = await base(); + const tenants = open(env); + ok(existsSync(registryFile(env)) && registryFile(env) === join(dir, 'domains.json'), '등록부 파일'); + const reg = loadRegistry(env); eq(reg.primary, 'arihakdang.com', '기본 도메인'); eq(reg.domains['arihakdang.com'].hosts[0], 'mail.arihakdang.com', '기본 호스트'); + eq(env.DOMAIN, 'arihakdang.com', 'env.DOMAIN'); eq(env.MAIL_SERVER_HOST, 'mail.arihakdang.com', '메일 서버 이름'); + for (const h of ['mail.arihakdang.com', 'MAIL.arihakdang.com:8790', '127.0.0.1:8790', 'localhost', '[::1]:8790', '', 'arihakdang.com', 'webmail.arihakdang.com']) eq(tenants.resolveHost(h).tenant?.entry.domain, 'arihakdang.com', '기본 도메인으로: ' + h); + eq(tenants.resolveHost('mail.other.test').tenant, null, '모르는 호스트'); eq(tenants.resolveHost('notarihakdang.com').tenant, null, '접미사만 비슷한 호스트'); + ok(isLocalHost('10.0.0.5') && isLocalHost('localhost') && !isLocalHost('mail.x.com'), 'isLocalHost'); + eq(tenants.resolveLogin('admin', '').DOMAIN, 'arihakdang.com', '도메인 없는 로그인 → 기본'); eq(tenants.resolveLogin('admin@arihakdang.com', ''), env, '@기본 도메인'); eq(tenants.resolveLogin('x@unknown.test', '').DOMAIN, 'arihakdang.com', '@등록되지 않은 도메인(www 관리자 이메일 로그인)은 기본 도메인에서 검사'); + eq((await tenants.list()).length, 1, '목록 1개'); tenants.close(); +}); + +await t('도메인 추가: 자기 디렉터리에 DB·설정(기본 도메인의 키·DNS 설정 복사)·초기 관리자(admin)가 생기고 계정은 도메인마다 따로이며, 중복·형식·www DB 오류를 거른다', async () => { + const { env, dir } = await base(); + const tenants = open(env); + const r = await tenants.add({ domain: 'Second.test', hosts: 'mail.second.test, webmail.second.test', adminPassword: 'secondpw1', note: '둘째' }); + eq(r.entry.domain, 'second.test', '소문자 정규화'); eq(r.entry.hosts.join(','), 'mail.second.test,webmail.second.test', '호스트 목록'); eq(r.initialAdmin?.login, 'admin', '초기 관리자'); eq(r.initialAdmin?.password, 'secondpw1', '지정한 비밀번호'); + ok(existsSync(join(dir, 'domains', 'second.test', 'mail.sqlite')) && existsSync(join(dir, 'domains', 'second.test', 'config.json')), '도메인 디렉터리'); + const sec = tenants.byDomain('second.test').env; + eq(sec.MAILBOX, 'admin@second.test', '메일함'); eq(sec.PUBLIC_HOST, 'mail.second.test', '공개 호스트'); eq(sec.MAIL_SERVER_HOST, 'mail.arihakdang.com', '메일 서버 이름은 기본 도메인'); eq(sec.LOGIN_MODE, 'standalone', '자체 계정'); ok(!sec.WWW_DB && !sec.WWW_DB_FILE, 'www DB 없음'); + eq(sec.RESEND_API_KEY, 're_primary_key_123456', 'API 키 복사'); eq(sec.DNS_PROVIDER, 'route53', 'DNS 설정 복사'); eq(sec.DNS_AWS_SECRET_ACCESS_KEY, 'sekret', 'DNS 비밀 복사'); eq(sec.DNS_AWS_HOSTED_ZONE_ID, undefined, '존 id 는 복사 안 함(도메인으로 자동 검색)'); eq(sec.MAIL_INBOUND, 'resend', '수신 기본'); + ok(COPY_KEYS.includes('RESEND_API_KEY') && !COPY_KEYS.includes('HARAKA_DKIM_PRIVATE'), '복사 키 목록'); + ok(JSON.parse(readFileSync(configFile(sec), 'utf8'))._meta.copiedFrom === 'arihakdang.com', '설정 파일 메타'); + // 로그인: 둘째 도메인의 admin 은 둘째 env 에서만, 기본 도메인의 www 관리자는 둘째 env 에서 거부 + const login = await api(sec, 'login', '', 'POST', { login: 'admin', password: 'secondpw1' }); eq(login.status, 200, '둘째 도메인 로그인'); + const cookie = login.headers.get('set-cookie').split(';')[0]; const session = await (await api(sec, 'session', cookie)).json(); + eq(session.mailbox, 'admin@second.test', '세션 메일함'); eq(session.domain, 'second.test', '세션 도메인'); eq(session.initialPassword, true, '초기 비밀번호 경고'); + const st = await (await api(sec, 'settings', cookie)).json(); eq(st.imap.host, 'mail.arihakdang.com', '메일 프로그램 서버 이름은 기본 도메인'); eq(st.smtp.host, 'mail.arihakdang.com', 'SMTP 서버 이름'); eq(st.host, 'mail.second.test', '웹 메일 호스트는 자기 것'); eq(st.mailbox, 'admin@second.test', '설정 화면 메일함'); + eq((await api(sec, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).status, 401, '기본 도메인 계정은 둘째에서 거부'); + eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'secondpw1' })).status, 401, '둘째 계정은 기본에서 거부'); + eq((await api(env, 'session', cookie)).status, 200, '세션 응답'); eq((await (await api(env, 'session', cookie)).json()).user, null, '둘째 세션 쿠키는 기본 도메인에서 비로그인'); + // 호스트·로그인 ID 로 고르기 + eq(tenants.resolveHost('webmail.second.test').tenant.entry.domain, 'second.test', '추가 호스트'); eq(tenants.resolveHost('imap.second.test').tenant.entry.domain, 'second.test', '하위 호스트'); eq(tenants.resolveLogin('admin@second.test', '').DOMAIN, 'second.test', '@둘째'); + eq(tenants.resolveLogin('admin', 'mail.second.test').DOMAIN, 'second.test', 'SNI 로 둘째'); eq(tenants.resolveLogin('admin', 'mail.arihakdang.com').DOMAIN, 'arihakdang.com', 'SNI 로 기본'); + // 오류 + for (const [body, code, field] of [[{ domain: 'second.test' }, 'domain_exists'], [{ domain: 'arihakdang.com' }, 'domain_exists'], [{ domain: 'third.test', hosts: 'mail.second.test' }, 'host_taken', 'hosts'], [{ domain: 'bad domain' }, 'validation', 'domain'], [{ domain: 'third.test', adminPassword: 'abc' }, 'validation', 'adminPassword'], [{ domain: 'third.test', auth: { mode: 'www', wwwDbFile: '/nonexistent/x.sqlite' } }, 'www_db_unreadable', 'auth.wwwDbFile'], [{ domain: 'third.test', auth: { mode: 'ldap' } }, 'validation', 'auth.mode']]) { + let err = null; try { await tenants.add(body); } catch (e) { err = e; } + eq(err?.message, code, JSON.stringify(body)); if (field) eq(err?.field, field, 'field ' + code); + } + eq((await tenants.list()).length, 2, '목록 2개'); const second = (await tenants.list())[1]; eq(second.accounts, 1, '계정 수'); eq(second.initialPassword, 'secondpw1', '목록의 초기 비밀번호 안내'); + // 관리자가 비밀번호를 바꾸면 초기 비밀번호 안내가 사라집니다 + await updateUser(sec, (await sec.DB.prepare('SELECT id FROM mail_users').first()).id, { password: 'changedpw1' }); eq((await tenants.list())[1].initialPassword, null, '비밀번호 변경 뒤 안내 없음'); + // 같은 도메인을 동시에 추가하면 하나만 성공합니다 + const race = await Promise.allSettled([tenants.add({ domain: 'race.test' }), tenants.add({ domain: 'race.test' })]); eq(race.filter((x) => x.status === 'fulfilled').length, 1, '동시 추가 하나만'); eq(race.find((x) => x.status === 'rejected')?.reason?.message, 'domain_exists', '다른 하나는 중복'); + // 복사 없이 추가 + 등록부 다시 읽기(재시작 흉내) + await tenants.add({ domain: 'third.test', copyFromPrimary: false }); eq(tenants.byDomain('third.test').env.RESEND_API_KEY, undefined, '복사 안 함'); + tenants.close(); + const env2 = (await base({ DB_FILE: join(dir, 'mail-gateway.sqlite') })).env; const again = open(env2); + eq(again.all().map((x) => x.entry.domain).sort().join(','), 'arihakdang.com,race.test,second.test,third.test', '재시작 뒤 등록부'); eq(again.byDomain('second.test').env.RESEND_API_KEY, 're_primary_key_123456', '재시작 뒤 설정 유지'); + eq((await api(again.byDomain('second.test').env, 'login', '', 'POST', { login: 'admin@second.test', password: 'changedpw1' })).status, 200, '재시작 뒤 로그인(바꾼 비밀번호)'); again.close(); +}); + +await t('중지·수정·삭제: 중지한 도메인은 disabled 로 알려 로그인·수신을 막고, 기본 도메인은 지울 수 없으며, 삭제해도 데이터 디렉터리는 남는다', async () => { + const { env, dir } = await base(); + const tenants = open(env); + await tenants.add({ domain: 'second.test' }); + tenants.update('second.test', { enabled: false }); + const r = tenants.resolveHost('mail.second.test'); ok(r.tenant && r.disabled, '중지 표시'); eq(tenants.resolveLogin('admin@second.test', ''), null, '중지 도메인 로그인 불가'); eq(tenants.envs().length, 1, '주기 작업 대상에서 제외'); + tenants.update('second.test', { enabled: true, hosts: 'post.second.test', note: '메모' }); + eq(tenants.resolveHost('post.second.test').disabled, false, '다시 사용'); eq(tenants.byDomain('second.test').env.PUBLIC_HOST, 'post.second.test', '호스트 변경이 env 에 반영'); eq(tenants.resolveHost('mail.second.test').tenant.entry.domain, 'second.test', '옛 호스트도 하위 호스트 규칙으로'); + // 로그인 방식 바꾸기(www): 읽을 수 있는 SQLite 파일이면 허용 + const www = join(dir, 'www.sqlite'); writeFileSync(www, ''); + tenants.update('second.test', { auth: { mode: 'www', wwwDbFile: www, ownerLogin: 'owner' } }); eq(tenants.byDomain('second.test').env.WWW_DB_FILE, www, 'www DB 반영'); eq(tenants.byDomain('second.test').env.LOGIN_MODE, 'www', 'www 방식'); + tenants.update('second.test', { auth: { mode: 'local' } }); ok(!tenants.byDomain('second.test').env.WWW_DB_FILE, '자체 계정으로 복귀'); + const p = tenants.update('arihakdang.com', { hosts: 'alias.arihakdang.com', enabled: false, auth: { mode: 'local' } }); + eq(p.enabled, true, '기본 도메인은 끌 수 없음'); eq(p.hosts.join(','), 'mail.arihakdang.com,alias.arihakdang.com', '기본 호스트는 항상 포함'); eq(p.auth.mode, 'www', '기본 도메인의 로그인 방식은 환경 파일'); + let err = null; try { tenants.remove('arihakdang.com'); } catch (e) { err = e; } eq(err?.message, 'primary_domain', '기본 도메인 삭제 불가'); + const removed = tenants.remove('second.test'); eq(tenants.resolveHost('post.second.test').tenant, null, '삭제 뒤 모르는 호스트'); ok(existsSync(join(removed.dir, 'mail.sqlite')), '데이터 디렉터리 유지'); + ok(!loadRegistry(env).domains['second.test'], '등록부에서 제거'); tenants.close(); +}); + +await t('전체 설정 API: 전용 포트에서 도메인 목록·추가·수정·삭제와 ?domain= 선택이 동작하고, 추가 도메인 호스트로 열면 안내 화면, 관리자 세션(/setup)에서는 도메인 관리가 없다', async () => { + const { env } = await base({ SETUP_PORT: '0' }); + const tenants = open(env); env.APP_VERSION = '1.0.7-test'; + const setup = createSetupServer(env, { webDir: join(ROOT, 'web'), tenants }); + const port = 40000 + Math.floor(Math.random() * 20000); await setup.listen({ port, host: '127.0.0.1' }); + const token = setupToken(env); eq(readFileSync(join(dirname(env.DB_FILE), 'setup-token'), 'utf8').trim(), token, '토큰 파일'); + const call = (path, method = 'GET', body, host = '127.0.0.1') => fetch(`http://127.0.0.1:${port}${path}`, { method, headers: { 'X-Setup-Token': token, Host: host, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }); + try { + let r = await call('/api/setup/domains'); let d = await r.json(); eq(r.status, 200, '목록'); eq(d.primary, 'arihakdang.com', 'primary'); eq(d.domains.length, 1, '1개'); eq(d.serverHost, 'mail.arihakdang.com', '메일 서버 이름'); + r = await call('/api/setup/domains', 'POST', { domain: 'second.test', adminPassword: 'secondpw1' }); d = await r.json(); eq(r.status, 201, '추가'); eq(d.initialAdmin.login, 'admin', '초기 관리자'); eq(d.domains.length, 2, '목록 갱신'); + r = await call('/api/setup/domains', 'POST', { domain: 'second.test' }); eq(r.status, 409, '중복'); eq((await r.json()).error, 'domain_exists', '중복 코드'); + r = await call('/api/setup/domains', 'POST', { domain: 'x y' }); eq(r.status, 422, '형식'); eq((await r.json()).field, 'domain', '필드'); + // ?domain= 으로 둘째 도메인의 설정을 읽고 씁니다 + r = await call('/api/setup/config?domain=second.test'); d = await r.json(); eq(d.meta.mailbox, 'admin@second.test', '둘째 설정'); eq(d.meta.primary, false, '기본 아님'); eq(d.meta.serverHost, 'mail.arihakdang.com', '서버 이름'); + r = await call('/api/setup/config?domain=second.test', 'PUT', { values: { MAIL_FROM_NAME: '둘째' } }); d = await r.json(); eq(d.values.MAIL_FROM_NAME, '둘째', '둘째 저장'); eq(tenants.byDomain('second.test').env.MAIL_FROM_NAME, '둘째', '둘째 env 적용'); ok(env.MAIL_FROM_NAME !== '둘째', '기본 도메인은 그대로'); + r = await call('/api/setup/config'); d = await r.json(); eq(d.meta.mailbox, 'admin@arihakdang.com', '기본 설정'); eq(d.meta.primary, true, '기본'); eq(d.meta.domains, 2, '도메인 수'); + r = await call('/api/setup/config?domain=nope.test'); eq(r.status, 404, '모르는 도메인'); eq((await r.json()).error, 'domain_not_found', '코드'); + r = await call('/api/setup/plan?domain=second.test'); d = await r.json(); eq(d.plan.domain, 'second.test', '둘째 도메인의 자동 설정 계획'); + r = await call('/api/setup/domains/second.test', 'PATCH', { enabled: false }); d = await r.json(); eq(r.status, 200, '중지'); eq(d.domains[1].enabled, false, '중지 반영'); + r = await call('/api/setup/domains/second.test', 'DELETE'); eq(r.status, 200, '삭제'); eq((await r.json()).domains.length, 1, '삭제 뒤 1개'); + r = await call('/api/setup/domains/arihakdang.com', 'DELETE'); eq(r.status, 409, '기본 삭제 불가'); + // 추가 도메인의 호스트 이름으로 전용 포트에 오면 안내 화면(기본 도메인 주소로 열라고) + await tenants.add({ domain: 'second.test' }); + // fetch 는 Host 헤더를 바꿀 수 없으므로 http 모듈로 직접 보냅니다 + const rawGet = (host) => new Promise((resolve, reject) => { http.get({ host: '127.0.0.1', port, path: '/', headers: { Host: host } }, (res) => { let body = ''; res.setEncoding('utf8'); res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, body })); }).on('error', reject); }); + let g = await rawGet('mail.second.test'); eq(g.status, 404, '다른 호스트'); ok(g.body.includes('기본 도메인 주소로'), '안내 문구'); + g = await rawGet('mail.arihakdang.com'); eq(g.status, 200, '기본 호스트'); g = await rawGet('127.0.0.1:' + port); eq(g.status, 200, 'IP'); r = await call('/api/health'); eq((await r.json()).setup, true, '상태 확인'); + } finally { setup.close(); tenants.close(); } + // 관리자 세션 쪽(api.mjs → setupApi, tenants 없음): domains 는 404 + const r2 = await setupApi(env, new Request('https://mail.arihakdang.com/api/setup/domains'), ['domains'], { ip: '127.0.0.1', user: { login: 'x' } }); eq(r2.status, 404, '관리자 세션에서는 도메인 관리 없음'); +}); + +await t('IMAP: 로그인 ID 의 @도메인으로 도메인을 고르고(admin@second.test → 둘째 DB), 도메인 없는 ID 는 기본 도메인, 모르는·중지한 도메인은 거부한다', async () => { + const { env } = await base({ POP_AUTH_DELAY_MS: 0 }); + const tenants = open(env); + await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' }); await tenants.add({ domain: 'off.test', adminPassword: 'offpw123' }); tenants.update('off.test', { enabled: false }); + const imap = createImap(env, { hostname: 'mail.test', resolveEnv: (l, s) => tenants.resolveLogin(l, s) }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' }); + const session = async (login, password) => { const c = imapClient(port); await c.greeting(); const r = await c.cmd(`LOGIN "${login}" "${password}"`); return { c, r }; }; + try { + let { c, r } = await session('admin@second.test', 'secondpw1'); ok(/OK/.test(r), '둘째 로그인: ' + r.trim()); r = await c.cmd('SELECT INBOX'); ok(/OK/.test(r), '둘째 SELECT'); c.end(); + ({ c, r } = await session('admin@second.test', 'admin-secret-12')); ok(/NO/.test(r), '둘째에 기본 비밀번호 거부'); c.end(); + ({ c, r } = await session('arihakdang', 'admin-secret-12')); ok(/OK/.test(r), '도메인 없는 ID 는 기본 도메인'); c.end(); + ({ c, r } = await session('admin@off.test', 'offpw123')); ok(/NO/.test(r), '중지한 도메인 거부'); c.end(); + ({ c, r } = await session('admin@nowhere.test', 'secondpw1')); ok(/NO/.test(r), '모르는 도메인은 기본 도메인에서 검사 → 계정 없음'); c.end(); + ({ c, r } = await session('ADMIN@arihakdang.com', 'admin-secret-12')); ok(/OK/.test(r), 'www 관리자 이메일 로그인(기본 도메인)'); c.end(); + } finally { imap.close(); tenants.close(); } +}); + +await t('Haraka 수신 전달: 받는 사람 주소의 도메인으로 저장할 DB 를 고르고, 토큰은 기본 도메인 것이며, 수신 방식이 haraka 인 도메인만 받는다', async () => { + const { env } = await base(); + const tenants = open(env); + await tenants.add({ domain: 'second.test' }); await tenants.add({ domain: 'third.test' }); + const sec = tenants.byDomain('second.test').env, third = tenants.byDomain('third.test').env; + eq((await api(env, 'inbound/haraka/rcpt?addr=admin@second.test', '', 'GET', undefined, { 'X-AriMail-Token': env.HARAKA_TOKEN })).status, 404, '아무 도메인도 haraka 수신이 아니면 404'); + saveConfig(sec, { inbound: 'haraka' }); // 둘째만 haraka 수신 + const hdr = { 'X-AriMail-Token': env.HARAKA_TOKEN }; + eq((await api(env, 'inbound/haraka/rcpt?addr=admin@second.test', '', 'GET', undefined, { 'X-AriMail-Token': sec.HARAKA_TOKEN })).status, 401, '둘째 도메인의 토큰은 거부(토큰은 기본 도메인 것)'); + const rc = async (addr) => (await (await api(env, 'inbound/haraka/rcpt?addr=' + addr, '', 'GET', undefined, hdr)).json()).local; + eq(await rc('admin@second.test'), true, '둘째 메일함'); eq(await rc('nobody@second.test'), false, '둘째 없는 사용자'); eq(await rc('admin@third.test'), false, '셋째는 haraka 수신 아님'); eq(await rc('admin@arihakdang.com'), false, '기본은 haraka 수신 아님'); + const eml = Buffer.from('From: sender@example.test\r\nTo: admin@second.test\r\nSubject: hello second\r\nMessage-ID: \r\n\r\nbody\r\n'); + const post = (rcpt, id) => handle(env, new Request('https://x/api/inbound/haraka', { method: 'POST', headers: { ...hdr, 'X-AriMail-Id': id, 'X-AriMail-From': 'sender@example.test', 'X-AriMail-Rcpt': JSON.stringify(rcpt), 'Content-Type': 'message/rfc822' }, body: eml }), { path: ['inbound', 'haraka'], ip: '127.0.0.1' }); + let r = await post(['admin@second.test'], 'tx1'); eq(r.status, 200, '저장'); const r1 = await r.json(); ok(r1.imported && r1.mailbox === 'admin@second.test', '둘째 메일함에 저장 — ' + JSON.stringify(r1)); + // 두 도메인의 주소가 한 트랜잭션에 함께 오면 각 DB 에 저장하고 합계를 돌려줍니다 + saveConfig(third, { inbound: 'haraka' }); + r = await post(['admin@second.test', 'admin@third.test'], 'tx2'); const r2 = await r.json(); eq(r2.imported, 2, '두 도메인 합계'); eq(r2.results.length, 2, '도메인별 결과'); + eq(Number((await third.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 1, '셋째 DB 에 저장'); saveConfig(third, { inbound: 'resend' }); + eq(Number((await sec.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 2, '둘째 DB 에 저장'); eq(Number((await env.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 0, '기본 DB 에는 없음'); + eq((await sec.DB.prepare("SELECT status FROM webhook_events WHERE id='haraka:tx1'").first()).status, 'stored', '둘째의 수신 기록'); + r = await post(['admin@second.test'], 'tx1'); eq((await r.json()).skipped, 'exists', '재시도는 중복'); eq((await sec.DB.prepare("SELECT status FROM webhook_events WHERE id='haraka:tx1'").first()).status, 'stored', '기록 유지'); + tenants.close(); +}); + +await t('안내 화면과 항목 정규화: 모르는 도메인·중지한 도메인 문구, 호스트 기본값 mail.<도메인>, 로그인 방식 검사', async () => { + const html = noServicePage({ host: 'mail.nowhere.test', strings: {}, version: '1.0.7' }); + ok(html.includes('메일 서비스가 존재하지 않습니다') && html.includes('mail.nowhere.test') && html.includes('관리자에게'), '모르는 도메인 문구'); + ok(noServicePage({ disabled: true }).includes('활성화되지 않았습니다'), '중지 문구'); ok(noServicePage({ host: '' }).includes('<x>'), '이스케이프'); + const e = normalizeEntry({ domain: ' Example.COM. ' }); eq(e.domain, 'example.com', '도메인'); eq(e.hosts[0], 'mail.example.com', '기본 호스트'); eq(e.mailbox, 'admin@example.com', '기본 메일함'); eq(e.auth.mode, 'local', '기본 로그인'); eq(e.enabled, true, '기본 사용'); + const e2 = normalizeEntry({ domain: 'example.com', mailbox: 'Postmaster', hosts: ['Mail.Example.com', 'mail.example.com', 'a.example.com:443'] }); eq(e2.mailbox, 'postmaster@example.com', '메일함 로컬'); eq(e2.hosts.join(','), 'mail.example.com,a.example.com', '중복·포트 제거'); + let err = null; try { normalizeEntry({ domain: 'example.com', hosts: 'bad host' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '호스트 검사'); + err = null; try { normalizeEntry({ domain: 'example.com', auth: { mode: 'www' } }); } catch (x) { err = x; } eq(err?.field, 'auth.wwwDbFile', 'www 는 DB 파일 필수'); +}); + +await t('Haraka 관리 프로세스: 등록부의 켜진 도메인 중 수신 haraka 인 도메인을 domains= 에 넣고, 발신 haraka + DKIM 키가 있는 도메인마다 dkim/<도메인>/ 키 파일을 만든다', async () => { + const dir = tmp(), data = join(dir, 'data'), haraka = join(dir, 'haraka'); mkdirSync(data, { recursive: true }); mkdirSync(join(haraka, 'node_modules', 'Haraka', 'bin'), { recursive: true }); mkdirSync(join(haraka, 'node_modules', 'Haraka', 'config'), { recursive: true }); + // 가짜 Haraka: 실행 파일은 그냥 대기하는 스크립트(설정 생성만 봅니다) + writeFileSync(join(haraka, 'node_modules', 'Haraka', 'bin', 'haraka'), 'setInterval(() => {}, 1000);\n'); writeFileSync(join(haraka, 'node_modules', 'Haraka', 'package.json'), '{"version":"0.0.0-test"}'); + writeFileSync(join(data, 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_TOKEN: 'tok', HARAKA_DKIM_PRIVATE: 'KEY-PRIMARY', HARAKA_DKIM_SELECTOR: 'p1' })); + writeFileSync(join(data, 'domains.json'), JSON.stringify({ primary: 'a.test', domains: { 'a.test': { domain: 'a.test', enabled: true, hosts: ['mail.a.test'] }, 'b.test': { domain: 'b.test', enabled: true, hosts: ['mail.b.test'] }, 'c.test': { domain: 'c.test', enabled: false, hosts: ['mail.c.test'] }, 'd.test': { domain: 'd.test', enabled: true, hosts: ['mail.d.test'] } } })); + mkdirSync(join(data, 'domains', 'b.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'b.test', 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PRIVATE: 'KEY-B', HARAKA_DKIM_SELECTOR: 'sel-b' })); + mkdirSync(join(data, 'domains', 'c.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'c.test', 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PRIVATE: 'KEY-C' })); + mkdirSync(join(data, 'domains', 'd.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'd.test', 'config.json'), JSON.stringify({ inbound: 'resend', outbound: 'haraka' })); // 발신만, 키 없음 + // 예전에 서명하던 도메인의 디렉터리(지워져야 함) + mkdirSync(join(haraka, 'config', 'dkim', 'old.test'), { recursive: true }); writeFileSync(join(haraka, 'config', 'dkim', 'old.test', 'private'), 'OLD'); writeFileSync(join(haraka, 'config', 'dkim.private.key'), 'OLD-SINGLE'); + const child = spawn(process.execPath, [join(ROOT, 'haraka', 'run.mjs')], { env: { ...process.env, DB_FILE: join(data, 'mail-gateway.sqlite'), HARAKA_DIR: haraka, HARAKA_CHECK_MS: '200', MAILBOX: 'admin@a.test', PUBLIC_HOST: 'mail.a.test', PORT: '18990' }, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; }); + try { + const until = Date.now() + 8000; while (Date.now() < until && !existsSync(join(haraka, 'config', 'dkim', 'b.test', 'private'))) await new Promise((r) => setTimeout(r, 100)); + const ini = readFileSync(join(haraka, 'config', 'arimail.ini'), 'utf8'); ok(ini.includes('domains=a.test,b.test\n'), 'domains= 에 켜진 haraka 수신 도메인만: ' + ini); + eq(readFileSync(join(haraka, 'config', 'dkim', 'b.test', 'selector'), 'utf8'), 'sel-b\n', 'b 셀렉터'); eq(readFileSync(join(haraka, 'config', 'dkim', 'b.test', 'private'), 'utf8'), 'KEY-B\n', 'b 키'); + eq(readFileSync(join(haraka, 'config', 'dkim', 'a.test', 'selector'), 'utf8'), 'p1\n', '기본 도메인 셀렉터'); ok(!existsSync(join(haraka, 'config', 'dkim', 'c.test')) && !existsSync(join(haraka, 'config', 'dkim', 'd.test')) && !existsSync(join(haraka, 'config', 'dkim', 'old.test')), '중지·키 없음·옛 도메인 디렉터리 없음'); + ok(readFileSync(join(haraka, 'config', 'plugins'), 'utf8').includes('dkim'), 'dkim 플러그인 켜짐'); ok(!readFileSync(join(haraka, 'config', 'dkim.ini'), 'utf8').includes('domain=') && !existsSync(join(haraka, 'config', 'dkim.private.key')), '단일 도메인 폴백 없음(키 없는 도메인은 서명하지 않음)'); + const status = JSON.parse(readFileSync(join(data, 'haraka-status.json'), 'utf8')); eq(status.domains.join(','), 'a.test,b.test', '상태 파일 domains'); eq(status.signers.join(','), 'a.test,b.test', '상태 파일 signers'); eq(status.dkim, true, 'dkim 표시'); + } catch (e) { e.message += '\n--- run.mjs 출력:\n' + out; throw e; } finally { child.kill('SIGTERM'); await new Promise((r) => child.on('exit', r)); rmSync(dir, { recursive: true, force: true }); } +}); + +/** IMAP 시험 클라이언트(test/imap.test.mjs 와 같은 최소 구현). */ +function imapClient(port) { + const socket = net.connect(port, '127.0.0.1'); let buffer = ''; const waiters = []; + socket.setEncoding('latin1'); socket.on('data', (d) => { buffer += d; flush(); }); + function flush() { + while (waiters.length) { + const w = waiters[0]; const re = w.tag ? new RegExp(`(^|\\r\\n)${w.tag} (OK|NO|BAD)[^\\r]*\\r\\n`) : /^\+ [^\r]*\r\n|^\* [^\r]*\r\n/; + const m = buffer.match(re); if (!m) return; + const end = m.index + m[0].length; waiters.shift(); w.resolve(buffer.slice(0, end)); buffer = buffer.slice(end); + } + } + let n = 0; + return { greeting: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }), cmd: (line) => { const tag = 'A' + (++n); socket.write(`${tag} ${line}\r\n`); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); }, end: () => socket.end() }; +} + +done(); diff --git a/modules/arimail/test/gateway.test.mjs b/modules/arimail/test/gateway.test.mjs new file mode 100644 index 0000000..2710911 --- /dev/null +++ b/modules/arimail/test/gateway.test.mjs @@ -0,0 +1,1157 @@ +import net from 'node:net'; +import tls from 'node:tls'; +import { execFileSync } from 'node:child_process'; +import { makeEnv, runner, eq, ok, fakeResend, request, params } from './_helpers.mjs'; +import { handle } from '../server/api.mjs'; +import { verifyAdmin, createSession, SESSION_COOKIE, ADMIN_COOKIE, createUser, readPrefs } from '../server/auth.mjs'; +import { sanitizeHtml } from '../server/sanitize.mjs'; +import { verifySvix, signSvix } from '../server/webhook.mjs'; +import { ingestReceived, syncMailbox, cleanupSessions, isoTime, saveDraft, sendDraft, addAttachment, appendMessage, ingestSent } from '../server/store.mjs'; +import { openDb, d1, migrate } from '../server/db.mjs'; +import { mkdtempSync, copyFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPop3, maildrop } from '../server/pop3.mjs'; +import { buildEml } from '../server/mime.mjs'; +import { parseMime, parseParams, summarize, parseAddresses } from '../server/imap-mime.mjs'; +import { addresses, siteName, formatSender, nameOf } from '../server/util.mjs'; +import { parseSize, systemLimits, periodBounds, mailStats, readAdminLimits, mailboxQuota, mailboxUsage, countMail, mimeSize } from '../server/limits.mjs'; +import { pendingLogin, listUsers, isPending, deleteUser } from '../server/auth.mjs'; +import { attachmentLimit, recipientLimit, writeAdminLimits as writeLimits } from '../server/limits.mjs'; +import { fixUtf8 } from '../server/imap-mime.mjs'; +import { systemLargeFiles, defaultAdminLarge, readLargeFileSettings, writeLargeFileSettings, uploadLargeFile, listLargeFiles, removeLargeFile, finalizeLargeFiles, largeFileInfo, claimDownload, finishDownload, cleanupLargeFiles, largeFileStats, filesDir, checkLargeFileTerms } from '../server/files.mjs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { uid } from '../server/util.mjs'; +import { updateUser, writePrefs, mailboxUser, standalone, knownMailboxes, readPasswordPolicy, writePasswordPolicy, passwordProblems, ensureInitialAdmin, INITIAL_ADMIN_PASSWORD, usesInitialPassword } from '../server/auth.mjs'; +import { autoReplyActive, automatedReason, autoProcess, FORWARD_MARKER } from '../server/automation.mjs'; +const { t, done } = runner(); +const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path)); +const login = async (env) => { const user = await verifyAdmin(env, 'arihakdang', 'admin-secret-12'); return SESSION_COOKIE + '=' + await createSession(env, user, new Request('https://x')); }; +// 관리자 페이지(/admin)는 별도 세션(범위 admin, 쿠키 mg_admin)과 X-Mail-Scope 헤더를 씁니다. +const adminLogin = async (env) => { const user = await verifyAdmin(env, 'arihakdang', 'admin-secret-12'); return ADMIN_COOKIE + '=' + await createSession(env, user, new Request('https://x'), { scope: 'admin' }); }; +const adminApi = (env, path, cookie, method, body) => handle(env, request(path, cookie, method, body, { 'X-Mail-Scope': 'admin' }), params(path)); +const sample = (id, extra = {}) => ({ id, from: 'Sender ', to: ['admin@arihakdang.com'], subject: '받은 메일 ' + id, html: '

내용 ' + id + '

', text: '내용 ' + id, created_at: new Date().toISOString(), headers: { 'x-test': '1' }, message_id: '<' + id + '@example.test>', ...extra }); + +await t('인증은 www 관리자 계정만 통과하고 학생·정지·오타를 거부한다', async () => { + const env = await makeEnv(); + ok(await verifyAdmin(env, 'arihakdang', 'admin-secret-12'), 'login_id'); + ok(await verifyAdmin(env, 'ADMIN@arihakdang.com', 'admin-secret-12'), 'email 대소문자'); + eq(await verifyAdmin(env, 'arihakdang', 'wrong'), null, '비밀번호'); + eq(await verifyAdmin(env, 'student', 'admin-secret-12'), null, '학생 거부'); + env.WWW_DB.prepare("UPDATE users SET status='suspended' WHERE id='admin-1'").run(); + eq(await verifyAdmin(env, 'arihakdang', 'admin-secret-12'), null, '정지 계정 거부'); +}); +await t('메일 주소·admin 별칭으로도 메일함 소유 관리자 계정에 인증된다', async () => { + const env = await makeEnv(); + ok(await verifyAdmin(env, 'admin', 'admin-secret-12'), '별칭 admin (이메일 일치)'); ok(await verifyAdmin(env, 'ADMIN@arihakdang.com', 'admin-secret-12'), '메일함 주소'); + eq(await verifyAdmin(env, 'admin', 'wrong'), null, '비밀번호는 www 그대로'); + env.WWW_DB.prepare("UPDATE users SET email='owner@example.test' WHERE id='admin-1'").run(); + ok(await verifyAdmin(env, 'admin', 'admin-secret-12'), '이메일 불일치 → 유일한 활성 관리자'); + env.WWW_DB.prepare("INSERT INTO users VALUES('admin-2','second@example.test','second','둘째','admin','active',?)").run(env.WWW_DB.prepare("SELECT password_hash FROM users WHERE id='admin-1'").get().password_hash); + eq(await verifyAdmin(env, 'admin', 'admin-secret-12'), null, '관리자 둘 이상이면 지정 필요'); + env.MAILBOX_OWNER_LOGIN = 'second'; eq((await verifyAdmin(env, 'admin@arihakdang.com', 'admin-secret-12')).id, 'www:admin-2', 'MAILBOX_OWNER_LOGIN 우선'); + const r = await api(env, 'login', '', 'POST', { login: 'admin', password: 'admin-secret-12' }); eq(r.status, 200, '웹 로그인도 별칭 허용'); +}); +await t('웹 로그인은 세션 쿠키를 주고 잘못된 자격은 401·요청 제한을 받는다', async () => { + const env = await makeEnv(); + eq((await api(env, 'mail')).status, 401, '로그인 필요'); + const r = await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' }); + eq(r.status, 200, '로그인'); const cookie = r.headers.get('set-cookie').split(';')[0]; + eq((await api(env, 'session', cookie)).status, 200, '세션'); + ok(JSON.stringify(await (await api(env, 'session', cookie)).json()).includes('"login":"arihakdang"'), '사용자'); + for (let i = 0; i < 10; i++) await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'bad' }); + eq((await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).status, 429, '요청 제한'); +}); +await t('HTML 정제는 실행 요소를 제거하고 서식·cid 이미지를 유지한다', async () => { + const c = sanitizeHtml('

본문

링크굵게'); + ok(!/script|onclick|onerror|javascript|

B

').html.includes('color') && sanitizeHtml('

A

B

').text.includes('B'), 'style 내용 제거'); + // 2) 헤더 없는 MIME 파트(RFC 2046 5.1) + const eml = Buffer.from('From: a@x.test\r\nTo: b@x.test\r\nSubject: s\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary="b1"\r\n\r\n--b1\r\n\r\nHello headerless\r\n--b1\r\nContent-Type: text/plain\r\n\r\nsecond\r\n--b1--\r\n'); + const parsed = parseMime(eml); eq(parsed.text.slice(parsed.root.parts[0].bodyStart, parsed.root.parts[0].end).trim(), 'Hello headerless', '헤더 없는 파트 본문'); eq(summarize(parsed).text.trim(), 'Hello headerless', '요약 본문'); + // 3) IMAP 으로 올린 초안: inline; filename=report.pdf(Content-ID 없음) + Bcc 헤더 → 웹 발송에서 첨부·숨은 참조가 모두 나갑니다. + const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }); + const draftEml = buildEml({ from: 'admin@arihakdang.com', to: ['friend@example.test'], subject: '보고서', text: '첨부 확인', bcc: ['boss@example.test', 'audit@example.test'], attachments: [{ filename: 'report.pdf', contentType: 'application/pdf', bytes: Buffer.from('%PDF-1.4 fake'), disposition: 'inline' }] }); + const draft = await appendMessage(env, 'drafts', draftEml); + const stored = await env.DB.prepare('SELECT bcc_json FROM messages WHERE id=?').bind(draft.id).first(); eq(stored.bcc_json, '["boss@example.test","audit@example.test"]', 'Bcc 저장'); + eq((await env.DB.prepare('SELECT disposition FROM attachments WHERE message_id=?').bind(draft.id).first()).disposition, 'attachment', 'Content-ID 없는 inline 은 일반 첨부'); + await sendDraft(env, draft.id); + const post = calls.find((c) => c.path === '/emails' && c.method === 'POST'); + eq(JSON.stringify(post.body.bcc), '["boss@example.test","audit@example.test"]', 'bcc 발송'); eq(post.body.attachments?.[0]?.filename, 'report.pdf', '첨부 발송'); ok(!post.body.attachments[0].content_id, '일반 첨부로'); + eq((await env.DB.prepare('SELECT COUNT(*) n FROM attachments WHERE message_id=?').bind(draft.id).first()).n, 1, '첨부가 지워지지 않음'); + // 4) 전달 첨부 복사 API: 받은 메일의 일반 첨부를 새 초안으로 복사(인라인·다른 메일함은 제외). + const inbound = await appendMessage(env, 'inbox', buildEml({ from: 'friend@example.test', to: ['admin@arihakdang.com'], subject: '청구서', text: 'x', html: '

', attachments: [{ filename: 'invoice.pdf', contentType: 'application/pdf', bytes: Buffer.from('pdf'), disposition: 'attachment' }, { filename: 'logo.png', contentType: 'image/png', bytes: Buffer.from('png'), contentId: 'logo', disposition: 'inline' }] })); + const cookie = await login(env); + const origAtt = (await (await api(env, 'mail/' + inbound.id + '/attachments', cookie)).json()).attachments; + const newDraft = (await (await api(env, 'mail', cookie, 'POST', { to: 'x@example.test', subject: 'Fwd: 청구서', html: '

fwd

' })).json()).id; + const copy = await api(env, 'mail/' + newDraft + '/attachments', cookie, 'POST', { from: inbound.id, ids: origAtt.map((a) => a.id) }, { 'Content-Type': 'application/json' }); + eq(copy.status, 201, '복사 201'); eq((await copy.json()).ids.length, 1, '일반 첨부만 복사'); + const copied = (await (await api(env, 'mail/' + newDraft + '/attachments', cookie)).json()).attachments; eq(copied.map((a) => a.filename).join(), 'invoice.pdf', '복사된 첨부'); + // 다른 메일함의 메일을 원본으로 지정하면 거부됩니다(일반 계정 세션). + await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test'); + const sc = (await api(env, 'login', '', 'POST', { login: 'info', password: 'staff-secret-1' })).headers.get('set-cookie').split(';')[0]; + const theirs = (await (await api(env, 'mail', sc, 'POST', { to: 'x@example.test', subject: 's', html: '

x

' })).json()).id; + eq((await api(env, 'mail/' + theirs + '/attachments', sc, 'POST', { from: inbound.id, ids: origAtt.map((a) => a.id) }, { 'Content-Type': 'application/json' })).status, 404, '다른 메일함 원본 거부'); + eq((await api(env, 'mail/' + newDraft + '/attachments', cookie, 'POST', { from: inbound.id }, { 'Content-Type': 'application/json' })).status, 400, 'ids 없으면 400'); +}); + +await t('0.9.0 회원 가입: 끄면 404, 켜면 승인 대기/즉시 사용, 대기 계정 로그인은 pending_approval, 승인·거절, 계정 수 제한, IP 제한', async () => { + const env = await makeEnv({ MAILBOX: 'admin@heart.test', LIMIT_ACCOUNTS: '3' }); delete env.WWW_DB; + await createUser(env, { login: 'admin', password: 'adminpasswd', role: 'admin' }, 'cli'); + const me = await verifyAdmin(env, 'admin', 'adminpasswd'); const ac = ADMIN_COOKIE + '=' + await createSession(env, me, new Request('https://x'), { scope: 'admin' }); + eq((await (await api(env, 'session', '')).json()).signup, false, '세션 응답: 가입 꺼짐'); + eq((await api(env, 'signup', '', 'POST', { login: 'kim', password: 'kim-pass-1', passwordConfirm: 'kim-pass-1' })).status, 404, '꺼져 있으면 404'); + // 관리자가 켭니다(기본: 승인 후 사용). + let cfg = await (await adminApi(env, 'admin/config', ac, 'PATCH', { signup: { enabled: true } })).json(); eq(cfg.signup.enabled, true, '켬'); eq(cfg.signup.approval, true, '기본 승인제'); eq(cfg.accounts.limit, 3, '시스템 계정 수'); + eq((await (await api(env, 'session', '')).json()).signup, true, '세션 응답: 가입 켜짐'); + eq((await (await api(env, 'signup', '', 'POST', { login: 'kim', password: 'kim-pass-1', passwordConfirm: 'kim-pass-2' })).json()).error, 'password_mismatch', '확인 불일치'); + eq((await (await api(env, 'signup', '', 'POST', { login: 'kim', password: 'short' })).json()).error, 'weak_password', '정책'); + let r = await api(env, 'signup', '', 'POST', { login: 'Kim', name: '김', password: 'kim-pass-1', passwordConfirm: 'kim-pass-1', role: 'admin' }); eq(r.status, 201, '가입'); const kim = await r.json(); eq(kim.pending, true, '승인 대기'); + const users = await listUsers(env); const row = users.find((u) => u.login === 'kim'); eq(row.status, 'pending', '목록 상태 pending'); eq(row.role, 'user', '역할은 항상 사용자'); ok(await isPending(env, kim.id), '대기 표시'); + r = await api(env, 'login', '', 'POST', { login: 'kim', password: 'kim-pass-1' }); eq(r.status, 403, '대기 계정 로그인 거부'); eq((await r.json()).error, 'pending_approval', '코드'); + eq((await api(env, 'login', '', 'POST', { login: 'kim', password: 'wrong' })).status, 401, '비밀번호 틀리면 일반 실패(존재 여부 비노출)'); + ok(await pendingLogin(env, 'kim@heart.test', 'kim-pass-1'), '메일함 주소로도 대기 판정'); + eq((await (await adminApi(env, 'admin/users', ac)).json()).users.find((u) => u.login === 'kim').status, 'pending', '관리자 목록에 승인 대기'); + // 승인: 상태를 사용으로. + eq((await adminApi(env, 'admin/users/' + kim.id, ac, 'PATCH', { status: 'active' })).status, 200, '승인'); eq(await isPending(env, kim.id), false, '대기 표시 해제'); + eq((await api(env, 'login', '', 'POST', { login: 'kim', password: 'kim-pass-1' })).status, 200, '승인 뒤 로그인'); + // 즉시 사용 모드 + await adminApi(env, 'admin/config', ac, 'PATCH', { signup: { approval: false } }); + r = await api(env, 'signup', '', 'POST', { login: 'lee', password: 'lee-pass-1', passwordConfirm: 'lee-pass-1' }); eq((await r.json()).pending, false, '즉시 사용'); + eq((await api(env, 'login', '', 'POST', { login: 'lee', password: 'lee-pass-1' })).status, 200, '바로 로그인'); + // 계정 수 제한(3): admin, kim, lee 로 가득 → 거절. 관리자 페이지의 계정 추가도 같은 제한. + r = await api(env, 'signup', '', 'POST', { login: 'park', password: 'park-pass-1' }); eq(r.status, 409, '계정 수 제한'); eq((await r.json()).limit, 3, '상한 전달'); + eq((await (await adminApi(env, 'admin/users', ac, 'POST', { login: 'park', password: 'park-pass-1' })).json()).error, 'account_limit_reached', '관리자 추가도 제한'); + // 거절 = 삭제(대기 표시도 지움) + await adminApi(env, 'admin/config', ac, 'PATCH', { signup: { approval: true } }); + await adminApi(env, 'admin/users/' + kim.id, ac, 'DELETE'); + r = await api(env, 'signup', '', 'POST', { login: 'choi', password: 'choi-pass-1' }); const choi = await r.json(); eq(r.status, 201, '자리 생김'); + eq((await adminApi(env, 'admin/users/' + choi.id, ac, 'DELETE')).status, 200, '거절(삭제)'); eq(await isPending(env, choi.id), false, '대기 키 정리'); + eq((await env.DB.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action='signup'").first()).n, 3, '가입 기록(kim·lee·choi; park 은 거절돼 기록 없음)'); + // IP 제한: 1시간 5회(성공·실패 모두 셈) + for (let i = 0; i < 2; i++) await api(env, 'signup', '', 'POST', { login: 'x' + i, password: 'short' }); + eq((await api(env, 'signup', '', 'POST', { login: 'y', password: 'y-pass-1' })).status, 429, 'IP 당 5회'); +}); +await t('0.9.0 제한: 시스템(환경)·관리자·계정별 메일함 크기, 첨부 크기(MIME 기준), 받는 사람 수, 월간 통수, 전체 용량과 현황(일·주·월 집계)', async () => { + eq(parseSize('500M'), 500 * 1024 * 1024, '크기 접미사'); eq(parseSize('2g'), 2 * 1024 ** 3, '소문자'); eq(parseSize('1.5K'), 1536, '소수'); eq(parseSize('100MB'), 100 * 1024 * 1024, 'MB 표기'); eq(parseSize(''), 0, '빈 값 = 제한 없음'); eq(parseSize('abc'), 0, '잘못된 값'); + eq(mimeSize(3), 4, 'base64 크기'); eq(mimeSize(1000), 1336, 'base64 크기 2'); + // 기간 경계는 Asia/Seoul 기준: 2026-10-12(월) 09:30 KST + const at = new Date('2026-10-12T00:30:00Z'); + eq(periodBounds('day', 'Asia/Seoul', at).start, '2026-10-11T15:00:00.000Z', '오늘 0시 KST'); eq(periodBounds('day', 'Asia/Seoul', at).end, '2026-10-12T15:00:00.000Z', '내일 0시'); + eq(periodBounds('week', 'Asia/Seoul', at).start, '2026-10-11T15:00:00.000Z', '이번 주 월요일'); eq(periodBounds('week', 'Asia/Seoul', at).end, '2026-10-18T15:00:00.000Z', '다음 월요일'); + eq(periodBounds('month', 'Asia/Seoul', at).start, '2026-09-30T15:00:00.000Z', '이달 1일'); eq(periodBounds('month', 'Asia/Seoul', at).end, '2026-10-31T15:00:00.000Z', '다음 달 1일(= 말일까지)'); + eq(periodBounds('month', 'UTC', new Date('2026-12-31T23:00:00Z')).end, '2027-01-01T00:00:00.000Z', '해 넘김'); + // 시스템: 메일함 1MB, 첨부 200KB(MIME), 받는 사람 3, 월 5통, 전체 3MB. 관리자가 시스템 값을 넘기면 거부. + const env = await makeEnv({ LIMIT_MAILBOX_BYTES: '1M', LIMIT_ATTACHMENT_BYTES: '200K', LIMIT_RECIPIENTS: '3', LIMIT_MONTHLY_MESSAGES: '5', LIMIT_TOTAL_BYTES: '3M', RESEND_FETCH: fakeResend({ received: [sample('q1')] }) }); + const sys = systemLimits(env); eq(sys.mailboxBytes, 1048576, '시스템 메일함'); eq(sys.recipients, 3, '시스템 수신자'); eq(sys.accounts, 0, '계정 제한 없음'); + const ac = await adminLogin(env), cookie = await login(env); + let r = await adminApi(env, 'admin/config', ac, 'PATCH', { limits: { recipients: 10 } }); eq(r.status, 422, '시스템 초과 거부'); const err = await r.json(); eq(err.error, 'limit_exceeds_system', '코드'); eq(err.field, 'recipients', '항목'); eq(err.limit, 3, '상한'); + eq((await (await adminApi(env, 'admin/config', ac, 'PATCH', { limits: { recipients: -1 } })).json()).error, 'limit_invalid', '음수 거부'); + let cfg = await (await adminApi(env, 'admin/config', ac, 'PATCH', { limits: { recipients: 2, attachmentBytes: 100 * 1024, mailboxBytes: 0 } })).json(); eq(cfg.limits.recipients, 2, '관리자 수신자 2'); eq(cfg.system.recipients, 3, '시스템 값 함께'); + // 계정별 메일함 크기: 시스템(1MB) 이내만. 적용값 = 계정별 → 관리자 → 시스템 중 가장 작은 0 아닌 값. + const sid = (await (await adminApi(env, 'admin/users', ac, 'POST', { login: 'info', password: 'staff-secret-1', quotaBytes: 2 * 1048576 })).json()); eq(sid.error, 'limit_exceeds_system', '계정 크기 시스템 초과'); + const info = await (await adminApi(env, 'admin/users', ac, 'POST', { login: 'info', password: 'staff-secret-1', quotaBytes: 300 * 1024 })).json(); eq(typeof info.id, 'string', '계정 생성'); + eq(await mailboxQuota(env, 'info@arihakdang.com'), 300 * 1024, '계정별 적용'); eq(await mailboxQuota(env, 'admin@arihakdang.com'), 1048576, '기본 메일함은 시스템 값'); + await adminApi(env, 'admin/config', ac, 'PATCH', { limits: { mailboxBytes: 500 * 1024 } }); eq(await mailboxQuota(env, 'admin@arihakdang.com'), 500 * 1024, '관리자 기본값 적용'); + const list = await (await adminApi(env, 'admin/users', ac)).json(); eq(list.users[0].quotaBytes, 300 * 1024, '목록에 계정 크기'); eq(list.users[0].limit, 300 * 1024, '적용값'); eq(list.system.mailboxBytes, 1048576, '시스템 값'); + eq((await adminApi(env, 'admin/users/' + info.id, ac, 'PATCH', { quotaBytes: 0 })).status, 200, '0 = 기본값'); eq(await mailboxQuota(env, 'info@arihakdang.com'), 500 * 1024, '관리자 기본값으로'); + // 받는 사람 수(관리자 2) + const d1 = await saveDraft(env, { to: 'a@example.test, b@example.test, c@example.test', subject: '많은 수신자', html: '

x

' }); + r = await api(env, 'mail/' + d1 + '/send', cookie, 'POST', {}); eq(r.status, 422, '수신자 초과'); const e1 = await r.json(); eq(e1.error, 'too_many_recipients', '코드'); eq(e1.limit, 2, '상한'); + eq((await (await api(env, 'mail/' + d1, cookie)).json()).message.state, 'draft', '검사는 발송 전이라 초안 유지'); + // 첨부 크기: 관리자 100KB(MIME) → 파일 80KB 는 MIME 107KB 라 거부, 70KB 는 통과. + const d2 = await saveDraft(env, { to: 'a@example.test', subject: '첨부', html: '

x

' }); + let threw = null; try { await addAttachment(env, d2, { filename: 'big.bin', contentType: 'application/octet-stream', bytes: Buffer.alloc(80 * 1024, 1) }); } catch (e) { threw = e; } + eq(threw?.message, 'attachment_too_large', '첨부 초과'); eq(threw?.limit, 100 * 1024, '상한(MIME)'); + ok(await addAttachment(env, d2, { filename: 'ok.bin', contentType: 'application/octet-stream', bytes: Buffer.alloc(70 * 1024, 1) }), '허용 크기'); + // 메일함 용량: info 메일함을 500KB 로 두고 600KB 짜리 받은 메일을 넣으면 가득 → 다음 수신은 quota_exceeded 로 건너뜀, 발송도 거부. + const infoUser = await verifyAdmin(env, 'info', 'staff-secret-1'); + await appendMessage(env, 'inbox', buildEml({ from: 'x@example.test', to: ['info@arihakdang.com'], subject: 'big', text: 'x'.repeat(600 * 1024) }), { mailbox: 'info@arihakdang.com', enforceQuota: false }); + ok((await mailboxUsage(env, 'info@arihakdang.com')) > 500 * 1024, '사용량 집계(MIME 기준)'); + eq((await ingestReceived(env, 'q1', { summary: { to: ['info@arihakdang.com'] } })).skipped, undefined, '다른 메일함(admin) 수신은 영향 없음'); + env.RESEND_FETCH = fakeResend({ received: [sample('q2', { to: ['info@arihakdang.com'] })] }); + eq((await ingestReceived(env, 'q2')).skipped, 'quota_exceeded', '가득 찬 메일함 수신 건너뜀'); + const sc = (await api(env, 'login', '', 'POST', { login: 'info', password: 'staff-secret-1' })).headers.get('set-cookie').split(';')[0]; + const sl = await (await api(env, 'mail?folder=inbox', sc)).json(); eq(sl.quotaBytes, 500 * 1024, '목록 응답에 적용 크기'); ok(sl.usageBytes > 500 * 1024, '목록 응답 사용량'); + const d3 = (await (await api(env, 'mail', sc, 'POST', { to: 'a@example.test', subject: 's', html: '

x

' })).json()).id; + eq((await (await api(env, 'mail/' + d3 + '/send', sc, 'POST', {})).json()).error, 'quota_exceeded', '가득 찬 메일함에서 발송 거부'); + threw = null; try { await appendMessage(env, 'drafts', buildEml({ from: 'info@arihakdang.com', to: ['a@example.test'], subject: 'd', text: 'x' }), { mailbox: 'info@arihakdang.com' }); } catch (e) { threw = e; } eq(threw?.message, 'quota_exceeded', 'IMAP APPEND 도 거부'); + // 월간 통수(5): 이달에 보낸 4 + 받은 1 = 5 → 발송 거부. 자동 전달도 건너뜀(limit). + const { start } = periodBounds('month', 'Asia/Seoul'); const inMonth = new Date(Date.parse(start) + 3600000).toISOString(); + for (let i = 0; i < 4; i++) await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,subject,state,received_at,created_at,updated_at,sent_at,mailbox) VALUES(?,'outbound','sent','a','s','sent',?,?,?,?,'admin@arihakdang.com')").bind('m' + i, inMonth, inMonth, inMonth, inMonth).run(); + const month = await countMail(env, periodBounds('month', 'Asia/Seoul')); eq(month.sent, 4, '월 발신'); ok(month.received >= 1, '월 수신'); ok(month.total >= 5, '합계'); + const d4 = await saveDraft(env, { to: 'a@example.test', subject: '월간', html: '

x

' }); + r = await api(env, 'mail/' + d4 + '/send', cookie, 'POST', {}); const e4 = await r.json(); eq(e4.error, 'monthly_limit_exceeded', '월간 초과'); eq(e4.limit, 5, '상한'); + await writePrefs(env, { id: 'www:admin-1', mailbox: 'admin@arihakdang.com' }, { forward: { enabled: true, to: 'fw@example.test' } }); + env.RESEND_FETCH = fakeResend({ received: [sample('q3')] }); const q3 = await ingestReceived(env, 'q3'); eq(q3.auto?.forward?.detail, 'limit', '자동 전달 건너뜀(월간 한도)'); + // 현황 + const st = await mailStats(env, await knownMailboxes(env), await listUsers(env)); + eq(st.timezone, 'Asia/Seoul', '시간대'); ok(st.periods.month.total >= 5, '월간 합계'); ok(st.periods.day.received >= 1, '일일 수신'); + const infoBox = st.mailboxes.find((m) => m.mailbox === 'info@arihakdang.com'); eq(infoBox.login, 'info', '메일함 주인'); eq(infoBox.limit, 500 * 1024, '적용 크기'); ok(infoBox.percent > 100, '사용 비율(초과)'); + eq(st.usage.monthly.limit, 5, '월간 제한'); ok(st.usage.monthly.percent >= 100, '월간 비율'); eq(st.usage.total.limit, 3 * 1048576, '전체 제한'); eq(st.usage.accounts.limit, 0, '계정 제한 없음'); eq(st.usage.accounts.percent, null, '제한 없으면 비율 없음'); + const status = await (await adminApi(env, 'admin/status', ac)).json(); eq(status.stats.usage.monthly.used, st.usage.monthly.used, '서버 상태 응답에 현황'); eq(status.services.pop, true, '서비스 상태'); + // 전체 용량(3MB): admin 메일함에 2.5MB 를 넣어 가득 채우면 수신·발송 모두 거부. + await env.DB.prepare("UPDATE messages SET raw_size=?, mailbox='archive@arihakdang.com' WHERE id='m0'").bind(2.6 * 1048576).run(); // 다른 메일함의 용량도 전체 합계에 듭니다 + env.RESEND_FETCH = fakeResend({ received: [sample('q4')] }); eq((await ingestReceived(env, 'q4')).skipped, 'storage_full', '전체 용량 초과 수신 건너뜀'); +}); +await t('0.9.0 POP/IMAP 서비스 스위치: 관리자가 끄면 계정 설정과 무관하게 로그인이 거부되고, 설정 화면에 알려진다', async () => { + const env = await makeEnv(), ac = await adminLogin(env), cookie = await login(env); + const cfg = await (await adminApi(env, 'admin/config', ac, 'PATCH', { services: { pop: false } })).json(); eq(cfg.services.pop, false, 'POP 끔'); eq(cfg.services.imap, true, 'IMAP 유지'); + eq((await (await api(env, 'settings', cookie)).json()).services.pop, false, '설정 응답에 반영'); + const pop = createPop3(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' }); + try { + const c = popClient(port); await c.greeting(); await c.cmd('USER arihakdang'); const r = await c.cmd('PASS admin-secret-12'); ok(r.startsWith('-ERR [SYS/PERM]') && /administrator/.test(r), 'POP 거부: ' + r); c.end(); + await adminApi(env, 'admin/config', ac, 'PATCH', { services: { pop: true } }); + const c2 = popClient(port); await c2.greeting(); await c2.cmd('USER arihakdang'); ok((await c2.cmd('PASS admin-secret-12')).startsWith('+OK'), '다시 켜면 로그인'); c2.end(); + } finally { pop.close(); } + ok((await env.DB.prepare("SELECT detail FROM audit_log WHERE action='config_update' ORDER BY created_at DESC LIMIT 1").first()).detail.includes('services'), '설정 변경 기록'); +}); +await t('0.9.7 평문 로그인 허용 스위치: 기본 허용이라 110 에서 STLS 없이 로그인되고, 관리자가 끄면 CAPA 에서 USER 가 빠지며 거절되고, POP_ALLOW_PLAINTEXT=1 은 항상 허용', async () => { + const env = await makeEnv(), ac = await adminLogin(env), cookie = await login(env); + // 1) allowPlaintext 를 넘기지 않으면(운영 기본) 관리자 설정을 따르고, 기본값은 허용입니다. Gmail 처럼 CAPA 없이 바로 USER/PASS 를 보내는 흐름. + const pop = createPop3(env, { hostname: 'mail.test' }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' }); + try { + let c = popClient(port); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), '기본값: 평문 로그인 허용'); c.end(); + c = popClient(port); await c.greeting(); let capa = await c.cmd('CAPA', true); ok(/\r\nUSER\r\n/.test(capa) && /SASL PLAIN/.test(capa) && !/STLS/.test(capa), 'CAPA 에 USER 광고(인증서 없음 → STLS 없음): ' + capa); c.end(); + let s = await (await api(env, 'settings', cookie)).json(); eq(s.pop.plaintext, true, '설정 응답 pop.plaintext'); eq(s.imap.plaintext, true, '설정 응답 imap.plaintext'); + // 2) 관리자가 끄면: 다음 접속부터 CAPA 에 USER 가 없고 USER/PASS/AUTH 는 [SYS/PERM] 로 거절. 비밀번호 검사(인증 실패 횟수)에는 들지 않습니다. + const cfg = await (await adminApi(env, 'admin/config', ac, 'PATCH', { services: { plainLogin: false } })).json(); eq(cfg.services.plainLogin, false, '끔'); eq(cfg.services.pop, true, 'POP 스위치는 그대로'); + c = popClient(port); await c.greeting(); capa = await c.cmd('CAPA', true); ok(!/\r\nUSER\r\n/.test(capa) && !/SASL/.test(capa), 'USER 광고 안 함: ' + capa); + const r = await c.cmd('USER arihakdang'); ok(r.startsWith('-ERR [SYS/PERM]') && /STLS/.test(r), '거절: ' + r); ok((await c.cmd('AUTH PLAIN')).startsWith('-ERR [SYS/PERM]'), 'AUTH 도 거절'); c.end(); + s = await (await api(env, 'settings', cookie)).json(); eq(s.pop.plaintext, false, '설정 응답에 반영'); + eq((await (await adminApi(env, 'admin/status', ac)).json()).plainLogin, false, '서버 상태 응답'); + eq((await (await adminApi(env, 'admin/config', ac)).json()).services.plainLogin, false, '관리자 설정 응답'); + // 3) 다시 켜면 바로(다음 접속부터) 허용. 설정은 접속마다 읽습니다. + await adminApi(env, 'admin/config', ac, 'PATCH', { services: { plainLogin: true } }); + c = popClient(port); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), '다시 켜면 로그인'); c.end(); + } finally { pop.close(); } + // 4) 환경 변수 POP_ALLOW_PLAINTEXT=1 은 관리자 설정이 꺼져 있어도 허용(진단용, 예전 의미 유지). 0 은 아무 뜻이 없습니다(관리자 설정을 따름). + await adminApi(env, 'admin/config', ac, 'PATCH', { services: { plainLogin: false } }); + const env1 = Object.assign(Object.create(Object.getPrototypeOf(env)), env, { POP_ALLOW_PLAINTEXT: '1' }); + const pop1 = createPop3(env1, { hostname: 'mail.test', allowPlaintext: env1.POP_ALLOW_PLAINTEXT === '1' ? true : null }); const port1 = port + 1; await pop1.listen({ plainPort: port1, host: '127.0.0.1' }); + try { const c = popClient(port1); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), 'env=1 이면 항상 허용'); c.end(); } finally { pop1.close(); } + eq((await (await api(env1, 'settings', cookie)).json()).pop.plaintext, true, 'env=1 은 설정 응답에도 허용으로'); + const env0 = Object.assign(Object.create(Object.getPrototypeOf(env)), env, { POP_ALLOW_PLAINTEXT: '0' }); + const pop0 = createPop3(env0, { hostname: 'mail.test', allowPlaintext: env0.POP_ALLOW_PLAINTEXT === '1' ? true : null }); const port0 = port + 2; await pop0.listen({ plainPort: port0, host: '127.0.0.1' }); + try { const c = popClient(port0); await c.greeting(); ok((await c.cmd('USER arihakdang')).startsWith('-ERR [SYS/PERM]'), 'env=0 + 관리자 끔 → 거절'); c.end(); } finally { pop0.close(); } + await adminApi(env, 'admin/config', ac, 'PATCH', { services: { plainLogin: true } }); + const pop2 = createPop3(env0, { hostname: 'mail.test', allowPlaintext: null }); const port2 = port + 3; await pop2.listen({ plainPort: port2, host: '127.0.0.1' }); + try { const c = popClient(port2); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), 'env=0 + 관리자 켬 → 허용(설치 템플릿 기본값 0 에서 Gmail 110 이 되는 핵심)'); c.end(); } finally { pop2.close(); } +}); +await t('0.9.7 POP3 메일함 잠금: 같은 계정의 두 번째 세션은 [IN-USE] 로 거절되고 기록되며, 첫 세션이 끝나면 바로 들어갈 수 있고 종료 요약이 남는다', async () => { + const env = await makeEnv(); + const pop = createPop3(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' }); + const logs = []; const orig = console.log; console.log = (...a) => { logs.push(a.join(' ')); }; + try { + const c1 = popClient(port); await c1.greeting(); await c1.cmd('USER arihakdang'); ok((await c1.cmd('PASS admin-secret-12')).startsWith('+OK'), '첫 세션'); + const c2 = popClient(port); await c2.greeting(); await c2.cmd('USER arihakdang'); const r = await c2.cmd('PASS admin-secret-12'); ok(r.startsWith('-ERR [IN-USE]'), '두 번째 세션 거절: ' + r); + ok(logs.some((l) => l.includes('POP3 접속 거부') && l.includes('다른 세션')), 'IN-USE 가 로그에 남음: ' + logs.filter((l) => l.includes('거부')).join(' | ')); + // 거절된 세션은 인증 상태로 남아 다시 시도할 수 있습니다(비밀번호 실패로 세지 않음). + ok((await c1.cmd('QUIT')).startsWith('+OK'), '첫 세션 종료'); + await new Promise((r) => setTimeout(r, 50)); + await c2.cmd('USER arihakdang'); ok((await c2.cmd('PASS admin-secret-12')).startsWith('+OK'), '잠금이 풀리면 바로 로그인'); ok((await c2.cmd('QUIT')).startsWith('+OK'), '종료'); + await new Promise((r) => setTimeout(r, 50)); + const ends = logs.filter((l) => l.startsWith('POP3 종료')); eq(ends.length, 2, '세션마다 종료 요약 한 줄: ' + ends.join(' | ')); ok(/RETR 0·DELE 0 [\d.]+s \(quit\)/.test(ends[0]), '요약 형식: ' + ends[0]); + } finally { console.log = orig; pop.close(); } +}); +await t('0.9.7 오류 수정: 세션 중 영구 삭제된 메일이 있어도 QUIT 이 성공하고 나머지 전달 기록이 남으며, 비 ASCII 비밀번호가 USER/PASS 로 로그인된다', async () => { + const raw = { a: Buffer.from('From: s@example.test\r\nSubject: raw a\r\n\r\nA\r\n'), b: Buffer.from('From: s@example.test\r\nSubject: raw b\r\n\r\nB\r\n') }; + const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a'), sample('b')], raw }) }); + for (const id of ['a', 'b']) await ingestReceived(env, id); + const cookie = await login(env), admin = await adminLogin(env); + const pop = createPop3(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000); await pop.listen({ plainPort: port, host: '127.0.0.1' }); + try { + // 1) RETR 두 통 → 세션이 열린 채 웹에서 첫 통을 영구 삭제 → QUIT. 예전에는 외래 키 위반으로 묶음 전체가 되돌아가 -ERR 였고 둘째 통도 기록되지 않았습니다. + const c = popClient(port); await c.greeting(); await c.cmd('USER arihakdang'); ok((await c.cmd('PASS admin-secret-12')).startsWith('+OK'), '로그인'); + const uidl = (await c.cmd('UIDL', true)).split('\r\n').filter((l) => /^\d+ /.test(l)).map((l) => l.split(' ')[1]); + ok((await c.cmd('RETR 1', true)).startsWith('+OK'), 'RETR 1'); ok((await c.cmd('RETR 2', true)).startsWith('+OK'), 'RETR 2'); + await api(env, 'mail/' + uidl[0], cookie, 'PATCH', { folder: 'trash' }); eq((await api(env, 'mail/' + uidl[0], cookie, 'DELETE')).status, 200, '세션 중 영구 삭제'); + const q = await c.cmd('QUIT'); ok(q.startsWith('+OK'), 'QUIT 성공: ' + q); c.end(); await new Promise((r) => setTimeout(r, 50)); + eq((await env.DB.prepare('SELECT COUNT(*) n FROM pop_deliveries WHERE message_id=?').bind(uidl[1]).first()).n, 1, '남은 메일의 전달 기록'); eq((await maildrop(env, 'www:admin-1')).length, 0, '다시 내려가지 않음'); + // 2) 한글 비밀번호: 웹·AUTH PLAIN 과 같은 UTF-8 값으로 검사해야 합니다. 예전에는 latin1 그대로 검사해 USER/PASS 만 항상 실패했습니다. + eq((await adminApi(env, 'admin/users', admin, 'POST', { login: 'hangul', password: '비밀번호-1234', mailbox: 'hangul@arihakdang.com' })).status, 201, '한글 비밀번호 계정'); + const c2 = popClient(port); await c2.greeting(); await c2.cmd('USER hangul'); ok((await c2.cmd('PASS 비밀번호-1234')).startsWith('+OK'), 'USER/PASS 한글 비밀번호'); await c2.cmd('QUIT'); c2.end(); + const c3 = popClient(port); await c3.greeting(); ok((await c3.cmd('AUTH PLAIN ' + Buffer.from('\0hangul\0비밀번호-1234').toString('base64'))).startsWith('+OK'), 'AUTH PLAIN 도 여전히 됨'); await c3.cmd('QUIT'); c3.end(); + } finally { pop.close(); } +}); +await t('0.9.7 오류 수정: 시작 때 인증서가 없으면 TLS 포트를 기억해 두고 setTls 로 인증서가 들어올 때 연다', async () => { + const env = await makeEnv(); + const dir = mkdtempSync(join(tmpdir(), 'mg-tls-')); + try { execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', join(dir, 'key.pem'), '-out', join(dir, 'cert.pem'), '-days', '2', '-subj', '/CN=mail.test'], { stdio: 'ignore' }); } + catch { console.log(' (openssl 없음 — TLS 지연 열기 시험 건너뜀)'); rmSync(dir, { recursive: true, force: true }); return; } + const { readFileSync } = await import('node:fs'); const key = readFileSync(join(dir, 'key.pem')), cert = readFileSync(join(dir, 'cert.pem')); + const pop = createPop3(env, { hostname: 'mail.test', tlsOptions: null, allowPlaintext: true }); const port = 20000 + Math.floor(Math.random() * 20000), sport = port + 1; + const tlsGreeting = () => new Promise((resolve) => { const s = tls.connect({ port: sport, host: '127.0.0.1', rejectUnauthorized: false }, () => {}); let buf = ''; s.setTimeout(3000, () => { s.destroy(); resolve('timeout'); }); s.on('data', (d) => { buf += d; if (buf.includes('\r\n')) { s.destroy(); resolve(buf.trim()); } }); s.on('error', (e) => resolve('error:' + e.code)); }); + try { + const bound = await pop.listen({ plainPort: port, securePort: sport, host: '127.0.0.1' }); eq(bound.length, 1, '인증서 없음 → 평문만 열림: ' + bound.join(',')); + eq(await tlsGreeting(), 'error:ECONNREFUSED', '995 는 아직 닫혀 있음'); + pop.setTls({ key, cert }); await new Promise((r) => setTimeout(r, 150)); + const g = await tlsGreeting(); ok(g.startsWith('+OK') && g.includes('POP3 ready'), '인증서가 들어오면 TLS 포트가 열려 인사함: ' + g); + pop.setTls({ key, cert }); await new Promise((r) => setTimeout(r, 50)); ok((await tlsGreeting()).startsWith('+OK'), '다시 setTls 해도(갱신) 그대로 동작'); + } finally { pop.close(); rmSync(dir, { recursive: true, force: true }); } +}); +await t('0.9.0 오류 수정: form/head/iframe 뒤 본문 보존, 재구성 원본의 옛 Cc·Reply-To·Bcc 제거, Cc 에만 있는 메일 동기화, LIKE 와일드카드, 8비트 UTF-8 헤더, 보내는 사람 이름 따옴표', async () => { + // 1) 정제기:
으로 감싼 본문·닫히지 않은 /`,remote};} +// 파일 첨부인지(본문에 끼운 그림이 아닌지). Content-ID 가 없는 inline 은 본문에서 참조할 수 없으므로 일반 첨부로 봅니다(0.9.9). +// Apple Mail 등은 PDF 를 "inline; filename=…" 으로 보내는데, 예전에는 읽기 창에는 칩이 보이면서 전달 창에서는 빠졌습니다. +const isFile=a=>a.disposition!=='inline'||!a.content_id; +async function openMessage(id){ + try{S.current=id;$$('.mail-row').forEach(r=>r.setAttribute('aria-selected',String(r.dataset.id===id))); + const m=(await api('mail/'+id)).message; + // 경쟁 방지(0.9.9): 느린 응답 사이에 다른 줄을 눌렀으면(S.current 가 바뀜) 이 응답은 버립니다. 예전에는 먼저 누른 메일의 늦은 응답이 나중에 그려져, + // 목록은 두 번째 메일이 선택돼 있는데 읽기 창은 첫 메일을 보여 주고 답장·삭제 단추도 첫 메일에 동작했습니다. + if(S.current!==id)return; + // 초안 상태(또는 발송 실패)인 메일만 작성 창으로 엽니다. 임시 보관함에 들어온 받은 메일(옛 IMAP MOVE 등)은 보통 읽기 창으로 보여 줍니다(0.8.24). + // 휴지통에 있는 메일은 발송 실패한 메일이라도 읽기 창(복원·영구 삭제)으로 엽니다. 예전에는 잠긴 작성 창이 열려 할 수 있는 일이 없었습니다(0.8.25). + if(m.folder!=='trash'&&((m.folder==='drafts'&&m.state==='draft')||m.state==='failed')){openCompose(m);return;} + const rowEl=$(`.mail-row[data-id="${id}"]`);const wasUnread=rowEl&&rowEl.classList.contains('unread');if(rowEl)rowEl.classList.remove('unread');const rowData=S.rows.find(x=>x.id===id);if(rowData&&!rowData.read_at)rowData.read_at=new Date().toISOString(); + if(wasUnread){const c=S.counts.find(x=>x.folder===m.folder);if(c&&c.unread>0)c.unread--;renderSidebar();refreshCounts();} + const to=JSON.parse(m.recipients_json||'[]'),cc=JSON.parse(m.cc_json||'[]'),replyTo=JSON.parse(m.reply_to_json||'[]');let details=false,remote=!!S.prefs.remoteImages; + const paint=()=>{if(S.current!==id)return;const body=frame(m.previewHtml||m.html||'

'+esc(m.text)+'

',remote); + $('#read-pane').innerHTML=`
${m.folder==='trash'?``:``}${m.direction==='inbound'?``:''}⬇ ${text('original')}

${esc(m.subject||tr('noSubject'))}${m.flagged?` ${text('flagged')}`:''}${m.answered?` ${text('answered')}`:''}

${esc(initial(m.sender))}
${esc(nameOf(m.sender))}
<${esc(addr(m.sender))}> → ${esc(to.join(', '))}${cc.length?' · cc '+esc(cc.join(', ')):''}
${details?`
${text('from')}${esc(m.sender)}${text('to')}${esc(to.join(', '))}${cc.length?`${text('cc')}${esc(cc.join(', '))}`:''}${replyTo.length?`Reply-To${esc(replyTo.join(', '))}`:''}${text('receivedAt')}${esc(fullDate(m.received_at))} · ${text('source_'+m.source)} · ${text(m.raw_status==='stored'?'originalStored':m.raw_status==='built'?'originalBuilt':'originalNone')}POP${m.popDeliveries.length?m.popDeliveries.map(p=>esc(fullDate(p.delivered_at))+' ('+esc(p.via)+')').join(', ')+` `:text('popNotDelivered')}${m.message_id?`Message-ID${esc(m.message_id)}`:''}${(m.autoActions||[]).length?`${text('autoActions')}${m.autoActions.map(autoLine).join('
')}
`:''}
`:''}
${body.remote?``:''}${body.html}
${m.attachments.length?`
${m.attachments.map(a=>a.available?`📎 ${esc(a.filename)} ${kb(a.size)}`:`📎 ${esc(a.filename)} ${text('attachmentUnavailable')}`).join('')}
`:''}${(m.largeFiles||[]).length?`
☁ ${text('largeFileHeading')}${m.largeFiles.map(f=>`☁ ${esc(f.filename)} ${kb(f.size)} ${f.expired?text('largeExpired'):esc(fill('largeStatus',{remaining:f.remaining,max:f.max_downloads,until:fullDate(f.expires_at)}))}${f.expired?'':``}`).join('')}
`:''}`; + $$('[data-revoke]').forEach(b=>b.onclick=async()=>{if(!confirm(tr('confirmRevoke')))return;try{await api('mail/'+id+'/files/'+b.dataset.revoke,'DELETE');openMessage(id);}catch(e){fail(e);}}); + $('#read-pane').classList.add('open');if(window.innerWidth<=900){$('#rd-back').style.display='';$('#rd-back').onclick=()=>{$('#read-pane').classList.remove('open');};} + const fr=$('.mail-frame');fr.onload=()=>{try{fr.style.height=Math.max(300,fr.contentDocument.body.scrollHeight+30)+'px';}catch{}}; + $('#rd-details').onclick=()=>{details=!details;paint();};if($('#rd-remote'))$('#rd-remote').onclick=()=>{remote=!remote;paint();}; + const act=async(body,confirmKey,after)=>{if(confirmKey&&!confirm(tr(confirmKey)))return;try{await api('mail/'+id,body.action==='delete'?'DELETE':'PATCH',body.action==='delete'?undefined:body);if(after==='close'){S.current=null;emptyRead();}await renderMail();if(after==='reopen')openMessage(id);}catch(e){fail(e);}}; + if($('#rd-trash'))$('#rd-trash').onclick=()=>act({folder:'trash'},null,'close');if($('#rd-restore'))$('#rd-restore').onclick=()=>act({restore:true},null,'close');if($('#rd-purge'))$('#rd-purge').onclick=()=>act({action:'delete'},'confirmDeleteForever','close'); + $('#rd-star').onclick=()=>act({flagged:!m.flagged},null,'reopen');if($('#rd-unread'))$('#rd-unread').onclick=()=>act({unread:true},null,'close');$('#rd-move').onchange=e=>{if(e.target.value)act({action:'move',target:e.target.value},null,'close');}; + if($('#rd-pop-reset'))$('#rd-pop-reset').onclick=()=>act({popReset:true},'popResetConfirm','reopen'); + // 답장·전달 인용문. 읽기 창에서 외부 이미지를 막아 둔 상태(remote=false)면 인용문에서도 외부 이미지 주소를 지웁니다. + // 편집기는 iframe 이 아니라 이 페이지 안에 있으므로, 그대로 넣으면 답장 버튼을 누르는 순간 추적 이미지가 불러와졌습니다. + const quoteBody=()=>{const html=m.previewHtml||m.html||esc(m.text).replaceAll('\n','
');if(remote)return html;const t=document.createElement('template');t.innerHTML=html;t.content.querySelectorAll('img').forEach(img=>{try{if(new URL(img.getAttribute('src'),location.href).origin!==location.origin)img.removeAttribute('src');}catch{img.removeAttribute('src');}});return t.innerHTML;}; + const quoted=`



${esc(fill('quoteHeader',{date:fullDate(m.received_at),from:m.sender}))}
${quoteBody()}
`; + const targets=m.direction==='inbound'?(replyTo.length?replyTo:[addr(m.sender)]):to;const re=(/^re:/i.test(m.subject)?'':'Re: ')+(m.subject||''); + // 전체 답장의 참조 목록(0.9.9): 내 주소와 받는 사람(targets)을 뺀 나머지인데, 비교는 소문자로 하고 같은 주소는 한 번만 넣습니다. + // 예전에는 대소문자만 다른 주소(From: Bob@Example.com, Cc: bob@example.com)가 받는 사람과 참조에 모두 들어가 두 번 발송됐습니다. + const lower=a=>String(a||'').toLowerCase();const ccList=()=>{const seen=new Set([S.mailbox,...targets].map(lower));return [...to,...cc].map(addr).filter(a=>{if(!a||seen.has(lower(a)))return false;seen.add(lower(a));return true;});}; + $('#rd-reply').onclick=()=>openCompose({recipients_json:JSON.stringify(targets),subject:re,html:quoted,inReplyTo:m.message_id,title:'replyTitle'}); + $('#rd-reply-all').onclick=()=>openCompose({recipients_json:JSON.stringify(targets),cc_json:JSON.stringify(ccList()),subject:re,html:quoted,inReplyTo:m.message_id,title:'replyTitle'}); + // 전달(0.8.25): 원본의 일반 첨부(인라인 그림 제외)도 함께 보냅니다. openCompose 가 초안을 만든 뒤 forwardFrom 으로 서버에 복사를 요청합니다. + $('#rd-forward').onclick=()=>openCompose({forwardFrom:{id:m.id,ids:(m.attachments||[]).filter(a=>isFile(a)&&a.available).map(a=>a.id)},subject:(/^fwd?:/i.test(m.subject)?'':'Fwd: ')+(m.subject||''),html:`


${text('forwardedHeader')}
${text('from')}: ${esc(m.sender)}
${text('receivedAt')}: ${esc(fullDate(m.received_at))}
${text('mailSubject')}: ${esc(m.subject)}
${text('to')}: ${esc(to.join(', '))}

${quoteBody()}`,title:'forwardTitle'}); + };paint(); + }catch(e){fail(e);} +} +// ------------------------------------------------------------------ 편집기 +const FONTS=['기본','Arial','Georgia','Verdana','Tahoma','Courier New','Malgun Gothic','Apple SD Gothic Neo','Nanum Gothic']; +// 도구 모음 아이콘(워드 프로세서와 같은 모양). 정렬: 긴 줄·짧은 줄을 번갈아 왼쪽/가운데/오른쪽에 맞춘 선. 들여쓰기/내어쓰기: 줄과 화살표. +const svg=(inner,box=16)=>``; +// 0.8.12: 링크·링크 제거·인용·이미지·서식 지우기도 일반 편집기(Word·Google Docs·Gmail)에서 쓰는 모양으로 통일합니다(24 격자). +// link: 사슬 두 고리 / unlink: 같은 사슬에 빗금(끊어진 링크) / quote: 큰따옴표 두 개(인용) / image: 그림 틀 + 해·산 / clear: T 에 빗금(서식 지우기 "Tx") +const ICON24={ + link:svg('',24), + unlink:svg('',24), + quote:'', + image:svg('',24), + clear:'', +}; +const ICON={alignLeft:svg(''),alignCenter:svg(''),alignRight:svg(''), + indent:svg(''),outdent:svg('')}; +// 기본 색상 8색. 글자색은 진한 색, 강조색은 연한 바탕색입니다. 더 많은 색은 브라우저 색상 선택창(RGB 입력·스포이드)으로 이어집니다. +const PALETTE={foreColor:[['#000000','color_black'],['#e53935','color_red'],['#fb8c00','color_orange'],['#fdd835','color_yellow'],['#43a047','color_green'],['#1e88e5','color_blue'],['#8e24aa','color_purple'],['#9e9e9e','color_gray']], + hiliteColor:[['#fff59d','color_yellow'],['#ffe0b2','color_orange'],['#ffcdd2','color_red'],['#c8e6c9','color_green'],['#bbdefb','color_blue'],['#e1bee7','color_purple'],['#e0e0e0','color_gray'],['#ffffff','color_white']]}; +function toolbar(){const b=(cmd,label,icon)=>``;return ``;} +/** + * 편집기 바인딩. 핵심은 "선택 영역 기억/복원"입니다. + * contenteditable 의 선택 영역은 도구 모음의 다른 요소(색상 선택창, 글꼴 목록)가 포커스를 가져가면 브라우저에 따라 사라집니다. + * 예전에는 색상 자체의 mousedown 에서만 선택을 기억해서, 글자 "A"(label)를 눌러 창을 열거나 브라우저의 + * 색상 선택창이 포커스를 가져간 뒤에는 기억한 범위가 없거나 낡아, 복원 때 커서가 맨 앞으로 가고 색이 적용되지 않았습니다. + * 이제 (1) 도구 모음 어디를 누르든 포커스가 옮겨 가기 전(capture 단계 mousedown)에 기억하고, (2) 편집기 안에서 선택이 바뀔 때마다 + * (selectionchange) 기억하며, (3) 색상 팔레트 버튼은 mousedown 을 막아 포커스를 아예 가져가지 않습니다. + */ +function bindEditor(form,onImage){const editor=$('[data-editor]',form);const tools=$('.editor-tools',form);let range=null; + const remember=()=>{const s=window.getSelection();if(s.rangeCount&&editor.contains(s.anchorNode)&&editor.contains(s.focusNode))range=s.getRangeAt(0).cloneRange();}; + const restore=()=>{editor.focus({preventScroll:true});if(range){const s=window.getSelection();s.removeAllRanges();s.addRange(range);}}; + const command=(cmd,value)=>{restore();document.execCommand('styleWithCSS',false,true);document.execCommand(cmd,false,value);remember();refreshState();}; + const refreshState=()=>{$$('[data-cmd]',form).forEach(b=>{if(['bold','italic','underline','strikeThrough','insertOrderedList','insertUnorderedList','justifyLeft','justifyCenter','justifyRight'].includes(b.dataset.cmd))b.setAttribute('aria-pressed',String(document.queryCommandState(b.dataset.cmd)));});}; + editor.addEventListener('keyup',()=>{remember();refreshState();});editor.addEventListener('mouseup',()=>{remember();refreshState();});editor.addEventListener('focus',remember); + document.addEventListener('selectionchange',()=>{if(document.activeElement===editor)remember();}); + tools.addEventListener('mousedown',remember,true); + $$('[data-cmd]',form).forEach(b=>{b.onmousedown=e=>e.preventDefault();b.onclick=()=>command(b.dataset.cmd);}); + // 글꼴 "기본" 은 글꼴만 상위 값으로 되돌립니다. 예전에는 removeFormat 을 불러 글꼴을 되돌리려다 굵게·색 등 다른 서식까지 지워졌습니다. + $$('[data-format]',form).forEach(el=>{el.onchange=()=>{if(el.dataset.format==='fontName'&&!el.value)command('fontName','inherit');else command(el.dataset.format,el.value);};}); + // ---- 서식 지우기(0.8.12 에서 다시 구현) + // 브라우저의 removeFormat 은 굵게·기울임·글꼴·글자색 같은 글자 서식만 지우고, 강조색(배경색, Chrome), 정렬(text-align), 들여쓰기(여백), + // 인용(blockquote), 제목(h1~h4), 링크는 그대로 두며 커서만 있을 때(선택 없음)는 아무 일도 하지 않습니다. 그래서 "서식 지우기가 안 된다"고 느껴졌습니다. + // 동작: 선택이 없으면 커서가 있는 단락 전체를 대상으로 하고, (1) removeFormat·unlink 로 글자 서식과 링크를 지운 뒤 + // (2) 경계에 걸친 인라인 요소는 선택 부분만 떼어 내고 (3) 범위 안의 요소에서 서식 속성(style·class·align·color·face·size·bgcolor)을 지우며 + // 제목·pre 는 단락(p)으로, 인용·인라인 래퍼(span·font·b·i·u·s·code·a …)는 껍데기를 벗깁니다. 목록·표·이미지·구분선은 내용 구조라 그대로 둡니다. + const BLOCKS=new Set(['P','DIV','H1','H2','H3','H4','H5','H6','PRE','BLOCKQUOTE','LI','TD','TH','UL','OL','TABLE','TR','TBODY','THEAD']); + const INLINE_WRAPPERS=new Set(['SPAN','FONT','B','STRONG','I','EM','U','S','STRIKE','DEL','INS','CODE','SUB','SUP','MARK','SMALL','BIG','TT','A']); + const FORMAT_ATTRS=['style','class','align','color','face','size','bgcolor']; + const blockOf=n=>{let e=n&&n.nodeType===1?n:n&&n.parentNode;while(e&&e!==editor&&!BLOCKS.has(e.tagName))e=e.parentNode;return e&&e!==editor?e:null;}; + const unwrap=el=>{const p=el.parentNode;if(!p)return;while(el.firstChild)p.insertBefore(el.firstChild,el);p.removeChild(el);}; + const containsNode=(r,node)=>{const nr=document.createRange();nr.selectNode(node);return r.compareBoundaryPoints(Range.START_TO_START,nr)<=0&&r.compareBoundaryPoints(Range.END_TO_END,nr)>=0;}; + // 경계(container/offset)를 감싸는 인라인 래퍼 사슬을 그 지점에서 둘로 나눕니다. 선택 밖 부분은 복제한 껍데기에 담아 옆에 둬 서식을 유지합니다. + const splitEdge=(r,toStart)=>{const container=toStart?r.startContainer:r.endContainer,offset=toStart?r.startOffset:r.endOffset;let el=container.nodeType===1?container:container.parentNode;let top=null;while(el&&el!==editor&&INLINE_WRAPPERS.has(el.tagName)){top=el;el=el.parentNode;} + if(!top)return;const cut=document.createRange();if(toStart){cut.setStart(top,0);cut.setEnd(container,offset);}else{cut.setStart(container,offset);cut.setEnd(top,top.childNodes.length);} + if(cut.collapsed)return;const frag=cut.extractContents();if(!frag.textContent&&!frag.querySelector('img,br'))return;const clone=top.cloneNode(false);clone.appendChild(frag);top.parentNode.insertBefore(clone,toStart?top:top.nextSibling);}; + const clearFormatting=()=>{restore();const sel=window.getSelection();if(!sel.rangeCount)return;let r=sel.getRangeAt(0); + if(r.collapsed){const b=blockOf(r.startContainer);if(!b)return;r=document.createRange();r.selectNodeContents(b);sel.removeAllRanges();sel.addRange(r);} + document.execCommand('styleWithCSS',false,true);document.execCommand('removeFormat');document.execCommand('unlink'); + const work=sel.getRangeAt(0).cloneRange(); // Range 는 DOM 이 바뀌면 경계를 따라 움직이므로 아래 수정 중에도 선택 범위를 가리킵니다. + splitEdge(work,true);splitEdge(work,false); + const walker=document.createTreeWalker(editor,NodeFilter.SHOW_ELEMENT);const nodes=[];let n;while((n=walker.nextNode()))if(work.intersectsNode(n))nodes.push(n); + for(const el of nodes){if(!el.isConnected||['IMG','BR','HR'].includes(el.tagName))continue;const inline=INLINE_WRAPPERS.has(el.tagName);if(inline&&!containsNode(work,el))continue; + for(const a of FORMAT_ATTRS)el.removeAttribute(a); + if(/^H[1-6]$/.test(el.tagName)||el.tagName==='PRE'){const p=document.createElement('p');while(el.firstChild)p.appendChild(el.firstChild);el.replaceWith(p);} + else if(el.tagName==='BLOCKQUOTE'||inline)unwrap(el);} + sel.removeAllRanges();sel.addRange(work);remember();refreshState(); + $$('[data-format]',form).forEach(el=>{el.selectedIndex=el.dataset.format==='fontSize'?1:0;}); + editor.dispatchEvent(new Event('input',{bubbles:true}));}; // 자동 저장이 변경을 알아채게 합니다(직접 DOM 을 고쳐 input 이벤트가 나지 않음). + $('[data-clear]',form).onmousedown=e=>e.preventDefault();$('[data-clear]',form).onclick=clearFormatting; + // 색상 팔레트: 기본 8색을 먼저 보여 주고, "더 많은 색상…" 을 누르면 브라우저의 색상 선택창(RGB 입력·스포이드)으로 이어집니다. + // 팝업 안의 버튼은 mousedown 을 막아 편집기의 선택 영역이 유지되게 합니다. 선택창은 포커스를 가져가므로 기억한 범위로 복원해 적용합니다. + let pop=null;const closePalette=()=>{if(pop){pop.remove();pop=null;}}; + $$('[data-color]',form).forEach(btn=>{const cmd=btn.dataset.color;btn.onmousedown=e=>e.preventDefault();btn.onclick=()=>{if(pop&&pop.dataset.cmd===cmd){closePalette();return;}closePalette();remember(); + pop=document.createElement('div');pop.className='color-pop';pop.dataset.cmd=cmd;pop.setAttribute('role','dialog'); + pop.innerHTML=`
${PALETTE[cmd].map(([c,k])=>``).join('')}
${cmd==='hiliteColor'?``:''}
`; + btn.parentElement.appendChild(pop); + const apply=c=>{command(cmd,c);btn.querySelector('.swatch').style.background=c==='transparent'?'#fff':c;}; + $$('[data-pick]',pop).forEach(p=>{p.onmousedown=e=>e.preventDefault();p.onclick=()=>{apply(p.dataset.pick);closePalette();};}); + const input=$('input[type=color]',pop);$('[data-more]',pop).onmousedown=e=>e.preventDefault();$('[data-more]',pop).onclick=()=>{remember();input.click();}; + input.oninput=()=>apply(input.value);input.onchange=()=>{apply(input.value);closePalette();};};}); + document.addEventListener('mousedown',e=>{if(pop&&!e.target.closest('.color-tool'))closePalette();}); + $('[data-link]',form).onmousedown=e=>e.preventDefault();$('[data-link]',form).onclick=()=>{const url=prompt(tr('linkUrl'),'https://');if(!url)return;try{if(!['https:','http:','mailto:'].includes(new URL(url).protocol))throw Error();restore();if(window.getSelection().isCollapsed){const label=prompt(tr('linkText'),url)||url;document.execCommand('insertHTML',false,`${esc(label)}`);}else command('createLink',url);}catch{notify('invalidUrl');}}; + $('[data-quote]',form).onmousedown=e=>e.preventDefault();$('[data-quote]',form).onclick=()=>command('formatBlock','blockquote'); + $('[data-image]',form).onmousedown=e=>e.preventDefault();$('[data-image]',form).onclick=()=>{const input=document.createElement('input');input.type='file';input.accept='image/png,image/jpeg,image/gif,image/webp';input.multiple=true;input.onchange=async()=>{for(const f of input.files){try{const url=await onImage(f);if(url){restore();document.execCommand('insertHTML',false,`
`);remember();}}catch(e){fail(e);}}};input.click();}; + $('[data-html]',form).onclick=()=>{const src=$('.html-source',form);if(src.hidden){src.value=editor.innerHTML;src.hidden=false;editor.hidden=true;$('[data-html]',form).setAttribute('aria-pressed','true');notify('htmlMode');}else{editor.innerHTML=src.value;src.hidden=true;editor.hidden=false;$('[data-html]',form).setAttribute('aria-pressed','false');}}; + editor.onpaste=async e=>{e.preventDefault();remember();try{const files=[...e.clipboardData.files].filter(f=>/^image\//.test(f.type));if(files.length){for(const f of files){const url=await onImage(f);if(url){restore();document.execCommand('insertHTML',false,`
`);}}return;}const html=e.clipboardData.getData('text/html');if(html){const clean=await api('mail/sanitize','POST',{html});restore();document.execCommand('insertHTML',false,clean.html);}else{restore();document.execCommand('insertText',false,e.clipboardData.getData('text/plain'));}}catch{notify('error');}}; + // ---- 이미지 크기: 삽입 시 편집기 폭에 맞추고, 클릭하면 크기 도구와 모서리 손잡이를 보여 줍니다. + const editorWidth=()=>Math.max(120,editor.clientWidth-34); + // silent: 창을 열 때 하는 첫 맞춤에서는 input 이벤트를 내지 않습니다. 예전에는 이 맞춤이 "편집" 으로 잡혀, 인용문에 이미지가 있는 답장·전달 창을 + // 열기만 해도 4초 뒤 자동 저장이 초안을 만들었습니다(아무것도 쓰지 않았는데 임시 보관함에 "Re: …" 가 남음). + const setWidth=(img,px,silent=false)=>{px=Math.round(Math.min(Math.max(40,px),editorWidth()));img.style.width=px+'px';img.style.height='auto';img.style.maxWidth='100%';img.setAttribute('width',String(px));img.removeAttribute('height');if(!silent)editor.dispatchEvent(new Event('input',{bubbles:true}));place();}; + // 기본 크기: 글쓰기 창의 가로/세로 중 작은 쪽의 80% 상자 안에 가로·세로가 모두 들어가게 줄입니다(확대는 하지 않음). + const fitBox=()=>{const visible=Math.max(200,form.clientHeight-160);return Math.round(Math.min(editorWidth(),visible)*0.8);}; + const fit=(img,silent=false)=>{const box=fitBox(),nw=img.naturalWidth||0,nh=img.naturalHeight||0;if(!nw||!nh){img.style.maxWidth='100%';return;}const scale=Math.min(1,box/nw,box/nh); + // 줄일 필요가 없는(상자보다 작은) 그림은 크기를 건드리지 않습니다(0.9.9). 예전에는 setWidth 의 최소 40px 때문에 인용문의 1×1 추적 그림·간격용 그림이 40px 로 커진 채 저장·발송됐습니다. + if(scale>=1){img.style.maxWidth='100%';return;}setWidth(img,Math.max(40,Math.round(nw*scale)),silent);}; + const whenLoaded=(img,fn)=>{const run=()=>{if(img.naturalWidth)fn(img);};if(img.complete&&img.naturalWidth){run();return;}(img.decode?img.decode():Promise.reject()).then(run,()=>{img.addEventListener('load',run,{once:true});if(img.complete)run();});}; + const sizer=document.createElement('div');sizer.className='img-sizer';sizer.hidden=true;sizer.innerHTML=`${text('imgSize')}${[25,50,75,100].map(p=>``).join('')}`; + const handle=document.createElement('div');handle.className='img-handle';handle.hidden=true;form.append(sizer,handle);let selected=null; + const place=()=>{if(!selected||!selected.isConnected){hideSizer();return;}const r=selected.getBoundingClientRect(),f=form.getBoundingClientRect();sizer.style.left=Math.max(0,r.left-f.left)+'px';sizer.style.top=(r.bottom-f.top+form.scrollTop+6)+'px';handle.style.left=(r.right-f.left-7)+'px';handle.style.top=(r.bottom-f.top+form.scrollTop-7)+'px';$('[data-px]',sizer).value=Math.round(r.width);}; + const hideSizer=()=>{if(selected)selected.classList.remove('selected');selected=null;sizer.hidden=true;handle.hidden=true;}; + const selectImage=img=>{hideSizer();selected=img;img.classList.add('selected');sizer.hidden=false;handle.hidden=false;place();}; + editor.addEventListener('click',e=>{if(e.target.tagName==='IMG')selectImage(e.target);else hideSizer();}); + form.addEventListener('scroll',()=>{if(selected)place();});window.addEventListener('resize',()=>{if(selected)place();}); + $$('[data-pct]',sizer).forEach(b=>b.onclick=()=>{if(selected)setWidth(selected,editorWidth()*Number(b.dataset.pct)/100);}); + $('[data-fit]',sizer).onclick=()=>{if(selected)setWidth(selected,editorWidth());};$('[data-orig]',sizer).onclick=()=>{if(selected)setWidth(selected,selected.naturalWidth||editorWidth());}; + $('[data-px]',sizer).onchange=e=>{if(selected)setWidth(selected,Number(e.target.value));}; + handle.addEventListener('mousedown',e=>{if(!selected)return;e.preventDefault();const startX=e.clientX,startW=selected.getBoundingClientRect().width;const mv=ev=>setWidth(selected,startW+(ev.clientX-startX));const up=()=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);};document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);}); + handle.addEventListener('touchstart',e=>{if(!selected)return;const startX=e.touches[0].clientX,startW=selected.getBoundingClientRect().width;const mv=ev=>setWidth(selected,startW+(ev.touches[0].clientX-startX));const up=()=>{document.removeEventListener('touchmove',mv);document.removeEventListener('touchend',up);};document.addEventListener('touchmove',mv,{passive:true});document.addEventListener('touchend',up);},{passive:true}); + // 다시 연 초안: 사용자가 정한 크기(width)가 없는 이미지만 맞춥니다. + editor.querySelectorAll('img').forEach(img=>{if(!img.getAttribute('width')&&!img.style.width)whenLoaded(img,i=>fit(i,true));else img.style.maxWidth='100%';}); + const insertImage=url=>{restore();document.execCommand('insertHTML',false,`
`);remember();const img=[...editor.querySelectorAll('img')].reverse().find(i=>i.getAttribute('src')===url||i.src===new URL(url,location.href).href);if(img)whenLoaded(img,fit);}; + return {html:()=>{hideSizer();editor.querySelectorAll('img.selected').forEach(i=>i.classList.remove('selected'));return $('.html-source',form).hidden?editor.innerHTML:$('.html-source',form).value;},editor,insertImage}; +} +// ------------------------------------------------------------------ 작성 +// 편집기에는 previewHtml(cid: 그림을 /api/mail//attachments/… 주소로 바꾼 본문)을 넣습니다. IMAP 으로 올린 초안의 그림이 예전에는 cid: 그대로라 깨져 보였습니다(0.8.16). +function openCompose(m){ + const locked=m.state&&m.state!=='draft';let id=m.id||null;let files=m.attachments||[];let dirty=false,saving=null,timer=null;let closed=false; + // rev: 편집할 때마다 1씩 늘어나는 번호. 저장이 끝났을 때 저장을 시작한 시점의 번호와 같을 때만 dirty 를 끕니다. + // 예전에는 저장 요청 중에 입력한 내용까지 "저장됨"으로 처리해 그 편집이 사라질 수 있었습니다. + let rev=0; + // autoCreated: 사용자가 아무것도 하지 않았는데 코드가 만든 초안(전달 창의 첨부 복사용). touched: 사용자가 첨부·대용량 파일을 올렸음. + // 전달 창을 열고 그대로 닫으면 rev(편집 횟수)가 0 이고 touched 도 아니므로 자동 생성 초안을 지웁니다(0.9.9). 예전에는 "Fwd: …" 초안이 임시 보관함에 남았습니다. + let autoCreated=false,touched=false; + const dlg=$('#compose');const title=m.title?tr(m.title):id?tr('editDraft'):tr('composeTitle');const showCc=!!(m.cc_json&&JSON.parse(m.cc_json).length),showBcc=!!(m.bcc_json&&JSON.parse(m.bcc_json).length); + $('#compose-body').innerHTML=`

${esc(title)}

${text('fromLabel')}
${text('to')}
${text('cc')}
${text('bcc')}
${text('mailSubject')}
${toolbar()}
${m.previewHtml||m.html||'


'}
${locked?'':`${S.largeFiles&&S.largeFiles.enabled?``:''}`}
`; + const form=$('#compose-form'); + // 첨부·본문 이미지를 올리려면 초안 id 가 필요합니다. 직접 POST 하지 않고 save 를 거쳐, 자동 저장과 동시에 초안이 두 개 생기지 않게 합니다. + const ensureDraft=async()=>{if(id)return id;return save(true);}; + const upload=async(file,inline)=>{touched=true;await ensureDraft();const data=new FormData();data.append('files',file);const d=await api('mail/'+id+'/attachments?'+(inline?'inline=1':''),'POST',data);files=(await api('mail/'+id+'/attachments')).attachments;paintFiles();return inline?d.urls[0]:null;}; + const ed=bindEditor(form,f=>upload(f,true)); + const values=()=>({to:form.elements.to.value,cc:form.elements.cc.value,bcc:form.elements.bcc.value,subject:form.elements.subject.value,html:ed.html(),...(m.inReplyTo?{inReplyTo:m.inReplyTo}:{})}); + const paintFiles=()=>{$('#cp-files').innerHTML=files.filter(isFile).map(a=>`📎 ${esc(a.filename)} ${kb(a.size)}${locked?'':``}`).join('');$$('[data-remove]',$('#cp-files')).forEach(b=>b.onclick=async()=>{try{await api('mail/'+id+'/attachments/'+b.dataset.remove,'DELETE');files=files.filter(a=>a.id!==b.dataset.remove);paintFiles();}catch(e){fail(e);}});};paintFiles(); + // ---- 대용량 첨부파일(0.9.3): 메일에 붙이지 않고 서버에 두고 링크를 보냅니다. 올리면 칩에 "☁" 표시와 함께 보이고, 하나라도 있으면 발송 조건(보관 일수·다운로드 횟수) 줄이 나타납니다. + let large=m.largeFiles||[]; + const paintLarge=()=>{const live=large.filter(f=>f.state!=='expired');$('#cp-large-files').innerHTML=live.map(f=>`☁ ${esc(f.filename)} ${kb(f.size)}${locked?'':``}`).join('');$('#cp-terms').hidden=!live.length; + $$('[data-remove-large]',$('#cp-large-files')).forEach(b=>b.onclick=async()=>{try{await api('mail/'+id+'/files/'+b.dataset.removeLarge,'DELETE');large=large.filter(f=>f.id!==b.dataset.removeLarge);paintLarge();}catch(e){fail(e);}});};paintLarge(); + // 업로드는 XMLHttpRequest 로(진행률 표시). 본문이 파일 바이트 그대로이고 이름은 X-File-Name 헤더로 보냅니다. 서버 상한을 넘으면 413 과 limit 이 돌아옵니다. + /** + * 대용량 파일 업로드(0.9.5 에서 진행 표시 추가). 올리는 동안 칩 자리에 파일 이름·진행 막대·퍼센트·보낸 양/전체·취소(✕) 단추가 보이고, 끝나면 보통 칩으로 바뀝니다. + * 오류는 원인을 나눠 알립니다: 서버가 JSON 으로 거부(코드 그대로), 프록시(nginx)가 413 으로 막음(upload_too_large_proxy), 그 밖의 HTTP 상태(upload_failed + 상태 코드), 네트워크 끊김(upload_network), 사용자가 취소(조용히). + */ + const uploadLarge=file=>new Promise(async(resolve,reject)=>{touched=true;try{await ensureDraft();}catch(e){reject(e);return;} + const x=new XMLHttpRequest();const row=document.createElement('span');row.className='chip large uploading';row.innerHTML=`☁ ${esc(file.name)} ${kb(file.size)}0%`;$('#cp-large-files').appendChild(row); + const bar=row.querySelector('progress'),pct=row.querySelector('.pct');let cancelled=false,sent=false; + // 취소: 전송 중이면 abort 가 onabort 를 부르지만, 아직 send 전(초안 생성 중)이면 아무 이벤트도 나지 않으므로 직접 정리합니다. + row.querySelector('button').onclick=()=>{cancelled=true;x.abort();if(!sent){done();reject(new Error('upload_cancelled'));}}; + x.open('POST',BASE+'api/files?draft='+encodeURIComponent(id));x.setRequestHeader('X-File-Name',encodeURIComponent(file.name));x.setRequestHeader('Content-Type',file.type||'application/octet-stream'); + x.upload.onprogress=e=>{if(!e.lengthComputable)return;const p=Math.round(e.loaded/e.total*100);bar.value=p;pct.textContent=p+'% · '+kb(e.loaded)+' / '+kb(e.total);$('#cp-status').textContent='☁ '+p+'%';}; + x.upload.onload=()=>{pct.textContent=tr('uploadFinishing');}; + const done=()=>{row.remove();$('#cp-status').textContent='';}; + x.onload=()=>{done();let d=null;try{d=JSON.parse(x.responseText);}catch{}if(x.status>=200&&x.status<300&&d&&d.file){large.push(d.file);paintLarge();resolve(d.file);return;} + const err=new Error(d&&d.error?d.error:x.status===413?'upload_too_large_proxy':'upload_failed');if(d&&d.limit!==undefined)err.limit=d.limit;if(!d||!d.error)err.detail='HTTP '+x.status;reject(err);}; + x.onerror=()=>{done();reject(new Error('upload_network'));};x.onabort=()=>{done();reject(new Error(cancelled?'upload_cancelled':'upload_network'));};sent=true;x.send(file);}); + if($('#cp-large')){$('#cp-large').onclick=()=>$('#cp-large-input').click();$('#cp-large-input').onchange=async e=>{$('#cp-large').disabled=true;try{for(const f of e.target.files)await uploadLarge(f);notify('largeAttached');}catch(err){if(err.message!=='upload_cancelled')fail(err);}finally{$('#cp-large').disabled=false;$('#cp-status').textContent='';e.target.value='';}};} + // 발송 조건: 보관 일수·다운로드 횟수(둘 다 필수, 상한 이내). 서버도 같은 검사를 합니다. + const largeTerms=()=>{if(!large.some(f=>f.state!=='expired'))return undefined;const t=$('#cp-terms');return {days:Number(t.querySelector('[name=largeDays]').value),downloads:Number(t.querySelector('[name=largeDownloads]').value)};}; + // 저장은 한 번에 하나씩. 진행 중인 저장이 있으면 끝나기를 기다린 뒤 지금 내용으로 한 번 더 저장합니다 + // (예전에는 진행 중인 저장을 그대로 돌려줘, 발송이 직전 편집이 빠진 본문으로 나갈 수 있었습니다). + const save=async(quiet)=>{if(locked)return id;if(saving){await saving.catch(()=>{});return save(quiet);}const startRev=rev;saving=(async()=>{try{if(id)await api('mail/'+id,'PATCH',values());else id=(await api('mail','POST',values())).id;if(rev===startRev)dirty=false;$('#cp-status').textContent=fill('draftAutosaved',{time:when(new Date().toISOString())});if(!quiet)notify('draftSaved');}finally{saving=null;}return id;})();return saving;}; + const markDirty=()=>{dirty=true;rev++;clearTimeout(timer);timer=setTimeout(()=>{if(dirty&&!closed)save(true).catch(()=>{});},4000);}; + form.oninput=markDirty;ed.editor.addEventListener('input',markDirty); + // 참조·숨은 참조(0.9.5): 받는 사람 줄의 "참조" / "숨은 참조" 를 누르면 각각의 입력 줄이 열리고 단추는 사라집니다(예전에는 참조 줄만 열리고 숨은 참조는 입력할 수 없었음). + const openRow=(btn,row)=>{$(btn).onclick=()=>{$(row).hidden=false;$(btn).hidden=true;$(row+' input').focus();};};openRow('#cp-cc-toggle','#cp-cc-row');openRow('#cp-bcc-toggle','#cp-bcc-row'); + if($('#cp-save'))$('#cp-save').onclick=()=>save(false).catch(fail); + const askImageMode=n=>new Promise(res=>{const box=$('#cp-choice');box.hidden=false;$('#cp-choice-text').textContent=fill('imageChoiceTitle',{count:n});const done=v=>{box.hidden=true;res(v);};$('#cp-choice-inline').onclick=()=>done('inline');$('#cp-choice-attach').onclick=()=>done('attach');$('#cp-choice-cancel').onclick=()=>done(null);box.scrollIntoView({block:'nearest'});}); + const handleFiles=async(list,{askImages=true}={})=>{list=[...list];if(!list.length||locked)return; + // 일반 첨부 상한(MIME 기준)을 넘는 파일은 대용량 첨부가 켜져 있으면 그쪽으로 올립니다(0.9.3). 꺼져 있으면 서버가 attachment_too_large 로 알립니다. + const over=S.largeFiles&&S.largeFiles.enabled&&S.attachmentBytes>0?list.filter(f=>Math.ceil(f.size/3)*4>S.attachmentBytes):[];if(over.length){list=list.filter(f=>!over.includes(f));try{for(const f of over)await uploadLarge(f);notify('largeAutoRouted');}catch(e){fail(e);}finally{$('#cp-status').textContent='';}if(!list.length)return;} + const images=list.filter(f=>/^image\//.test(f.type)),others=list.filter(f=>!/^image\//.test(f.type));let mode='attach';if(images.length&&askImages){mode=await askImageMode(images.length);if(!mode)return;}$('#cp-status').textContent='⏳';try{for(const f of others)await upload(f,false);for(const f of images){if(mode==='inline'){const url=await upload(f,true);ed.insertImage(url);}else await upload(f,false);}notify(images.length&&mode==='inline'?'inlineAdded':'attached');}catch(e){fail(e);}finally{$('#cp-status').textContent='';}}; + if($('#cp-attach')){$('#cp-attach').onclick=()=>$('#cp-file-input').click();$('#cp-file-input').onchange=async e=>{$('#cp-attach').disabled=true;try{await handleFiles(e.target.files,{askImages:false});}finally{$('#cp-attach').disabled=false;e.target.value='';}};} + let dragDepth=0;const overlay=$('#cp-drop');const hasFiles=e=>[...(e.dataTransfer?.types||[])].includes('Files'); + form.addEventListener('dragenter',e=>{if(!hasFiles(e)||locked)return;e.preventDefault();dragDepth++;overlay.hidden=false;});form.addEventListener('dragover',e=>{if(hasFiles(e)){e.preventDefault();e.dataTransfer.dropEffect='copy';}});form.addEventListener('dragleave',()=>{dragDepth=Math.max(0,dragDepth-1);if(!dragDepth)overlay.hidden=true;});form.addEventListener('drop',e=>{if(!hasFiles(e))return;e.preventDefault();dragDepth=0;overlay.hidden=true;handleFiles(e.dataTransfer.files);}); + const close=()=>{closed=true;clearTimeout(timer);dlg.close();renderMail();}; + // 닫기 전에 저장합니다. 저장에 실패하면 창을 닫지 않고 물어봅니다(예전에는 오류만 보이고 닫혀 작성한 내용이 사라졌습니다). + $('#cp-close').onclick=async()=>{if(dirty&&!locked){try{await save(true);}catch(e){fail(e);if(!confirm(tr('confirmCloseUnsaved')))return;}} + // 손대지 않은 자동 생성 초안은 휴지통을 거쳐 영구 삭제합니다(서버는 휴지통의 메일만 DELETE 를 받음). 실패해도 창은 닫습니다(초안이 남는 것뿐). + if(id&&autoCreated&&rev===0&&!touched){try{await api('mail/'+id,'PATCH',{folder:'trash'});await api('mail/'+id,'DELETE');}catch{}} + close();}; + $('#cp-discard').onclick=async()=>{if(!confirm(tr('confirmDiscard')))return;try{if(id)await api('mail/'+id,'PATCH',{folder:'trash'});}catch(e){fail(e);}closed=true;clearTimeout(timer);dlg.close();renderMail();}; + $('#cp-send').onclick=async()=>{const b=$('#cp-send');b.disabled=true;$('#cp-status').textContent=tr('sending');try{if(!locked)await save(true);await api('mail/'+id+'/send','POST',{largeFiles:largeTerms()});closed=true;clearTimeout(timer);dlg.close();S.folder='sent';S.page=0;S.current=null;await renderMail();notify('mailSent');}catch(e){fail(e);const msg=tr(A[e.message]?e.message:'error')+(e.detail?' ('+e.detail+')':'');$('#cp-status').textContent=msg; + // 발송 실패로 잠긴 메일은 "다시 보내기" 창으로 다시 그립니다. 오류 문구(statusText)를 함께 넘겨 새 창의 상태 줄에도 남깁니다(0.9.9). 예전에는 다시 그리면서 비어 6초짜리 알림만 남았습니다. + if(id){try{const cur=(await api('mail/'+id)).message;if(cur.state!=='draft'){openCompose({...cur,statusText:msg});return;}}catch{}}}finally{b.disabled=false;}}; + dlg.oncancel=e=>{e.preventDefault();$('#cp-close').click();}; + if(!dlg.open)dlg.showModal();if(!locked)(form.elements.to.value?ed.editor:form.elements.to).focus(); + if(m.statusText)$('#cp-status').textContent=m.statusText; + // 전달 첨부 복사: 초안을 먼저 만들고(ensureDraft) 원본의 첨부 id 를 서버에 넘겨 복사한 뒤 칩 목록을 다시 그립니다. + if(m.forwardFrom&&m.forwardFrom.ids.length)(async()=>{try{if(!id)autoCreated=true;await ensureDraft();await api('mail/'+id+'/attachments','POST',{from:m.forwardFrom.id,ids:m.forwardFrom.ids});files=(await api('mail/'+id+'/attachments')).attachments;paintFiles();}catch(e){fail(e);}})(); +} +// ------------------------------------------------------------------ 설정 +/** + * 웹 메일 "도구 → 설정". 0.9.0 에서 더한 것: 비밀번호 변경 카드(게이트웨이 계정만, 현재 비밀번호 + 새 비밀번호 2회 + 눈 단추), + * 메일함 사용량·적용 중인 제한(첨부 크기·받는 사람 수) 카드, 관리자가 POP/IMAP 서비스 자체를 껐을 때의 안내. + */ +async function settingsPage(){ + viewVersion++;showPanes(true);const s=await api('settings');const kv=rows=>`
${rows.map(([k,v])=>`
${k}
${v}
`).join('')}
`; + // 메일 전달·자동 응답(0.8.16): 서버에 저장된 설정(prefs.forward / prefs.autoReply)으로 양식을 채웁니다. + // 시각은 서버에 ISO(UTC)로 저장돼 있어 datetime-local 입력 상자에는 브라우저 지역 시각(YYYY-MM-DDTHH:MM)으로 바꿔 보여 줍니다. + const fw=S.prefs.forward||{enabled:false,to:''},ar=S.prefs.autoReply||{enabled:false,noLimit:true,start:'',end:'',subject:'',message:''}; + const localInput=iso=>{if(!iso)return '';const d=new Date(iso);if(isNaN(d))return '';const p=x=>String(x).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;}; + const acct=`${kv([[text('popUser'),`${esc(S.me.source==='www'?'admin':S.me.login)} / ${esc(S.mailbox)}`],[text('password'),text('popPasswordValue')]])}`; + const svc=s.services||{pop:true,imap:true},q=s.quota||{usageBytes:0,limit:0},lim=s.limits||{},policy=s.policy||{minLength:6}; + const pct=q.limit>0?Math.min(100,Math.round(q.usageBytes/q.limit*100)):null; + $('#page-pane').innerHTML=`

${text('settings')}

${text('settingsIntro')}

+

${text('passwordSection')}

${s.canChangePassword?`

${text('passwordIntro')}

${pwField('currentPassword',text('userCurrentPassword'),'autocomplete="current-password" required')}${pwField('password',text('newPassword'),`autocomplete="new-password" required minlength="${Number(policy.minLength)||6}" title="${esc(passwordRule(policy))}"`)}${pwField('passwordConfirm',text('userPasswordConfirm'),'autocomplete="new-password" required')}

${esc(passwordRule(policy))} ${text('passwordChangeHelp')}

`:`

${text(s.managedBy==='webmaker'?'passwordWebmakerManaged':'passwordWwwManaged')}

`}
+

${text('quotaSection')}

${kv([[text('usageLabel'),`${kb(q.usageBytes)}${q.limit>0?` / ${kb(q.limit)} ${esc(fill('quotaPercent',{percent:pct}))}`:` · ${text('quotaUnlimited')}`}`],[text('limitAttachment'),esc(fill('limitAttachmentValue',{mb:mbOf(lim.attachmentBytes||0)}))],[text('limitRecipients'),esc(fill('limitRecipientsValue',{n:lim.recipients||0}))],...(s.largeFiles&&s.largeFiles.enabled?[[text('largeFileHeading'),esc(fill('largeLimitsValue',{mb:mbOf(s.largeFiles.maxBytes||0),days:s.largeFiles.maxDays,downloads:s.largeFiles.maxDownloads}))]]:[])])}

${text('mimeNote')}

+

${text('prefsSection')}

${text('prefRemoteImagesHelp')}

+

${text('servicesSection')}

${text('servicesIntro')}

${text('servicesHelp')}${svc.pop===false||svc.imap===false?' '+text('serviceOffByAdminHelp'):''}

+

${text('forwardSection')}

${text('forwardIntro')}

${text('forwardOnNote')}

${text('forwardHelp')}

+

${text('autoReplySection')}

${text('autoReplyIntro')}

${text('autoReplyOnNote')}
${text('autoReplyPeriod')}

${text('autoReplyPeriodHelp')}

${text('autoReplyHelp')}

+

${text('imapSettings')}

${text('imapIntro')}

${kv([[text('imapHost'),`${esc(s.imap.host)}`],[text('imapPortTls'),`${s.imap.ports.imaps} ${text(s.imap.tls?'imapTlsActive':'imapTlsMissing')}`],[text('imapPortPlain'),`${s.imap.ports.starttls} ${text(s.imap.plaintext?'popPlainAllowed':'popPlainRequired')}`]])}${acct}${kv([[text('imapMailboxes'),text('imapMailboxesValue')]])}

${text('imapSteps')}

    ${[1,2,3,4,5].map(n=>`
  1. ${text('imapStep'+n)}
  2. `).join('')}

${text('imapNotes')}

    ${[1,2,3].map(n=>`
  • ${text('imapNote'+n)}
  • `).join('')}
+

${text('smtpSettings')}

${text('smtpIntro')}

${kv([[text('smtpHost'),`${esc(s.smtp.host)}`],[text('smtpPortSmtps'),`${s.smtp.ports.smtps} ${text(s.smtp.tls?'imapTlsActive':'smtpTlsMissing')}`],[text('smtpPortStarttls'),`${s.smtp.ports.starttls}`]])}${acct}${kv([[text('smtpFrom'),`${esc(s.smtp.from)}`],[text('smtpAuth'),text('smtpAuthValue')]])}

${text('smtpSteps')}

    ${[1,2,3,4,5].map(n=>`
  1. ${text('smtpStep'+n)}
  2. `).join('')}

${text('smtpNotes')}

    ${[1,2,3].map(n=>`
  • ${text('smtpNote'+n)}
  • `).join('')}
+

${text('popSettings')}

${text('popIntro')}

${kv([[text('popHost'),`${esc(s.pop.host)}`],[text('popPortTls'),`${s.pop.ports.pop3s} ${text(s.pop.tls?'popTlsActive':'popTlsMissing')}`],[text('popPortPlain'),`${s.pop.ports.starttls} ${text(s.pop.plaintext?'popPlainAllowed':'popPlainRequired')}`]])}${acct}

${text('popDedupe')}

${text('popNoteGmail')}

+ `; + // 비밀번호 변경(0.9.0): 새 비밀번호 두 입력이 같은지 화면에서 먼저 보고, 서버(PATCH /api/password)가 현재 비밀번호와 정책을 검사합니다. + // 성공하면 서버가 이 계정의 세션을 모두 끝내므로 로그인 화면으로 돌아가 다시 로그인하게 안내합니다. + if($('#pw-form')){const pf=$('#pw-form');bindEyes(pf);pf.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',pf);b.disabled=true;try{const f=pf.elements;if(f.password.value!==f.passwordConfirm.value){fail(new Error('password_mismatch'));return;} + await api('password','PATCH',{currentPassword:f.currentPassword.value,password:f.password.value,passwordConfirm:f.passwordConfirm.value});showLogin();$('#login-feedback').textContent=tr('passwordChangedRelogin');$('#login-feedback').classList.add('info');} + catch(err){const el=$('#feedback');el.textContent=passwordError(err);clearTimeout(notify.t);notify.t=setTimeout(()=>{el.textContent='';},6000);}finally{b.disabled=false;}};} + // 메일 프로그램 접속(POP/IMAP) 허용은 사용자가 직접 켜고 끕니다. 끄면 그 서버 로그인이 거부되고, 웹 메일에는 영향이 없습니다. + const toggle=(sel,key)=>{$(sel).onchange=async e=>{try{const r=await api('prefs','PATCH',{[key]:e.target.checked});S.prefs=r.prefs;notify('prefSaved');}catch(err){fail(err);e.target.checked=!e.target.checked;}};};toggle('#pref-pop','pop');toggle('#pref-imap','imap'); + $('#pref-remote').onchange=async e=>{try{const r=await api('prefs','PATCH',{remoteImages:e.target.checked});S.prefs=r.prefs;notify('prefSaved');}catch(err){fail(err);e.target.checked=!e.target.checked;}}; + // 메일 전달: 체크 상자와 주소를 저장 버튼 한 번으로 보냅니다(주소 검사는 서버가 하고 forward_invalid 로 알림). + // 켜져 있으면 체크 상자 옆에 "현재 메일은 다음 주소로 전달되고 있습니다" 를 굵게 보여 전달 중임을 분명히 합니다. 끄려면 체크를 풀고 저장합니다. + // (예전에는 저장 버튼 옆의 상태 글("전달하지 않음")이 버튼처럼 보여 혼동을 주었습니다.) + const fwForm=$('#fw-form');const fwNote=()=>{$('#fw-on').hidden=!S.prefs.forward?.enabled;};fwNote(); + fwForm.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',fwForm);b.disabled=true;try{const r=await api('prefs','PATCH',{forward:{enabled:fwForm.elements.enabled.checked,to:fwForm.elements.to.value}});S.prefs=r.prefs;fwForm.elements.to.value=r.prefs.forward.to;fwForm.elements.enabled.checked=r.prefs.forward.enabled;fwNote();notify('prefSaved');renderSidebar();}catch(err){fail(err);}finally{b.disabled=false;}}; + // 자동 응답: 기간 선택(기한 제한 없음 / 기간 지정)과 시작·종료 입력 상자를 처음부터 모두 보여 줍니다. "기한 제한 없음" 이면 시각 상자만 비활성화합니다. + // 켜고 끄는 것은 "자동 응답 사용" 체크 상자와 저장 버튼뿐이며, 켜져 있으면 옆에 "메일이 수신되면 자동으로 아래 메일을 발신자에게 회신합니다." 를 굵게 보여 줍니다. + // 켜져 있지만 지정한 기간 밖(시작 전·종료 후)이면 그 아래에 기간을 알려 지금은 보내지 않는다는 점을 덧붙입니다. 입력한 지역 시각은 ISO(UTC)로 바꿔 보냅니다. + const arForm=$('#ar-form');const periodInputs=()=>{const range=arForm.elements.period.value==='range';$$('.period input',arForm).forEach(i=>i.disabled=!range);};$$('[name=period]',arForm).forEach(rd=>rd.onchange=periodInputs); + const arNote=()=>{const a=S.prefs.autoReply||{};$('#ar-on').hidden=!a.enabled;const outside=$('#ar-outside');outside.hidden=!(a.enabled&&!S.autoReplyActive);if(!outside.hidden)outside.textContent=fill('autoReplyOutsideNote',{start:a.start?fullDate(a.start):tr('autoReplyNow'),end:a.end?fullDate(a.end):'-'});};arNote(); + arForm.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',arForm);b.disabled=true;try{const f=arForm.elements;const toIso=v=>{if(!v)return '';const d=new Date(v);return isNaN(d)?'invalid':d.toISOString();}; + const r=await api('prefs','PATCH',{autoReply:{enabled:f.enabled.checked,noLimit:f.period.value==='none',start:toIso(f.start.value),end:toIso(f.end.value),subject:f.subject.value,message:f.message.value}});S.prefs=r.prefs;S.autoReplyActive=!!r.autoReplyActive;f.enabled.checked=r.prefs.autoReply.enabled;arNote();notify('prefSaved');renderSidebar();}catch(err){fail(err);}finally{b.disabled=false;}}; +} +// ------------------------------------------------------------------ 관리자 페이지: 서버 상태 +// 발송 서비스 웹훅·동기화 기록·저장 통계·POP 전달 기록. 0.8.4 부터 웹 메일 설정 화면이 아니라 /admin 에서만 봅니다. +// 0.9.0: 메일 현황(일·주·월 발신/수신/수발신, 메일함별·전체 사용량, 시스템 제한 대비 사용 비율)을 더했습니다. +/** "8개 사용 중 / 최대 10개 (80%)" 또는 제한이 없으면 "8개 사용 중 · 제한 없음". unit: 값을 문구로 바꾸는 함수(개수·바이트). */ +function usageLine(u,unit){if(!u)return '';return u.limit>0?fill('usageOfMax',{used:unit(u.used),max:unit(u.limit),percent:u.percent??0}):fill('usageNoLimit',{used:unit(u.used)});} +const countUnit=n=>fill('countUnit',{n}); +async function statusPage(){ + viewVersion++;showPanes(true);let s;try{s=await api('admin/status');}catch(e){fail(e);return;}const kv=rows=>`
${rows.map(([k,v])=>`
${k}
${v}
`).join('')}
`; + const runs=(s.sync?.runs||[]).map(r=>`${esc(fullDate(r.started_at))}${esc(r.trigger)}${text('status_'+r.status)}${r.imported}${esc(r.detail||'')}`).join(''); + const events=(s.webhook?.events||[]).map(e=>`${text('status_'+e.status)}${e.n}${esc(fullDate(e.last))}`).join(''); + const st=s.stats||{periods:{},mailboxes:[],usage:{}};const per=k=>st.periods[k]||{sent:0,received:0,total:0}; + const periodRows=[['day','statDay'],['week','statWeek'],['month','statMonth']].map(([k,l])=>{const p=per(k);return `${text(l)}
${esc(fullDate(p.start))} ~ ${esc(fullDate(p.end))}
${p.sent}${p.received}${p.total}`;}).join(''); + const pctTag=p=>p===null||p===undefined?'':` ${p}%`; + const boxes=(st.mailboxes||[]).map(m=>`${esc(m.mailbox)}${m.login?`
${esc(m.login)}${m.name?' · '+esc(m.name):''}
`:''}${m.messages}${kb(m.bytes)}${m.limit>0?kb(m.limit)+pctTag(m.percent):text('quotaUnlimited')}`).join(''); + const svc=s.services||{pop:true,imap:true};const onoff=v=>`${text(v?'serviceOn':'serviceOff')}`; + $('#page-pane').innerHTML=`

${text('nav_status')}

${esc(fill('statusIntro',{host:s.host,version:s.version||'-'}))}

+

${text('statsSection')}

${esc(fill('statsIntro',{tz:st.timezone||''}))}

${periodRows}
${text('statPeriod')}${text('statSent')}${text('statReceived')}${text('statTotal')}
+

${text('statLimits')}

${kv([[text('statAccounts'),esc(usageLine(st.usage.accounts,countUnit))],[text('statTotalUsage'),esc(usageLine(st.usage.total,kb))],[text('statMonthly'),esc(usageLine(st.usage.monthly,countUnit))],[text('largeFileHeading'),esc(fill('largeUsage',{count:(s.largeFiles||{}).count||0,size:kb((s.largeFiles||{}).bytes||0)}))]])}

${text('mimeNote')}

+

${text('statMailboxes')}

${boxes?`${boxes}
${text('userMailbox')}${text('statMessages')}${text('usageLabel')}${text('quotaLimit')}
`:`

${text('empty')}

`}
+ ${s.mail?`

${text('statusMailSection')}

${text('mailConfigIntro')}

${kv([[text('statusInbound'),text('mode_'+s.mail.inbound)],[text('statusOutbound'),text('mode_'+s.mail.outbound)],[text('statusOutboundState'),`${s.mail.status&&s.mail.status.provider==='ok'?text('serviceOn'):esc(tr((s.mail.status||{}).provider||'mail_provider_unavailable'))}${s.mail.status&&s.mail.status.relay?` ${esc(s.mail.status.relay.host)}:${esc(s.mail.status.relay.port)}`:''}${s.mail.status&&s.mail.status.provider!=='ok'&&s.mail.status.detail?`
${esc(s.mail.status.detail)}
`:''}`],[text('statusHaraka'),(()=>{const h=s.mail.haraka;if(!h)return `${text('harakaOff')}`;const st=h.status||{};const on=h.installed&&h.status&&!h.stale&&st.running;return `${text(!h.installed?'harakaNotInstalled':!h.status||h.stale?'harakaStopped':st.running?'harakaRunning':'harakaWaiting')}${on?` ${esc((st.listen||[]).join(', '))}`:''}`;})()],[text('statusConfigFile'),`${esc(s.mail.configFile||'')}`]])}
`:''} +

${text('statusServices')}

${kv([[text('statusTls'),`${text(s.tls?'imapTlsActive':'statusTlsMissing')}`],['IMAP',`${s.ports.imaps} / ${s.ports.imap} ${onoff(svc.imap!==false)}`],['POP3',`${s.ports.pop3s} / ${s.ports.pop3} ${onoff(svc.pop!==false)}`],[text('statusPlainLogin'),`${text(s.plainLogin?'popPlainAllowed':'popPlainRequired')}`],['SMTP',`${s.ports.smtps} / ${s.ports.submission}`],[text('popDeliveries'),esc(fill('popDeliveriesValue',{count:s.pop.deliveries.n||0,accounts:s.pop.deliveries.accounts||0,last:fullDate(s.pop.deliveries.last)||'-'}))]])}
+

${text('receiveSettings')}

${text('receiveIntro')}

${kv([[text('webhookUrl'),`${esc(s.webhook.url)}`],[text('webhookEvent'),'email.received'],[text('webhookSecret'),`${text(s.webhook.secretConfigured?'webhookSecretOk':'webhookSecretMissing')}`],[text('syncInterval'),esc(fill('syncIntervalValue',{minutes:s.sync.intervalMinutes}))],[text('retention'),text('retentionValue')],[text('storage'),esc(fill('storageValue',{total:s.storage.total||0,raw:s.storage.raw_stored||0,built:s.storage.raw_built||0,webhook:s.storage.via_webhook||0,sync:s.storage.via_sync||0}))]])}

${text('webhookEvents')}

${events?`${events}
`:`

${text('empty')}

`}

${text('syncRuns')}

${runs?`${runs}
`:`

${text('empty')}

`}
`; + $('#sync-now').onclick=async()=>{try{const r=await api('admin/sync','POST',{});notify(r.started?'syncStarted':'syncing');setTimeout(()=>{if(S.view==='status')statusPage();},6000);}catch(e){fail(e);}}; +} +// ------------------------------------------------------------------ 계정 관리 (관리자) +/** + * 관리자 페이지 "계정 관리". 계정 표(상태에 '승인 대기' 포함, 사용량/최대 크기 열) + 비밀번호 정책 카드 + 0.9.0 설정 카드(회원 가입·메일 제한·서비스). + * 계정 목록(admin/users)과 설정(admin/config)을 함께 읽어 한 화면에 그립니다. + */ +async function adminPage(){ + viewVersion++;showPanes(true);let d,cfg;try{[d,cfg]=await Promise.all([api('admin/users'),api('admin/config')]);}catch(e){fail(e);return;} + // 서비스 열: 계정 설정에서 POP·IMAP 접속을 켰는지. 행을 누르면 계정 수정 창에서 바꿀 수 있습니다. + const svc=x=>`${x.pop!==false?'POP':'POP ✕'} ${x.imap!==false?'IMAP':'IMAP ✕'}`; + const usage=u=>`${kb(u.usageBytes||0)}${u.limit>0?` / ${kb(u.limit)}`:''}`; + const status=u=>u.status==='pending'?`${text('status_pending')}`:text('status_'+u.status); + const accounts=d.accounts||cfg.accounts||{used:d.users.length,limit:0}; + // 웹메이커 계정(1.0.8): d.external 의 행은 읽기 전용(이름·비밀번호·역할은 웹메이커가 관리)이며 POP/IMAP 허용·메일함 크기만 바꿉니다. 파일을 못 읽으면 그 사유를 안내합니다. + const ext=d.external||[],extSrc=d.externalSource||null; + const extRows=ext.map((u,i)=>`${esc(u.login)}${esc(u.name)}${esc(u.mailbox)}${text('role_'+u.role)}${text('webmakerAccount')}${u.status!=='active'?' '+text('status_suspended'):''}${svc(u.services||{})}${usage(u)}${esc(tr('kind_'+u.kind))}`).join(''); + const extNote=extSrc?(extSrc.ok?esc(fill('adminUsersIntroWebmaker',{n:ext.length,site:extSrc.site?extSrc.site.title||extSrc.site.id:tr('webmakerNoSite')})):`${text('webmakerReadFailed')} ${esc(extSrc.error||'')}`):''; + $('#page-pane').innerHTML=`

${text('adminUsers')}

${esc(fill('adminUsersIntro',{domain:d.domain}))}${d.me?' '+esc(text('adminUsersIntroWww')):''}${extNote?' '+extNote:''}

${S.initialPassword?`
⚠ ${text('initialPasswordWarn')}
`:''}
${esc(usageLine(accounts,countUnit))}
${d.me?`${svc(d.me.services||{})}${usage(d.me)}`:''}${extRows}${d.users.map((u,i)=>`${svc(u.services||{})}${usage(u)}`).join('')}
${text('userLogin')}${text('userName')}${text('userMailbox')}${text('userRole')}${text('userStatus')}${text('userServices')}${text('usageLabel')}${text('userLastLogin')}
admin${esc(d.me.name||d.me.login)}${esc(d.defaultMailbox)}${text('role_admin')}${text('wwwAdminNote')}
${esc(u.login)}${esc(u.name)}${esc(u.mailbox)}${text('role_'+u.role)}${status(u)}${esc(fullDate(u.last_login_at))}

${esc(fill('userHelpMailbox',{domain:d.domain}))}

${configCards(cfg)}${policyCard(d.policy||{})}`; + const open=u=>userDialog(u,d.domain,d.policy,d.system||cfg.system||{}); + $('#add-user').onclick=()=>open(null);$$('[data-row]',$('#page-pane')).forEach(r=>{r.onclick=()=>open(r.dataset.row==='me'?{me:true,...d.me}:r.dataset.row.startsWith('ext:')?{external:true,...ext[Number(r.dataset.row.slice(4))]}:d.users[Number(r.dataset.row)]);r.onkeydown=e=>{if(e.key==='Enter')r.click();};}); + // 비밀번호 정책 저장(0.8.24): 최소 길이(6~64)와 대·소문자/숫자/특수문자 요구를 PATCH 하고, 결과로 화면의 정책 안내를 갱신합니다. + const pf=$('#policy-form');pf.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',pf);b.disabled=true;try{const r=await api('admin/policy','PATCH',{minLength:Number(pf.minLength.value),mixedCase:pf.mixedCase.checked,digit:pf.digit.checked,special:pf.special.checked});d.policy=r.policy;$('#policy-now').textContent=passwordRule(r.policy);notify('policySaved');}catch(err){fail(err);}finally{b.disabled=false;}}; + // 설정 카드(0.9.0): 카드마다 자기 부분만 PATCH admin/config 로 보냅니다. 제한 값은 MB 입력을 바이트로 바꿔 보내고, 시스템 제한을 넘으면 서버가 limit_exceeds_system 으로 거부합니다. + const bindConfig=(sel,build)=>{const f=$(sel);if(!f)return;f.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',f);b.disabled=true;try{cfg=await api('admin/config','PATCH',build(f.elements));notify('configSaved');}catch(err){fail(err);}finally{b.disabled=false;}};}; + bindConfig('#signup-form-admin',f=>({signup:{enabled:f.enabled.checked,approval:f.approval.value==='approve'}})); + bindConfig('#limits-form',f=>({limits:{mailboxBytes:toBytes(f.mailboxMb.value),attachmentBytes:toBytes(f.attachmentMb.value),recipients:Math.max(0,Math.round(Number(f.recipients.value)||0))}})); + bindConfig('#services-form',f=>({services:{pop:f.pop.checked,imap:f.imap.checked,plainLogin:f.plainLogin.checked}})); + bindConfig('#large-form',f=>({largeFiles:{enabled:f.enabled.checked,maxBytes:toBytes(f.maxMb.value),maxDays:Math.round(Number(f.maxDays.value)||0),maxDownloads:Math.round(Number(f.maxDownloads.value)||0)}})); +} +/** MB 입력을 바이트로(빈 값·0 = 제한 없음). 소수 입력(1.5)도 받습니다. */ +const toBytes=v=>Math.max(0,Math.round((Number(v)||0)*1048576)); +const fromBytes=b=>b>0?String(Math.round(b/1048576*100)/100):''; +/** 시스템 제한 안내 한 줄: "시스템 제한: 500 MB 이내" 또는 "시스템 제한 없음". 관리자가 넣을 수 있는 최댓값을 알려 줍니다. */ +const sysNote=(v,unit)=>v>0?fill('systemCap',{max:unit(v)}):tr('systemNoCap'); +/** + * 관리자 설정 카드(0.9.0). + * - 회원 가입: 로그인 화면에 가입 단추를 보일지, 가입 계정을 즉시 사용 / 관리자 승인 후 사용 중 무엇으로 할지. + * - 메일 제한: 계정별 기본 메일함 크기, 첨부 한 개 최대 크기(MIME 기준), 한 메일의 받는 사람 수. 각 값은 시스템 제한(설치 시 설정) 이내로만 저장됩니다. 0 = 시스템 값 그대로. + * - 서비스: POP3 / IMAP 서비스 자체를 켜고 끕니다. 끄면 모든 계정의 메일 프로그램 접속이 거부됩니다. + */ +/** 대용량 첨부파일 카드(0.9.3): 사용 여부와 최대 크기(MB)·보관 기간(일)·다운로드 횟수. 모두 1 이상이어야 하고(무제한 불가) 시스템 상한 이내이며, 기본값은 시스템 값의 50% 입니다. */ +function largeCard(c){const lf=c.largeFiles||{},sys=c.systemLargeFiles||{},u=c.largeFileUsage||{count:0,bytes:0}; + return `

${text('largeSection')}

${text('largeAdminIntro')}

${esc(fill('systemCapStrict',{max:kb(sys.bytes||0)}))}

${esc(fill('systemCapStrict',{max:fill('daysUnit',{n:sys.days||0})}))}

${esc(fill('systemCapStrict',{max:fill('timesUnit',{n:sys.downloads||0})}))}

${text('largeAdminHelp')} ${esc(fill('largeUsage',{count:u.count,size:kb(u.bytes)}))}

`;} +function configCards(c){const sys=c.system||{},lim=c.limits||{},sg=c.signup||{},sv=c.services||{}; + return `

${text('signupSection')}

${text('signupAdminIntro')}

${text('signupApprovalLegend')}

${text('signupAdminHelp')}

+

${text('limitsSection')}

${text('limitsIntro')}

${esc(sysNote(sys.mailboxBytes,kb))}

${esc(sysNote(sys.attachmentBytes,kb))}

${esc(sysNote(sys.recipients,countUnit))}

${text('limitsHelp')} ${text('mimeNote')}

+

${text('systemSection')}

${text('systemIntro')}

${[['limit_accounts',sys.accounts>0?countUnit(sys.accounts):tr('noLimit')],['limit_totalBytes',sys.totalBytes>0?kb(sys.totalBytes):tr('noLimit')],['limit_mailboxBytes',sys.mailboxBytes>0?kb(sys.mailboxBytes):tr('noLimit')],['limit_attachmentBytes',sys.attachmentBytes>0?kb(sys.attachmentBytes):tr('noLimit')],['limit_recipients',sys.recipients>0?countUnit(sys.recipients):tr('noLimit')],['limit_monthlyMessages',sys.monthlyMessages>0?countUnit(sys.monthlyMessages):tr('noLimit')]].map(([k,v])=>`
${text(k)}
${esc(v)}
`).join('')}
+ ${largeCard(c)}

${text('servicesAdminSection')}

${text('servicesAdminIntro')}

${text('servicePlainLoginHelp')}

${text('servicesAdminHelp')}

`;} +/** + * 비밀번호 정책(0.8.24~). + * - passwordRule(p): 정책을 한 문장으로("비밀번호는 6자 이상, 숫자 포함이어야 합니다."). 계정 창의 안내문과 정책 카드, weak_password 오류 안내에 같이 씁니다. + * - passwordError(err): 서버가 weak_password 와 함께 보낸 problems(어긋난 항목)로 어느 규칙에 걸렸는지 적습니다. + * - policyCard(p): 관리자 페이지 아래의 정책 편집 카드. 최소 길이는 6 미만으로 내릴 수 없습니다(요구사항: 최소 6자 이상). + */ +function ruleParts(p,only){const parts=[];const want=k=>!only||only.includes(k);if(want('length'))parts.push(fill('passwordRuleLength',{n:p.minLength||6}));if(p.mixedCase&&want('mixedCase'))parts.push(tr('passwordRuleMixedCase'));if(p.digit&&want('digit'))parts.push(tr('passwordRuleDigit'));if(p.special&&want('special'))parts.push(tr('passwordRuleSpecial'));return parts;} +function passwordRule(p){return fill('passwordRule',{rules:ruleParts(p||{}).join(', ')});} +function passwordError(err){if(err.message!=='weak_password'||!err.policy)return errText(err);const parts=ruleParts(err.policy,err.problems||[]);return fill('weakPasswordDetail',{rules:parts.join(', ')||fill('passwordRuleLength',{n:err.policy.minLength})});} +function policyCard(p){return `

${text('policySection')}

${text('policyIntro')}

${text('policyMinLengthHelp')}

${esc(passwordRule(p))}

`;} +/** + * 계정 추가·수정 창. + * - 메일함 주소는 입력받지 않고 "로그인 ID@도메인" 으로 자동 지정됩니다(0.8.4~). 새 계정은 ID 를 입력하는 대로 미리 보여 줍니다. + * - 비밀번호(0.9.0): 다른 계정은 한 번만 입력하고 눈 단추로 확인합니다(예전의 두 번 입력·현재 비밀번호 확인은 설정 화면의 "내 비밀번호 변경" 으로 옮김). + * 자기 계정을 열면 비밀번호 칸 대신 "도구 → 설정에서 바꾸세요" 안내만 보입니다. + * - 메일함 최대 크기(0.9.0): MB 로 입력, 0/빈 값이면 관리자 기본값·시스템 값을 따릅니다. 시스템 제한 이내로만 저장됩니다. + * - 승인 대기 계정(0.9.0): "승인"(상태를 사용으로) / "거절"(계정 삭제) 단추가 보입니다. + * - POP / IMAP 접속 허용을 계정마다 켜고 끌 수 있습니다(서버에는 계정 설정 prefs 로 저장). + * - u.me 는 관리자 자신(www 계정) 행: 이름·비밀번호는 www 사이트에서 관리하므로 접속 허용만 바꿉니다. + */ +function userDialog(u,domain,policy,system){policy=policy||{minLength:6};system=system||{};const edit=!!u,me=!!(u&&u.me),external=!!(u&&u.external);u=u||{role:'user',status:'active',services:{pop:true,imap:true},quotaBytes:0};const sv=u.services||{pop:true,imap:true}; + const self=edit&&!me&&u.id===S.me.id,pending=u.status==='pending'; + $('#dialog-title').textContent=tr(me?'editMyServices':external?'editExternalUser':edit?'editUser':'addUser');$('#dialog-feedback').textContent=''; + const services=`
${text('userServices')}

${text('servicesHelp')}

`; + const quota=`

${esc(sysNote(system.mailboxBytes,kb))}${u.limit>0?' · '+esc(fill('quotaNow',{limit:kb(u.limit)})):''}

`; + $('#dialog-body').innerHTML=me?`

${text('wwwAdminServicesHelp')}

${services}
` + :external?`

${esc(fill('webmakerAccountHelp',{login:u.login,mailbox:u.mailbox}))}

${quota}
${services}
` + :`
${pending?`
${text('pendingNotice')}
`:''}
${self?`

${text('selfPasswordNote')} ${text('selfRoleNote')}

`:pwField('password',text(edit?'userPasswordEdit':'userPassword'),`autocomplete="new-password" ${edit?'':'required'} minlength="${Number(policy.minLength)||6}" title="${esc(passwordRule(policy))}"`)}${quota}

${text('userMailboxAuto')} ${self?'':esc(passwordRule(policy))}

${services}
${pending?``:''}${edit&&!pending?``:''}
`; + const dlg=$('#detail');const form=$('#user-form');$('#cancel-user').onclick=()=>dlg.close(); + // 새 계정: 로그인 ID 를 입력하는 대로 메일함 주소를 "id@도메인" 으로 미리 보여 줍니다(서버도 같은 규칙으로 정함). + if(!edit)form.elements.login.oninput=()=>{$('#user-mailbox').value=form.elements.login.value.trim().toLowerCase()+'@'+domain;}; + bindEyes(form); + const submit=async(extra={})=>{const body=Object.fromEntries(new FormData(form));const payload={services:{pop:form.elements.pop.checked,imap:form.elements.imap.checked},quotaBytes:toBytes(body.quotaMb)};delete body.pop;delete body.imap;delete body.quotaMb; + if(me)await api('admin/users/me','PATCH',payload);else if(external)await api('admin/users/'+encodeURIComponent(u.id),'PATCH',payload);else if(edit){if(!body.password)delete body.password;delete body.login;await api('admin/users/'+u.id,'PATCH',{...body,...payload,...extra});}else await api('admin/users','POST',{...body,...payload});}; + form.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',form);b.disabled=true;$('#dialog-feedback').textContent='';try{await submit();dlg.close();notify('userSaved');adminPage();}catch(err){$('#dialog-feedback').textContent=passwordError(err);}finally{b.disabled=false;}}; + // 승인: 상태를 '사용' 으로 저장(서버가 승인 대기 표시를 지움). 거절: 계정 삭제(메일함의 메일은 남음). + if($('#approve-user'))$('#approve-user').onclick=async()=>{try{form.elements.status.value='active';await submit({status:'active'});dlg.close();notify('userApproved');adminPage();}catch(err){$('#dialog-feedback').textContent=passwordError(err);}}; + if($('#reject-user'))$('#reject-user').onclick=async()=>{if(!confirm(tr('confirmRejectUser')))return;try{await api('admin/users/'+u.id,'DELETE');dlg.close();notify('userRejected');adminPage();}catch(err){$('#dialog-feedback').textContent=errText(err);}}; + if($('#delete-user'))$('#delete-user').onclick=async()=>{if(!confirm(tr('confirmDeleteUser')))return;try{await api('admin/users/'+u.id,'DELETE');dlg.close();adminPage();}catch(err){$('#dialog-feedback').textContent=errText(err);}}; + if(!dlg.open)dlg.showModal(); +} +// 패널 너비 조절: 손잡이를 끌어 왼쪽 메일함·목록 창 너비를 바꾸고 브라우저에 기억합니다. +(function resizers(){const root=document.documentElement;const load=(k,def,min,max)=>{try{const v=Number(localStorage.getItem(k));return v>=min&&v<=max?v:def;}catch{return def;}}; + const state={sidebar:load('mg:sidebar-w',230,160,420),list:load('mg:list-w',400,260,760)};root.style.setProperty('--sidebar-w',state.sidebar+'px');root.style.setProperty('--list-w',state.list+'px'); + const bind=(el,key,min,max,getBase)=>{if(!el)return;let startX=0,base=0;const move=e=>{const x=e.touches?e.touches[0].clientX:e.clientX;const v=Math.min(max,Math.max(min,base+(x-startX)));state[key]=v;root.style.setProperty(key==='sidebar'?'--sidebar-w':'--list-w',v+'px');};const up=()=>{document.body.classList.remove('resizing');el.classList.remove('active');document.removeEventListener('mousemove',move);document.removeEventListener('mouseup',up);document.removeEventListener('touchmove',move);document.removeEventListener('touchend',up);try{localStorage.setItem(key==='sidebar'?'mg:sidebar-w':'mg:list-w',String(state[key]));}catch{}}; + const down=e=>{startX=e.touches?e.touches[0].clientX:e.clientX;base=getBase();document.body.classList.add('resizing');el.classList.add('active');document.addEventListener('mousemove',move);document.addEventListener('mouseup',up);document.addEventListener('touchmove',move,{passive:true});document.addEventListener('touchend',up);e.preventDefault();}; + el.addEventListener('mousedown',down);el.addEventListener('touchstart',down,{passive:false});el.addEventListener('dblclick',()=>{state[key]=key==='sidebar'?230:400;root.style.setProperty(key==='sidebar'?'--sidebar-w':'--list-w',state[key]+'px');try{localStorage.removeItem(key==='sidebar'?'mg:sidebar-w':'mg:list-w');}catch{}});}; + bind($('#rz-sidebar'),'sidebar',160,420,()=>$('#sidebar').getBoundingClientRect().width);bind($('#rz-list'),'list',260,760,()=>$('#list-pane').getBoundingClientRect().width);})(); +setInterval(()=>{if(S.me&&S.view==='mail'&&!$('#compose').open&&!$('#detail').open)renderMail(true);},60000); +start(); diff --git a/modules/arimail/web/favicon.svg b/modules/arimail/web/favicon.svg new file mode 100644 index 0000000..a8a1acd --- /dev/null +++ b/modules/arimail/web/favicon.svg @@ -0,0 +1 @@ + diff --git a/modules/arimail/web/index.html b/modules/arimail/web/index.html new file mode 100644 index 0000000..434ff70 --- /dev/null +++ b/modules/arimail/web/index.html @@ -0,0 +1,14 @@ +__TITLE__ + + +
+

+ + + diff --git a/modules/arimail/web/setup.css b/modules/arimail/web/setup.css new file mode 100644 index 0000000..c53b0ce --- /dev/null +++ b/modules/arimail/web/setup.css @@ -0,0 +1,30 @@ +/* AriMail 메일 서비스 설정 화면(1.0.0). 웹 메일(style.css)과 같은 색·글꼴을 쓰되 이 화면 하나만을 위해 작게 따로 둡니다(전용 포트에서 style.css 를 받지 않음). */ +:root{--brand:#2b64c8;--brand-dark:#1f4c9b;--bg:#f3f5f8;--card:#fff;--text:#1d2733;--muted:#6b7a8c;--line:#dfe5ee;--ok:#1d6b33;--ok-bg:#e3f3e6;--warn:#8d2323;--warn-bg:#fde8e6;--note-bg:#fff8e6;--note-line:#ffe2a8} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo",sans-serif} +a{color:var(--brand)}code{background:#eef2f7;padding:1px 6px;border-radius:6px;font-size:13px;overflow-wrap:anywhere} +.top{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 20px;background:var(--brand-dark);color:#fff}.top .brand{display:flex;align-items:center;gap:12px}.top .logo{font-size:26px}.top .muted{color:#c9d6ee}.top .meta{font-size:12px;color:#c9d6ee} +.top-links a{color:#fff;margin-left:14px;font-size:13px;text-decoration:none;opacity:.9}.top-links a:hover{text-decoration:underline} +main{max-width:980px;margin:18px auto 40px;padding:0 16px;display:flex;flex-direction:column;gap:14px} +.card{background:var(--card);border-radius:14px;padding:18px 20px;box-shadow:0 6px 24px rgba(20,35,60,.08)}.card h2{margin:0 0 6px;font-size:17px}.card h3{margin:16px 0 6px;font-size:14px;color:var(--muted)}.card .intro{margin:0 0 12px;color:var(--muted);font-size:14px} +.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px}.summary .item{background:var(--bg);border-radius:10px;padding:10px 12px}.summary .item .k{font-size:12px;color:var(--muted)}.summary .item .v{font-weight:600;overflow-wrap:anywhere} +.tag{display:inline-block;padding:1px 9px;border-radius:999px;font-size:12px;background:#e8edf3;color:#3a4a60;vertical-align:middle}.tag.ok{background:var(--ok-bg);color:var(--ok)}.tag.warn{background:var(--warn-bg);color:var(--warn)}.tag.off{background:#eceff3;color:#7a879a} +.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px 16px}@media(max-width:640px){.grid{grid-template-columns:1fr}} +label.field{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}label.field input,label.field select{font:inherit;font-size:14px;color:var(--text);padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;min-height:38px}label.field input:focus,label.field select:focus{outline:2px solid #b9cdf3;border-color:var(--brand)} +label.field .help{font-size:12px;color:var(--muted)}label.field .src{font-size:11px;color:#8a97a8}.secret-row{display:flex;gap:6px;align-items:center}.secret-row input{flex:1}.secret-row button{min-height:38px} +.choices{display:flex;flex-direction:column;gap:8px}.choice{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border:1px solid var(--line);border-radius:10px;cursor:pointer}.choice input{margin-top:4px}.choice.selected{border-color:var(--brand);background:#f3f7fe}.choice .t{font-weight:600}.choice .d{font-size:13px;color:var(--muted)} +button{font:inherit;font-size:14px;border:1px solid var(--line);background:#fff;color:var(--text);border-radius:8px;padding:7px 14px;min-height:36px;cursor:pointer}button:hover{background:#f5f8fc}button.primary{background:var(--brand);border-color:var(--brand);color:#fff}button.primary:hover{background:var(--brand-dark)}button.ghost{border-color:transparent;color:var(--muted)}button:disabled{opacity:.5;cursor:default}button.small{min-height:28px;padding:2px 9px;font-size:12px} +.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:14px}.actions .status{font-size:13px;color:var(--muted)} +.note{background:var(--note-bg);border:1px solid var(--note-line);border-radius:10px;padding:10px 12px;font-size:13px;margin:10px 0}.note.warn{background:var(--warn-bg);border-color:#f3c3bf}.note.ok{background:var(--ok-bg);border-color:#bfe3c7} +table.simple{width:100%;border-collapse:collapse;font-size:13px}table.simple th,table.simple td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}table.simple th{color:var(--muted);font-weight:600;font-size:12px}table.simple td.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px} +.copy{display:inline-flex;gap:6px;align-items:center}pre.log{background:#1d2733;color:#d6e0ee;border-radius:10px;padding:10px 12px;font-size:12px;max-height:220px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0 0} +.checklist{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:6px}.checklist li{display:flex;gap:10px;align-items:flex-start;font-size:14px}.checklist .mark{width:22px;flex:none;text-align:center} +.foot{max-width:980px;margin:0 auto 30px;padding:0 16px;font-size:12px}.muted{color:var(--muted)} +.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#1d2733;color:#fff;padding:10px 16px;border-radius:10px;font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.25);z-index:10;max-width:90vw}.toast.error{background:#8d2323} +.steps{counter-reset:s}.card.step h2::before{counter-increment:s;content:counter(s) ". ";color:var(--brand)} +.hint-token{max-width:560px;margin:40px auto} +.checklist.plan li{align-items:flex-start;padding:8px 0;border-bottom:1px solid var(--line)}.checklist.plan li:last-child{border-bottom:0}.checklist.plan .body{flex:1;min-width:0}.checklist.plan .t{font-weight:600}.checklist.plan .d{font-size:13px;color:var(--muted);margin-top:2px;overflow-wrap:anywhere}.plan-input{margin-top:8px;padding:10px 12px;background:var(--bg);border-radius:10px} +table.simple tr.missing td{background:#fff8f7}table.simple tr.ok td{background:#f6fbf7}label.field textarea{font:inherit;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text);padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;resize:vertical;min-height:90px;flex:1} +/* 1.0.7 도메인 카드: 목록 표와 추가·수정 폼 */ +table.domains td{vertical-align:top}table.domains tr.current td{background:#eef4ff}table.domains tr.off td{color:#8a97a8}table.domains .actions-cell{white-space:nowrap}table.domains .actions-cell button{margin:0 4px 4px 0} +.domain-form{margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}.domain-form.editing{background:#fffdf3;border:1px solid var(--note-line);border-radius:10px;padding:12px}.domain-form h3{margin:0 0 8px} +.meta.warn{color:var(--warn)} diff --git a/modules/arimail/web/setup.html b/modules/arimail/web/setup.html new file mode 100644 index 0000000..7cb4294 --- /dev/null +++ b/modules/arimail/web/setup.html @@ -0,0 +1,13 @@ +__TITLE__ + +
AriMail 메일 서비스 설정
__HOST__ · v__VERSION__
+

설정을 읽는 중…

+
AriMail v__VERSION__ · 저장한 값은 재시작 없이 바로 적용됩니다. 설정 파일은 서버의 데이터 디렉터리(config.json)에 있습니다.
+ + + diff --git a/modules/arimail/web/setup.js b/modules/arimail/web/setup.js new file mode 100644 index 0000000..eeef7d9 --- /dev/null +++ b/modules/arimail/web/setup.js @@ -0,0 +1,312 @@ +/** + * AriMail 전체 설정 화면(1.0.7, 요구사항 2) · 메일 서비스 설정 화면(1.0.0, 요구사항 4·6). + * + * 1.0.7 다중 도메인: 전용 포트(token 방식)에서는 맨 위 "도메인" 카드가 서버의 모든 도메인을 보여 주고(추가·중지·삭제·수정), 고른 도메인(DOMAIN)의 설정을 + * 아래 카드들이 다룹니다. 모든 API 호출에 ?domain=<도메인> 을 붙여 서버가 그 도메인의 설정 파일을 읽고 씁니다. admin 방식(/setup)은 자기 도메인만 다룹니다. + * + * 하는 일 + * - /api/setup/config 로 현재 설정(비밀값은 가린 채)·출처·메타(호스트·도메인·웹훅 주소·Haraka 상태·DNS 레코드·발신 상태)를 읽어 카드로 그립니다. + * - 카드마다 "저장" 이 바뀐 값만 PUT 합니다. 비밀값 입력 칸은 비워 두면 "그대로", "비우기" 를 누르면 '' 로 지웁니다. + * - 수·발신 방식에 따라 필요한 카드만 보입니다(Resend / 외부 SMTP 중계 / 내장 Haraka). + * - Haraka 카드: DKIM 키 생성과 DNS 레코드 안내(복사 단추), 서비스 상태(설치·실행·마지막 로그). + * - 마무리 카드: 빠진 항목 점검표, "설치 완료로 표시", systemd 아래면 "서비스 재시작". + * + * 인증 방식(index.html 의 data-mode) + * token : 전용 포트. 주소의 ?token= 을 세션 저장소에 두고(주소에서 지움) X-Setup-Token 헤더로 보냅니다. 없으면 토큰 입력 칸을 보입니다. + * admin : 웹 메일 서버의 /setup. 관리자 세션 쿠키 + X-Mail-Scope: admin. 로그인이 없으면 /admin 으로 안내합니다. + */ +(() => { +const MODE = document.documentElement.dataset.mode || 'token'; +// BASE(1.0.8): 화면의 기준 경로(서버가 data-base 로 심음). 경로 접두사(/mail) 아래의 /setup 에서도 API·링크가 맞게 동작합니다. +const BASE = (document.documentElement.dataset.base || '/').replace(/\/?$/, '/'); +const $ = (s, el = document) => el.querySelector(s), $$ = (s, el = document) => [...el.querySelectorAll(s)]; +const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +const TOKEN_KEY = 'arimail-setup-token'; +let token = ''; +let DOMAIN = '', DOMAINS = null; // 1.0.7: 고른 도메인과 도메인 목록 응답(token 방식에서만) +let EDIT = null; // 도메인 카드의 수정 중인 항목(null 이면 추가 폼) +if (MODE === 'token') { + const u = new URL(location.href); const t = u.searchParams.get('token'); + if (t) { try { sessionStorage.setItem(TOKEN_KEY, t); } catch { /* 저장소를 못 쓰면 메모리로만 */ } u.searchParams.delete('token'); history.replaceState(null, '', u.pathname + (u.search || '')); token = t; } + else { try { token = sessionStorage.getItem(TOKEN_KEY) || ''; } catch { token = ''; } } +} +let toastTimer = null; +function toast(msg, error = false) { const t = $('#toast'); t.textContent = msg; t.className = 'toast' + (error ? ' error' : ''); t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, error ? 7000 : 3500); } +/** API 호출. 인증 헤더는 방식에 따라 다르고, 실패는 Error(코드) 로 던집니다(detail·field 포함). */ +async function api(path, method = 'GET', body) { + const headers = { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(MODE === 'token' ? { 'X-Setup-Token': token } : { 'X-Mail-Scope': 'admin' }) }; + // 1.0.7: 전용 포트에서는 고른 도메인을 ?domain= 으로 알립니다(도메인 등록부 API 자체는 예외). + const q = MODE === 'token' && DOMAIN && !path.startsWith('domains') ? (path.includes('?') ? '&' : '?') + 'domain=' + encodeURIComponent(DOMAIN) : ''; + const r = await fetch(BASE + 'api/setup/' + path + q, { method, headers, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }); + let d = null; try { d = await r.json(); } catch { d = null; } + if (!r.ok || !d || d.ok === false) { const e = new Error((d && d.error) || 'http_' + r.status); e.status = r.status; e.field = d && d.field; e.detail = d && d.detail; throw e; } + return d; +} +/** 로그인 방식 이름표(도메인 표). */ +const AUTH_LABEL = { local: '자체 계정', www: '웹 서비스 연동', webmaker: '웹메이커 연동' }; +const ERRORS = { dns_not_configured: 'DNS 모듈 인증 정보가 없습니다', dns_bad_credentials: 'DNS 인증 정보 형식이 틀립니다', dns_auth_failed: 'DNS 서비스가 인증을 거부했습니다', dns_forbidden: 'DNS 서비스 권한이 없습니다', dns_zone_not_found: '도메인의 DNS 존을 찾지 못했습니다', dns_unavailable: 'DNS 서비스에 연결할 수 없습니다', setup_token_invalid: '접속 토큰이 맞지 않습니다. 배포 출력에 나온 주소(…?token=…)로 다시 여세요.', admin_login_required: '관리자 페이지에서 로그인한 뒤 다시 여세요.', rate_limited: '시도가 너무 많습니다. 15분 뒤 다시 하세요.', validation: '입력 값 형식이 맞지 않습니다', restart_unavailable: 'systemd 서비스로 돌고 있을 때만 재시작할 수 있습니다. 서버에서 직접 재시작하세요.', domain_exists: '이미 등록된 도메인입니다', host_taken: '다른 도메인이 쓰는 호스트 이름입니다', www_db_unreadable: '웹 서비스 DB 파일을 읽을 수 없습니다(경로·권한 확인)', webmaker_dir_unreadable: '웹메이커 데이터 디렉터리의 users.json 을 서비스 계정이 읽을 수 없습니다. "파일을 읽을 계정" 을 적고 deploy/webmaker-access.sh 를 실행하거나 읽기 권한을 주세요.', primary_domain: '기본 도메인은 지울 수 없습니다(환경 파일이 정합니다)', primary_only: '이 작업은 기본 도메인에서만 할 수 있습니다(서버 하나에 키 하나)', domain_not_found: '등록되지 않은 도메인입니다', server_error: '서버 오류가 났습니다', mail_not_configured: '연결 정보가 없습니다', mail_auth_failed: '인증(키·비밀번호)이 거부되었습니다', mail_forbidden: 'Resend 가 요청을 거부했습니다(403) — API 키 권한·도메인 소유 확인', mail_domain_unverified: '발신 도메인이 Resend 에서 검증되지 않았습니다 — 자동 설정 카드의 DNS 레코드 확인', mail_domain_missing: '발신 도메인이 Resend 에 등록되어 있지 않습니다 — 자동 설정 실행', mail_rate_limited: '서비스가 잠시 요청을 제한하고 있습니다', mail_rejected: '서비스가 요청을 거부했습니다', mail_provider_unavailable: '서비스에 연결할 수 없습니다', ok: '정상' }; +const errText = (e) => (ERRORS[e.message] || e.message) + (e.field ? ` (${e.field})` : '') + (e.detail ? ` — ${e.detail}` : ''); +const MODES = { + inbound: { resend: ['Resend 웹훅 + 동기화', 'Resend 가 받은 메일을 웹훅으로 밀어 주고 1시간마다 목록을 맞춥니다(예전 방식). DNS 의 MX 는 Resend 를 가리킵니다.'], haraka: ['내장 Haraka 로 직접 수신', '이 서버의 25 포트가 메일을 받습니다. DNS 의 MX 를 이 서버로 바꾸고 방화벽에서 25 를 열어야 합니다.'] }, + outbound: { resend: ['Resend API', 'Resend 발송 API 로 보냅니다(예전 방식). 도메인이 Resend 에 검증되어 있어야 합니다.'], haraka: ['내장 Haraka 로 직접 발송', '이 서버가 받는 쪽 메일 서버(MX)에 직접 배달합니다. SPF·DKIM DNS 레코드와 역방향 DNS(PTR)가 있어야 스팸으로 분류되지 않습니다.'], smtp: ['외부 SMTP 중계 서버', '회사 메일 서버나 다른 발송 서비스의 SMTP(587/465)로 넘깁니다. 아래 "SMTP 중계 서버" 카드에 접속 정보를 적으세요.'] }, +}; +const FIELD = { + USAGE_API_ALLOW: ['접속 허용 IP', '웹메이커 서버의 IP 또는 CIDR(쉼표 구분, 예: 203.0.113.10, 10.0.0.0/8). 비우면 키만 맞으면 어디서나 허용합니다.'], + RESEND_API_KEY: ['API 키', 'Resend → API Keys 에서 만든 전체 접근(Full access) 키. 서버에만 저장되고 사용자에게 노출되지 않습니다.'], + RESEND_WEBHOOK_SECRET: ['웹훅 서명 비밀키', 'Resend → Webhooks → 아래 주소의 엔드포인트 → Signing Secret(whsec_…). 수신 방식이 Resend 일 때 필요합니다.'], + SMTP_RELAY_HOST: ['서버 주소', '예: smtp.example.com'], SMTP_RELAY_PORT: ['포트', '587(STARTTLS) · 465(TLS) · 25'], SMTP_RELAY_SECURITY: ['암호화', 'starttls = 접속 뒤 STARTTLS · tls = 접속 즉시 TLS · none = 없음(내부망만)'], + SMTP_RELAY_USER: ['로그인 ID', '비우면 인증 없이 보냅니다'], SMTP_RELAY_PASS: ['비밀번호', ''], + HARAKA_PORT: ['수신 포트', '외부에서 메일을 받는 포트(표준 25). 방화벽에서 열어야 합니다.'], HARAKA_RELAY_PORT: ['제출 포트(내부)', '게이트웨이가 Haraka 에 발송을 넘기는 로컬 전용 포트(127.0.0.1). 외부에서 열지 않습니다.'], + HARAKA_HOSTNAME: ['메일 서버 이름', 'EHLO·MX 에 쓰는 이름. 비우면 공개 호스트 이름(웹 메일 주소)과 같습니다. 이 이름의 A 레코드와 역방향(PTR)이 이 서버를 가리켜야 합니다.'], HARAKA_DKIM_SELECTOR: ['DKIM 셀렉터', 'DNS 레코드 이름의 앞부분(기본 arimail). 바꾸면 키를 다시 만들고 DNS 도 바꿔야 합니다.'], + MAIL_FROM_NAME: ['발신 표시 이름', '계정에 이름이 없을 때 보낸 사람 이름으로 쓰는 값'], SITE_TITLE: ['브라우저 탭 제목', '비우면 서비스 이름(도메인 기준, 예: ARIHAKDANG 메일)'], MAILBOX_OWNER_LOGIN: ['기본 메일함 소유 www 관리자', 'www 연동일 때 admin 메일함을 가진 www 관리자(로그인 ID 또는 이메일)'], + RESEND_REGION: ['Resend 지역', '도메인을 등록할 발송 지역. 수신 MX 값도 지역에 따라 다릅니다(한국은 ap-northeast-1 권장).'], + DNS_PROVIDER: ['DNS 서비스', 'none = 자동 적용 없이 레코드만 안내 · google = Google Cloud DNS · route53 = AWS Route 53'], + DNS_GOOGLE_CREDENTIALS: ['서비스 계정 JSON', 'Cloud DNS 관리자 역할을 가진 서비스 계정의 키 파일(JSON) 내용을 그대로 붙여 넣으세요.'], DNS_GOOGLE_PROJECT: ['프로젝트 ID', '비우면 JSON 의 project_id'], DNS_GOOGLE_ZONE: ['존 이름(선택)', '비우면 도메인으로 자동 검색'], + DNS_AWS_ACCESS_KEY_ID: ['AWS Access Key ID', 'Route 53 변경 권한(ListHostedZonesByName, ChangeResourceRecordSets 등)이 있는 IAM 키'], DNS_AWS_SECRET_ACCESS_KEY: ['AWS Secret Access Key', ''], DNS_AWS_HOSTED_ZONE_ID: ['호스팅 존 ID(선택)', '비우면 도메인으로 자동 검색'], + STATS_TIMEZONE: ['통계 시간대', '메일 현황(일·주·월)의 기준 시간대. 예: Asia/Seoul'], SYNC_INTERVAL_MIN: ['Resend 동기화 주기(분)', 'Resend 목록을 다시 훑는 간격(기본 60분)'], +}; +const SRC = { config: '설정 파일', env: '환경 파일 값(아직 설정 파일에 없음)', default: '기본값' }; +let D = null; // 마지막으로 읽은 설정 응답 +let PLAN = null, planBusy = false; // 자동 설정 계획(/api/setup/plan)과 실행 중 표시(1.0.5) +const STATE = { ok: ['✅', '완료', 'ok'], todo: ['▶', '자동 실행 가능', ''], wait: ['⏳', '반영 대기', 'warn'], action: ['✍️', '사용자 작업', 'warn'], error: ['❌', '오류', 'warn'], info: ['ℹ️', '참고', 'off'], skip: ['—', '해당 없음', 'off'] }; +/** 자동 설정 카드 HTML. PLAN 이 없으면 읽기 단추만. */ +function planCard() { + if (!PLAN) return `

자동 설정

고른 수·발신 방식과 도메인에 맞게 Resend 도메인·웹훅, DNS 레코드, 내장 Haraka 준비 상태를 진단하고 할 수 있는 일은 자동으로 합니다.

Resend·DNS 조회를 하므로 몇 초 걸립니다.
`; + const p = PLAN; + const rows = p.steps.filter((s) => s.state !== 'skip').map((s) => { + const [icon, word, cls] = STATE[s.state] || STATE.info; + const inputsHtml = s.input && s.input.length ? `
${s.input.map((f) => field(f.key, { type: f.type === 'number' ? 'number' : 'text', options: f.options || null, value: f.type === 'secret' ? (D.values[f.key] || '') : (D.values[f.key] ?? ''), source: (D.sources || {})[f.key] || 'default', secret: f.type === 'secret', multiline: !!f.multiline })).join('')}
` : ''; + const btns = (s.actions || []).map((a) => ``).join('') + (s.auto && s.state === 'todo' ? `` : ''); + return `
  • ${icon}
    ${esc(s.title)} ${esc(word)}
    ${s.detail ? `
    ${esc(s.detail)}
    ` : ''}${btns ? `
    ${btns}
    ` : ''}${inputsHtml}
  • `; + }).join(''); + const recs = (p.records || []).map((r) => `${r.check && r.check.ok ? '✅' : r.optional ? '◦' : '⚠️'}${esc(r.type)}${esc(r.name)}${esc((r.priority !== undefined ? r.priority + ' ' : '') + r.values.join(' / '))}${esc(r.purpose)}${r.check && !r.check.ok && r.check.found && r.check.found.length ? `
    지금 값: ${esc(r.check.found.join(' / '))}
    ` : ''}`).join(''); + const dnsLine = p.dns.provider === 'none' ? 'DNS 모듈 없음 — 레코드를 직접 넣습니다(아래 "DNS 모듈" 카드에서 Google Cloud DNS / Route 53 을 연결하면 자동 적용).' : `DNS 모듈: ${p.dns.provider}${p.dns.zone ? ` (존 ${p.dns.zone})` : ''}${!p.dns.configured ? ' — 인증 정보 없음' : ''}${p.dns.error ? ` — 오류: ${ERRORS[p.dns.error] || p.dns.error}` : ''}`; + return `

    자동 설정

    수신 ${esc(MODES.inbound[p.inbound][0])} · 발신 ${esc(MODES.outbound[p.outbound][0])} · 도메인 ${esc(p.domain)} · 메일 서버 이름 ${esc(p.host)}

    +
    ${esc(p.readyMessage)}
    진단 ${esc(new Date(p.checkedAt).toLocaleString('ko-KR'))}
    +
      ${rows}
    +
    ${planBusy ? '실행 중…' : '자동 실행 가능한 단계(▶)를 순서대로 처리하고 다시 진단합니다.'}
    +

    필요한 DNS 레코드 (${esc(p.domain)})

    ${esc(dnsLine)} 공개 DNS(8.8.8.8 / 1.1.1.1) 기준으로 반영 여부를 봅니다.

    + ${recs ? `${recs}
    종류이름값용도
    ` : '

    이 구성에는 추가할 DNS 레코드가 없습니다.

    '} + ${PLAN.log && PLAN.log.length ? `

    마지막 실행 기록

    ${esc(PLAN.log.map((l) => `${l.id}: ${l.result}${l.detail ? ' — ' + l.detail : ''}`).join('\n'))}
    ` : ''}
    `; +} +/** + * 도메인 카드(1.0.7, 요구사항 1·2): 서버의 모든 도메인 목록과 추가·수정 폼. 전용 포트(token)에서만 보입니다. + * 행의 "선택" 이 아래 카드들의 대상 도메인(DOMAIN)을 바꾸고, "중지/사용" 은 그 도메인의 모든 접속(웹·메일 프로그램·수신)을 막거나 엽니다. + */ +function domainsCard() { + if (MODE !== 'token' || !DOMAINS) return ''; + const rows = DOMAINS.domains.map((d) => { + const cur = d.domain === DOMAIN; + const btns = [``, ``]; + if (!d.primary) btns.push(``, ``); + return `${esc(d.domain)}${d.primary ? ' 기본' : ''}${d.note ? `
    ${esc(d.note)}
    ` : ''}${d.hosts.map(esc).join('
    ')}${d.enabled ? tag(true, '사용', '') : tag(false, '', '중지')}${esc(AUTH_LABEL[d.auth.mode] || d.auth.mode)}${d.auth.mode === 'www' ? `
    ${esc(d.auth.wwwDbFile)}
    ` : ''}${d.auth.mode === 'webmaker' ? `
    ${esc(d.auth.webmakerDir)}${d.auth.webmakerUser ? ` (sudo -u ${esc(d.auth.webmakerUser)})` : ''}
    ${d.external && d.external.error ? `
    읽기 실패: ${esc(d.external.error)}
    ` : ''}` : ''}${esc(MODES.inbound[d.inbound] ? MODES.inbound[d.inbound][0] : d.inbound)} / ${esc(MODES.outbound[d.outbound] ? MODES.outbound[d.outbound][0] : d.outbound)}${d.accounts}명${d.external && !d.external.error ? ` + 웹메이커 ${d.external.count}명` : ''} · ${d.messages}통${d.initialPassword ? `
    초기 관리자 ${esc(d.mailbox.split('@')[0])} / ${esc(d.initialPassword)}
    ` : ''}${btns.join('')}`; + }).join(''); + const e = EDIT, editing = !!e, isPrimaryEdit = editing && e.primary; + const form = `

    ${editing ? `도메인 수정 — ${esc(e.domain)}` : '도메인 추가'}

    + ${editing ? '' : ``} + + ${isPrimaryEdit ? '' : ` + + + + + + + ${editing ? '' : ` + `}`} + +
    ${editing ? '' : ''}
    `; + return `

    도메인 ${DOMAINS.domains.length}개

    이 서버가 메일을 다루는 도메인입니다. 도메인마다 계정·메일·설정이 따로 있고, 웹 메일은 각 호스트 이름으로, 메일 프로그램은 ${esc(DOMAINS.serverHost || '')} 에 아이디@도메인 으로 접속합니다. 아래 카드는 선택한 도메인의 설정입니다.

    + ${rows}
    도메인웹 메일 호스트상태로그인수신 / 발신계정 · 메일
    ${form}
    `; +} +/** + * 사용량 API 카드(1.0.9, 요구사항 1): 웹메이커 같은 외부 시스템이 /api/usage 로 디스크 사용량·발송량을 읽을 수 있게 비밀 키를 만들고(한 번만 표시), 허용 IP 를 정합니다. + * 키는 서버 하나에 하나(기본 도메인 설정)이므로 기본 도메인을 보고 있을 때만 그립니다. 새로 만든 키는 USAGE_KEY_SHOWN 에 잠시 두어 화면에 한 번 보여 줍니다. + */ +let USAGE_KEY_SHOWN = null; +function usageCard() { + const m = D.meta, u = m.usageApi; if (!u || !m.primary) return ''; + const v = D.values, s = D.sources; + // 새 키는 이번 그리기에만 보이고 다음 그리기(다른 저장 등)부터는 사라집니다 — "지금만 보입니다" 그대로. + const shown = USAGE_KEY_SHOWN; USAGE_KEY_SHOWN = null; + const curl = `curl -H 'Authorization: Bearer <키>' '${u.endpoint}?days=30'`; + return `

    사용량 API ${u.enabled ? '사용 중' : '꺼짐'}

    +

    웹메이커 등 외부 관리 시스템이 도메인·계정별 디스크 사용량·메일 수·발송량(일/주/월·일별 추이)을 읽는 읽기 전용 API 입니다. 비밀 키(Bearer)와 접속 IP 제한으로 보호되며 메일 내용·비밀번호·API 키는 나가지 않습니다. 사용 방법은 저장소의 docs/USAGE-API.md.

    +
    주소
    ${esc(u.endpoint)} (도메인 하나: /api/usage/<도메인>)
    +
    키
    ${u.enabled ? `${esc(u.keyMasked)}${u.updatedAt ? ` (${esc(new Date(u.updatedAt).toLocaleString('ko-KR'))} 생성)` : ''}` : '없음 — 아래 단추로 만들면 API 가 켜집니다'}
    + ${shown ? `
    새 키(지금만 보입니다 — 웹메이커 설정에 복사해 두세요):
    ${esc(shown)}

    예: ${esc(curl.replace('<키>', shown))}

    ` : ''} +
    ${u.enabled ? '' : ''}
    +
    ${field('USAGE_API_ALLOW', { value: v.USAGE_API_ALLOW || '', source: s.USAGE_API_ALLOW, placeholder: '203.0.113.10, 10.0.0.0/8' })}
    `; +} +/** DNS 모듈 카드(요구사항 1): 서비스 선택과 인증 정보, 연결 확인. */ +function dnsCard() { + const v = D.values, s = D.sources, prov = v.DNS_PROVIDER || 'none'; + return `

    DNS 모듈

    도메인의 DNS 를 Google Cloud DNS 나 AWS Route 53 으로 관리하면 인증 정보를 넣어 두세요. 자동 설정이 필요한 레코드를 직접 넣고 반영을 확인합니다. 다른 DNS 서비스는 "none" 으로 두고 위 표의 레코드를 직접 넣으세요.

    +
    ${field('DNS_PROVIDER', { options: ['none', 'google', 'route53'], value: prov, source: s.DNS_PROVIDER })}${prov === 'google' ? field('DNS_GOOGLE_CREDENTIALS', { secret: true, multiline: true, value: v.DNS_GOOGLE_CREDENTIALS, source: s.DNS_GOOGLE_CREDENTIALS }) + field('DNS_GOOGLE_PROJECT', { value: v.DNS_GOOGLE_PROJECT, source: s.DNS_GOOGLE_PROJECT }) + field('DNS_GOOGLE_ZONE', { value: v.DNS_GOOGLE_ZONE, source: s.DNS_GOOGLE_ZONE }) : ''}${prov === 'route53' ? field('DNS_AWS_ACCESS_KEY_ID', { value: v.DNS_AWS_ACCESS_KEY_ID, source: s.DNS_AWS_ACCESS_KEY_ID }) + field('DNS_AWS_SECRET_ACCESS_KEY', { secret: true, value: v.DNS_AWS_SECRET_ACCESS_KEY, source: s.DNS_AWS_SECRET_ACCESS_KEY }) + field('DNS_AWS_HOSTED_ZONE_ID', { value: v.DNS_AWS_HOSTED_ZONE_ID, source: s.DNS_AWS_HOSTED_ZONE_ID }) : ''}
    +
    ${prov !== 'none' ? `` : ''}
    `; +} + +function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) { + const [label, help] = FIELD[key] || [key, '']; + const src = `${esc(SRC[source] || source)}`; + if (secret && multiline) return ``; + if (secret) return ``; + if (options) return ``; + return ``; +} +const tag = (ok, onText, offText, cls = ok ? 'ok' : 'warn') => `${esc(ok ? onText : offText)}`; +const statusTag = (st) => !st ? tag(false, '', '확인 중') : st.provider === 'ok' ? tag(true, '정상', '') : `${esc(ERRORS[st.provider] || st.provider)}`; + +/** 전체 화면 그리기. D(설정 응답)를 기준으로 카드를 만들고 이벤트를 붙입니다. */ +function render() { + const m = D.meta, v = D.values, s = D.sources, p = D.present; + $('#top-meta').textContent = `${m.host} · ${m.mailbox} · v${m.version || ''}${MODE === 'token' && DOMAINS && DOMAINS.domains.length > 1 ? ` · 도메인 ${DOMAINS.domains.length}개` : ''}`; + $('#top-links').innerHTML = `웹 메일 ↗관리자 페이지 ↗`; + const h = m.haraka || {}, hs = h.status || {}; + const harakaTag = !m.usesHaraka ? tag(false, '', '사용 안 함', 'off') : !h.installed ? tag(false, '', '미설치') : !h.status || h.stale ? tag(false, '', '서비스 꺼짐') : hs.running ? tag(true, '실행 중', '') : tag(false, '', '시작 대기'); + const choices = (name, current) => `
    ${Object.entries(MODES[name]).map(([k, [t, d]]) => ``).join('')}
    `; + const missing = checklist(D); + $('#main').innerHTML = ` +${domainsCard()} +

    현재 상태${MODE === 'token' ? ` — ${esc(m.domain)}${m.primary ? ' 기본 도메인' : ''}` : ''}

    +
    수신
    ${esc(MODES.inbound[m.inbound][0])}
    +
    발신
    ${esc(MODES.outbound[m.outbound][0])}
    +
    발신 서비스 연결
    ${statusTag(m.outboundStatus)}
    ${m.outboundStatus && m.outboundStatus.detail ? `
    ${esc(m.outboundStatus.detail)}
    ` : ''}${m.outboundStatus && m.outboundStatus.domain ? `
    Resend 도메인 ${esc(m.outboundStatus.domain.name)}: ${esc(m.outboundStatus.domain.status)}
    ` : ''}
    +
    내장 Haraka
    ${harakaTag}
    +
    설정 파일
    ${esc(D.file)}
    ${D.updatedAt ? '마지막 저장 ' + esc(new Date(D.updatedAt).toLocaleString('ko-KR')) : ''}${D.importedFromEnv && D.importedFromEnv.length ? ` · 환경 파일에서 가져온 값 ${D.importedFromEnv.length}개` : ''}
    +
    초기 설정
    ${tag(v.SETUP_DONE === true, '완료 표시됨', '진행 중', v.SETUP_DONE === true ? 'ok' : '')}
    +
    +
    +

    메일을 받고 보내는 방식

    받는 길과 보내는 길을 따로 고를 수 있습니다. 예: 수신은 내장 Haraka, 발신은 Resend. 저장하면 바로 적용되며, Haraka 를 고르면 별도 서비스(haraka)가 설정을 보고 스스로 뜹니다.

    +

    수신

    ${choices('inbound', m.inbound)}

    발신

    ${choices('outbound', m.outbound)}
    +${planCard()} +${dnsCard()} +${usageCard()} +${m.usesResend ? `

    Resend

    Resend 를 ${m.inbound === 'resend' && m.outbound === 'resend' ? '수신과 발신에' : m.inbound === 'resend' ? '수신에' : '발신에'}씁니다. 도메인 ${esc(m.domain)} 이 Resend 에 등록·검증되어 있어야 합니다.

    +
    ${field('RESEND_API_KEY', { secret: true, value: v.RESEND_API_KEY, source: s.RESEND_API_KEY })}${field('RESEND_REGION', { options: ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'], value: v.RESEND_REGION || 'ap-northeast-1', source: s.RESEND_REGION })}${m.inbound === 'resend' ? field('RESEND_WEBHOOK_SECRET', { secret: true, value: v.RESEND_WEBHOOK_SECRET, source: s.RESEND_WEBHOOK_SECRET }) : ''}
    + ${m.inbound === 'resend' ? `
    웹훅 엔드포인트: ${esc(m.webhookUrl)} · 이벤트 email.received
    ` : ''} +
    ` : ''} +${m.outbound === 'smtp' ? `

    SMTP 중계 서버

    보내는 메일을 넘길 외부 SMTP 서버입니다. 보낸 사람 주소(${esc(m.domain)})를 그 서버가 허용해야 합니다.

    +
    ${field('SMTP_RELAY_HOST', { value: v.SMTP_RELAY_HOST, source: s.SMTP_RELAY_HOST, placeholder: 'smtp.example.com' })}${field('SMTP_RELAY_PORT', { type: 'number', value: v.SMTP_RELAY_PORT, source: s.SMTP_RELAY_PORT })}${field('SMTP_RELAY_SECURITY', { options: ['starttls', 'tls', 'none'], value: v.SMTP_RELAY_SECURITY || 'starttls', source: s.SMTP_RELAY_SECURITY })}${field('SMTP_RELAY_USER', { value: v.SMTP_RELAY_USER, source: s.SMTP_RELAY_USER })}${field('SMTP_RELAY_PASS', { secret: true, value: v.SMTP_RELAY_PASS, source: s.SMTP_RELAY_PASS })}
    +
    ` : ''} +${m.usesHaraka ? `

    내장 Haraka

    Haraka 는 이 서버에서 함께 도는 메일 서버(MTA)입니다. 게이트웨이와는 별도 systemd 서비스로 돌며, 여기 설정을 보고 자기 설정 파일을 만들어 스스로 켜고 끕니다.

    + ${!h.installed ? `
    Haraka 가 설치되어 있지 않습니다. 서버에서 한 번 실행하세요(root): sudo bash ${esc((h.dir || '').replace(/\/haraka$/, ''))}/repo/deploy/haraka-apply.sh — 서비스 등록과 npm 설치(Haraka ${esc(hs.version || '3.x')})를 합니다. 설치 뒤 이 화면을 새로 고치세요.
    ` : !h.status || h.stale ? `
    Haraka 관리 서비스가 돌고 있지 않습니다. sudo systemctl start <서비스 이름>-haraka 또는 deploy/haraka-apply.sh 를 다시 실행하세요.
    ` : hs.running ? `
    실행 중 (pid ${esc(hs.pid)}, ${esc(hs.startedAt ? new Date(hs.startedAt).toLocaleString('ko-KR') : '')} 부터) · 대기 포트: ${esc((hs.listen || []).join(', '))}${hs.restarts ? ` · 재시작 ${esc(hs.restarts)}회` : ''}
    ` : `
    ${hs.enabled ? '시작을 준비하고 있습니다…' : '설정이 저장되면 몇 초 안에 시작합니다.'}${hs.lastExit ? ` 마지막 종료: 코드 ${esc(hs.lastExit.code)} (${esc(hs.lastExit.at)})` : ''}
    `} + ${m.primary ? '' : `
    Haraka 는 서버에 하나이므로 수신 포트·제출 포트·메일 서버 이름은 기본 도메인의 설정을 따릅니다. 이 도메인에서는 DKIM 셀렉터만 정합니다.
    `} +
    ${m.primary ? field('HARAKA_PORT', { type: 'number', value: v.HARAKA_PORT, source: s.HARAKA_PORT }) + field('HARAKA_RELAY_PORT', { type: 'number', value: v.HARAKA_RELAY_PORT, source: s.HARAKA_RELAY_PORT }) + field('HARAKA_HOSTNAME', { value: v.HARAKA_HOSTNAME, source: s.HARAKA_HOSTNAME, placeholder: m.host }) : ''}${field('HARAKA_DKIM_SELECTOR', { value: v.HARAKA_DKIM_SELECTOR, source: s.HARAKA_DKIM_SELECTOR })}
    +
    +

    DNS 와 발신 준비

    ${m.inbound === 'haraka' ? 'MX 를 이 서버로 바꾸면 그때부터 메일이 이 서버로 옵니다(이전 MX 의 메일은 더 오지 않습니다). ' : ''}${m.outbound === 'haraka' ? 'SPF·DKIM 이 없으면 보낸 메일이 스팸으로 분류되기 쉽습니다. 서버 IP 의 역방향 DNS(PTR)가 메일 서버 이름을 가리키게 호스팅 업체에 요청하세요. ' : ''}필요한 레코드와 반영 상태는 위 "자동 설정" 카드에 있습니다.

    + ${m.outbound === 'haraka' ? `
    ${p.HARAKA_DKIM_PRIVATE ? `DKIM 키 있음` : `자동 설정 실행이 대신 만들어 줍니다`}
    ` : ''} +

    서비스 로그(최근)

    ${hs.lastLog && hs.lastLog.length ? `
    ${esc(hs.lastLog.join('\n'))}
    ` : `

    아직 기록이 없습니다. 서버에서 journalctl -u <서비스 이름>-haraka -f 로 볼 수 있습니다.

    `} +
    ` : ''} +

    사이트 정보

    배포에는 필요 없지만 운영 중 바꿀 수 있는 값입니다.

    +
    ${field('MAIL_FROM_NAME', { value: v.MAIL_FROM_NAME, source: s.MAIL_FROM_NAME })}${field('SITE_TITLE', { value: v.SITE_TITLE, source: s.SITE_TITLE })}${m.loginMode === 'www' ? field('MAILBOX_OWNER_LOGIN', { value: v.MAILBOX_OWNER_LOGIN, source: s.MAILBOX_OWNER_LOGIN }) : ''}${field('STATS_TIMEZONE', { value: v.STATS_TIMEZONE || 'Asia/Seoul', source: s.STATS_TIMEZONE })}${m.usesResend ? field('SYNC_INTERVAL_MIN', { type: 'number', value: Math.round(Number(v.SYNC_INTERVAL_MS || 3600000) / 60000), source: s.SYNC_INTERVAL_MS }) : ''}
    +
    +

    마무리

    +
      ${missing.map((c) => `
    • ${c.ok ? '✅' : '⚠️'}${esc(c.text)}
    • `).join('')}
    +
    ${v.SETUP_DONE === true ? `` : ``}${m.systemd ? `` : ''}저장한 설정은 재시작 없이 적용됩니다. 재시작은 포트(SETUP_PORT 등 환경 파일) 변경 뒤에만 필요합니다.
    +

    ${MODE === 'token' ? '이 화면은 전용 포트에서 토큰으로 열었습니다. 운영 중에는 관리자 페이지(/admin) → "메일 서비스 설정" 으로도 같은 화면을 열 수 있습니다.' : '이 화면은 관리자 세션으로 열었습니다. 설치 직후에는 배포 출력에 나온 전용 포트 주소(…:' + esc(m.setupPort) + '/?token=…)로도 열 수 있습니다.'}

    +
    `; + bind(); +} +/** 점검표: 고른 방식에 꼭 필요한 값이 있는지. */ +function checklist(d) { + const m = d.meta, v = d.values, p = d.present, out = []; + if (m.usesResend) { out.push({ ok: !!v.RESEND_API_KEY, required: true, text: 'Resend API 키' + (v.RESEND_API_KEY ? '' : ' — 없음') }); if (m.inbound === 'resend') out.push({ ok: !!v.RESEND_WEBHOOK_SECRET, required: true, text: 'Resend 웹훅 서명 비밀키' + (v.RESEND_WEBHOOK_SECRET ? '' : ' — 없음(웹훅 거부, 1시간 동기화만 동작)') }); } + if (m.outbound === 'smtp') out.push({ ok: !!v.SMTP_RELAY_HOST, required: true, text: 'SMTP 중계 서버 주소' + (v.SMTP_RELAY_HOST ? ` — ${v.SMTP_RELAY_HOST}:${v.SMTP_RELAY_PORT}` : ' — 없음') }); + if (m.usesHaraka) { const h = m.haraka || {}; out.push({ ok: !!h.installed, required: true, text: 'Haraka 설치' + (h.installed ? '' : ' — deploy/haraka-apply.sh 실행 필요') }); out.push({ ok: !!(h.status && !h.stale && h.status.running), required: true, text: 'Haraka 실행' }); if (m.outbound === 'haraka') out.push({ ok: !!p.HARAKA_DKIM_PRIVATE, required: false, text: 'DKIM 키' + (p.HARAKA_DKIM_PRIVATE ? '(DNS 에 TXT 레코드를 올렸는지 확인)' : ' — 아직 만들지 않음') }); if (m.inbound === 'haraka') out.push({ ok: true, required: false, text: `DNS MX → ${m.harakaHost} 로 바꾸고 방화벽 ${v.HARAKA_PORT || 25} 포트 열기(수동 확인)` }); } + // 1.0.11: 서버가 만든 원인 문장(detail — 예: 어느 DNS 레코드 값이 다른지)을 함께 보입니다. + if (m.outboundStatus) out.push({ ok: m.outboundStatus.provider === 'ok', required: false, text: '발신 서비스 연결 확인' + (m.outboundStatus.provider === 'ok' ? '' : ' — ' + (ERRORS[m.outboundStatus.provider] || m.outboundStatus.provider) + (m.outboundStatus.detail ? ` (${m.outboundStatus.detail})` : '')) }); + return out; +} +/** 폼에서 바뀐 값만 모읍니다. 비밀 칸은 입력했을 때만, "비우기" 를 눌렀으면 '' 로. */ +function collect(form) { + const patch = {}; + for (const el of form.elements) { + if (!el.name) continue; + if (el.type === 'radio') { if (el.checked) patch[el.name] = el.value; continue; } + if (el.dataset.secret) { if (el.dataset.clear === '1') patch[el.name] = ''; else if (el.value) patch[el.name] = el.value; continue; } + if (el.name === 'SYNC_INTERVAL_MIN') { patch.SYNC_INTERVAL_MS = String(Math.max(1, Number(el.value) || 60) * 60000); continue; } + patch[el.name] = el.value; + } + return patch; +} +async function save(form, patch) { + const btn = $('button[type=submit]', form), st = $('.status', form); if (btn) btn.disabled = true; if (st) st.textContent = '저장 중…'; + try { const before = D.meta.inbound + D.meta.outbound; D = await api('config', 'PUT', { values: patch }); if (PLAN && (before !== D.meta.inbound + D.meta.outbound || patch.DNS_PROVIDER !== undefined)) PLAN = null; toast('저장했습니다'); render(); } + catch (e) { if (st) st.textContent = errText(e); toast(errText(e), true); if (btn) btn.disabled = false; } +} +/** 계획 API 호출 뒤 카드 다시 그리기. */ +async function planCall(fn) { + planBusy = true; render(); + try { const r = await fn(); PLAN = r.plan; PLAN.log = r.log || []; D = await api('config'); } + catch (e) { toast(errText(e), true); } + finally { planBusy = false; render(); if ($('#card-plan')) $('#card-plan').scrollIntoView({ block: 'start', behavior: 'smooth' }); } +} +/** 도메인 카드의 동작(1.0.7): 선택·수정·중지/사용·삭제·추가. 목록을 다시 읽고 화면을 그립니다. */ +function bindDomains() { + const f = $('#f-domain'); if (!f) return; + const refresh = async () => { DOMAINS = await api('domains'); if (!DOMAINS.domains.some((d) => d.domain === DOMAIN)) DOMAIN = DOMAINS.primary; D = await api('config'); PLAN = null; render(); }; + // 기본 도메인을 수정할 때는 로그인 방식 칸이 없습니다(환경 파일이 정함) — 없으면 건너뜁니다. + const syncAuth = () => { if (!f.elements.authMode) return; const mode = f.elements.authMode.value; $$('.www-only', f).forEach((el) => { el.hidden = mode !== 'www'; }); $$('.webmaker-only', f).forEach((el) => { el.hidden = mode !== 'webmaker'; }); $$('.local-only', f).forEach((el) => { el.hidden = mode !== 'local'; }); }; + if (f.elements.authMode) f.elements.authMode.onchange = syncAuth; syncAuth(); + f.onsubmit = async (e) => { + e.preventDefault(); const st = $('.status', f), btn = $('button[type=submit]', f); btn.disabled = true; st.textContent = '처리 중…'; + const body = { hosts: f.elements.hosts.value, note: f.elements.note.value, ...(f.elements.authMode ? { auth: { mode: f.elements.authMode.value, wwwDbFile: f.elements.wwwDbFile ? f.elements.wwwDbFile.value : '', ownerLogin: f.elements.ownerLogin ? f.elements.ownerLogin.value : '', webmakerDir: f.elements.webmakerDir ? f.elements.webmakerDir.value : '', webmakerUser: f.elements.webmakerUser ? f.elements.webmakerUser.value : '', siteId: f.elements.siteId ? f.elements.siteId.value : '' } } : {}) }; + try { + if (EDIT) { await api('domains/' + encodeURIComponent(EDIT.domain), 'PATCH', body); toast('도메인 정보를 저장했습니다'); EDIT = null; } + else { + const r = await api('domains', 'POST', { ...body, domain: f.elements.domain.value.trim().toLowerCase(), mailbox: f.elements.mailbox.value.trim().toLowerCase(), adminPassword: f.elements.adminPassword.value, copyFromPrimary: f.elements.copyFromPrimary.value === '1' }); + DOMAIN = r.domain.domain; + toast(r.initialAdmin ? `도메인 ${r.domain.domain} 을 추가했습니다. 초기 관리자 ${r.initialAdmin.login} / ${r.initialAdmin.password}` : `도메인 ${r.domain.domain} 을 추가했습니다`); + } + await refresh(); + } catch (err) { st.textContent = errText(err); toast(errText(err), true); btn.disabled = false; } + }; + if ($('#domain-cancel')) $('#domain-cancel').onclick = () => { EDIT = null; render(); }; + $$('[data-domain-select]').forEach((b) => { b.onclick = async () => { DOMAIN = b.dataset.domainSelect; EDIT = null; try { D = await api('config'); PLAN = null; render(); } catch (err) { toast(errText(err), true); } }; }); + $$('[data-domain-edit]').forEach((b) => { b.onclick = () => { EDIT = DOMAINS.domains.find((d) => d.domain === b.dataset.domainEdit) || null; render(); $('#f-domain').scrollIntoView({ block: 'start', behavior: 'smooth' }); }; }); + $$('[data-domain-toggle]').forEach((b) => { b.onclick = async () => { const on = b.dataset.enabled === '1'; if (on && !confirm(`${b.dataset.domainToggle} 의 메일 서비스를 중지합니다. 웹 메일·메일 프로그램 로그인과 수신이 모두 막히고 화면에는 "활성화되지 않았습니다" 가 보입니다. 계속할까요?`)) return; try { await api('domains/' + encodeURIComponent(b.dataset.domainToggle), 'PATCH', { enabled: !on }); toast(on ? '중지했습니다' : '다시 사용합니다'); await refresh(); } catch (err) { toast(errText(err), true); } }; }); + $$('[data-domain-remove]').forEach((b) => { b.onclick = async () => { if (!confirm(`${b.dataset.domainRemove} 을 등록에서 지웁니다. 데이터 디렉터리(domains/${b.dataset.domainRemove})는 서버에 남으며, 지우려면 서버에서 직접 삭제합니다. 계속할까요?`)) return; try { await api('domains/' + encodeURIComponent(b.dataset.domainRemove), 'DELETE'); toast('등록을 지웠습니다'); await refresh(); } catch (err) { toast(errText(err), true); } }; }); +} +function bind() { + bindDomains(); + $$('form:not(.plan-input):not(#f-domain)').forEach((f) => { f.onsubmit = (e) => { e.preventDefault(); save(f, collect(f)); }; }); + // 자동 설정(1.0.5): 진단·실행·단계 단추·입력 저장 + if ($('#plan-load')) $('#plan-load').onclick = () => planCall(async () => ({ plan: (await api('plan')).plan, log: [] })); + if ($('#plan-reload')) $('#plan-reload').onclick = () => planCall(async () => ({ plan: (await api('plan')).plan, log: PLAN && PLAN.log })); + if ($('#plan-run')) $('#plan-run').onclick = () => planCall(() => api('plan/run', 'POST', {})); + $$('[data-plan-run]').forEach((b) => { b.onclick = () => planCall(() => api('plan/run', 'POST', { step: b.dataset.planRun })); }); + $$('[data-plan-action]').forEach((b) => { b.onclick = () => planCall(() => api('plan/run', 'POST', { step: b.dataset.step, action: b.dataset.planAction })); }); + $$('form.plan-input').forEach((f) => { f.onsubmit = (e) => { e.preventDefault(); planCall(() => api('plan/run', 'POST', { step: f.dataset.step, input: collect(f) })); }; }); + // 사용량 API 키(1.0.9): 만들기(교체) / 삭제. 만든 키는 응답에만 있으므로 화면에 한 번 보여 주고 다음 그리기부터는 가린 값만 남습니다. + if ($('#usage-generate')) $('#usage-generate').onclick = async (e) => { const btn = e.currentTarget; if (D.meta.usageApi.enabled && !confirm('기존 키는 바로 무효가 됩니다. 웹메이커 쪽 설정도 새 키로 바꿔야 합니다. 계속할까요?')) return; btn.disabled = true; try { const r = await api('usage-key', 'POST', { action: 'generate' }); USAGE_KEY_SHOWN = r.key; D = await api('config'); render(); toast('사용량 API 키를 만들었습니다 — 지금 복사하세요'); $('#card-usage').scrollIntoView({ block: 'start', behavior: 'smooth' }); } catch (err) { toast(errText(err), true); btn.disabled = false; } }; + if ($('#usage-revoke')) $('#usage-revoke').onclick = async () => { if (!confirm('사용량 API 키를 지워 API 를 끕니다. 웹메이커의 사용량 조회가 멈춥니다. 계속할까요?')) return; try { await api('usage-key', 'POST', { action: 'revoke' }); USAGE_KEY_SHOWN = null; D = await api('config'); render(); toast('키를 지웠습니다'); } catch (err) { toast(errText(err), true); } }; + if ($('#dns-probe')) $('#dns-probe').onclick = async (e) => { e.currentTarget.disabled = true; try { const r = await api('dns/probe', 'POST', {}); toast(r.probe.ok ? `연결 정상 — 존 ${r.probe.zone}` : (ERRORS[r.probe.error] || r.probe.error) + (r.probe.detail ? ' — ' + r.probe.detail : ''), !r.probe.ok); } catch (err) { toast(errText(err), true); } finally { e.target.disabled = false; } }; + $$('.choice input').forEach((r) => { r.onchange = () => { $$('.choice', r.closest('.choices')).forEach((c) => c.classList.toggle('selected', $('input', c).checked)); }; }); + $$('[data-clear]').forEach((b) => { b.onclick = () => { const input = $(`input[name="${b.dataset.clear}"]`, b.closest('form')); input.dataset.clear = input.dataset.clear === '1' ? '' : '1'; input.value = ''; input.placeholder = input.dataset.clear === '1' ? '(저장하면 지워집니다)' : '(그대로 둠)'; b.textContent = input.dataset.clear === '1' ? '취소' : '비우기'; }; }); + $$('[data-copy]').forEach((b) => { b.onclick = async () => { try { await navigator.clipboard.writeText(b.dataset.copy); toast('복사했습니다'); } catch { prompt('복사:', b.dataset.copy); } }; }); + const check = async (btn, what) => { btn.disabled = true; try { const r = await api('check', 'POST', { what }); if (what === 'outbound') { D.meta.outboundStatus = r.outboundStatus; toast(r.outboundStatus.provider === 'ok' ? '연결 정상' : errText(new Error(r.outboundStatus.provider)) + (r.outboundStatus.detail ? ' — ' + r.outboundStatus.detail : ''), r.outboundStatus.provider !== 'ok'); } else { D.meta.haraka = r.haraka; } render(); } catch (e) { toast(errText(e), true); btn.disabled = false; } }; + for (const id of ['check-outbound', 'check-resend', 'check-smtp']) if ($('#' + id)) $('#' + id).onclick = (e) => check(e.currentTarget, 'outbound'); + if ($('#check-haraka')) $('#check-haraka').onclick = (e) => check(e.currentTarget, 'haraka'); + const dkim = async (force) => { if (force && !confirm('DKIM 키를 다시 만들면 DNS 의 TXT 레코드도 새 값으로 바꿔야 합니다. 계속할까요?')) return; try { await api('dkim', 'POST', { force }); toast('DKIM 키를 만들었습니다. DNS 레코드를 올리세요.'); D = await api('config'); render(); } catch (e) { toast(errText(e), true); } }; + if ($('#dkim-gen')) $('#dkim-gen').onclick = () => dkim(false); if ($('#dkim-regen')) $('#dkim-regen').onclick = () => dkim(true); + if ($('#setup-done')) $('#setup-done').onclick = async () => { try { D = await api('config', 'PUT', { values: { SETUP_DONE: true } }); toast('완료로 표시했습니다'); render(); } catch (e) { toast(errText(e), true); } }; + if ($('#setup-undone')) $('#setup-undone').onclick = async () => { try { D = await api('config', 'PUT', { values: { SETUP_DONE: false } }); render(); } catch (e) { toast(errText(e), true); } }; + if ($('#restart')) $('#restart').onclick = async () => { if (!confirm('서비스를 재시작합니다. 몇 초 동안 접속이 끊깁니다.')) return; try { await api('restart', 'POST', {}); toast('재시작 중… 10초 뒤 화면을 다시 읽습니다'); setTimeout(() => location.reload(), 10000); } catch (e) { toast(errText(e), true); } }; +} +function showTokenPrompt(message) { + $('#main').innerHTML = `

    접속 토큰

    ${esc(message)}

    토큰은 서버의 데이터 디렉터리 setup-token 파일에 있습니다(배포 계정만 읽을 수 있음). 배포 스크립트가 끝에 전체 주소를 출력합니다.

    `; + $('#f-token').onsubmit = (e) => { e.preventDefault(); token = $('#f-token input').value.trim(); try { sessionStorage.setItem(TOKEN_KEY, token); } catch { /* 무시 */ } load(); }; +} +async function load() { + try { if (MODE === 'token') { DOMAINS = await api('domains'); if (!DOMAIN) DOMAIN = DOMAINS.primary; } D = await api('config'); render(); } + catch (e) { + if (MODE === 'token' && (e.message === 'setup_token_invalid' || e.message === 'rate_limited' || !token)) return showTokenPrompt(token ? errText(e) : '이 화면은 배포 출력에 나온 주소(…/?token=…)로 열어야 합니다. 토큰을 직접 넣을 수도 있습니다.'); + if (MODE === 'admin' && (e.status === 401 || e.status === 403)) { $('#main').innerHTML = `

    관리자 로그인 필요

    이 화면은 관리자 페이지에서 로그인한 뒤 열 수 있습니다.

    `; return; } + // CSP(script-src 'self')가 인라인 onclick 을 막으므로 단추는 코드로 묶습니다. + $('#main').innerHTML = `

    설정을 읽지 못했습니다

    ${esc(errText(e))}

    `; + $('#retry').onclick = () => location.reload(); + } +} +load(); +})(); diff --git a/modules/arimail/web/style.css b/modules/arimail/web/style.css new file mode 100644 index 0000000..fee3cd8 --- /dev/null +++ b/modules/arimail/web/style.css @@ -0,0 +1,105 @@ +:root{--sidebar-w:230px;--list-w:400px;--bg:#f3f5f8;--panel:#fff;--line:#e3e7ee;--text:#1d2733;--muted:#6b7a8c;--brand:#2b64c8;--brand-dark:#1f4c9b;--accent:#ff9f1c;--danger:#d64545;--hover:#eef3fb;--selected:#dfe9fb;--unread:#111;--shadow:0 8px 30px rgba(20,35,60,.12)} +*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo","Noto Sans KR",system-ui,sans-serif} +button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:8px;background:#fff;padding:7px 12px;min-height:36px}button:disabled{opacity:.5;cursor:wait} +button.primary{background:var(--brand);color:#fff;border-color:var(--brand)}button.primary:hover{background:var(--brand-dark)}button.danger{background:var(--danger);color:#fff;border-color:var(--danger)}button.ghost{background:transparent;border-color:transparent}button.ghost:hover{background:var(--hover)} +.icon-btn{border:0;background:transparent;font-size:18px;padding:6px 10px;border-radius:8px}.icon-btn:hover{background:var(--hover)} +input,select,textarea{width:100%;padding:8px 10px;border:1px solid #cbd3de;border-radius:8px;background:#fff;min-height:36px}input:focus,select:focus,textarea:focus,[contenteditable]:focus{outline:2px solid #9dbcf3;outline-offset:1px} +label{display:block;margin-bottom:12px}label>span{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}.muted{color:var(--muted)}.meta{font-size:12px;color:var(--muted)} +[hidden]{display:none!important} +/* 로그인 */ +#login{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;background:linear-gradient(135deg,#1f4c9b,#2b64c8 60%,#4c8ae6)} +.login-card{background:#fff;border-radius:16px;padding:32px 28px;width:min(400px,100%);box-shadow:var(--shadow)}.login-brand{display:flex;align-items:center;gap:10px;margin-bottom:6px}.login-brand h1{font-size:20px;margin:0} +/* 버전 표시(0.8.1~): "아리학당 메일" 바로 아래 작은 회색 글씨 */ +.brand-text{display:flex;flex-direction:column;line-height:1.15}.app-version{font-size:11px;font-weight:400;color:var(--muted);letter-spacing:.02em}.logo{display:inline-flex;width:34px;height:34px;border-radius:9px;background:var(--brand);color:#fff;align-items:center;justify-content:center;font-size:18px} +#login button{width:100%;min-height:42px;margin-top:6px}#login-feedback:not(:empty){margin:12px 0 0;padding:10px;border-radius:8px;background:#fdecec;color:#8d2323} +/* 상단 */ +#workspace{height:100vh;display:flex;flex-direction:column} +.topbar{display:flex;align-items:center;gap:14px;padding:0 16px;height:54px;background:#fff;border-bottom:1px solid var(--line);flex-shrink:0}.brand{display:flex;align-items:center;gap:8px;font-weight:700;white-space:nowrap}.search{flex:1;max-width:620px;display:flex;align-items:center;background:var(--bg);border-radius:10px;padding:0 4px 0 12px}.search input{border:0;background:transparent;min-height:38px}.search input:focus{outline:none}.topbar-right{margin-left:auto;display:flex;align-items:center;gap:10px}.who{font-size:13px;color:var(--muted);white-space:nowrap}#mailbox-select{width:auto;min-height:32px;padding:4px 8px;font-size:13px} +#menu-toggle{display:none} +/* 3단 */ +.shell{flex:1;display:flex;min-height:0} +.sidebar{width:var(--sidebar-w);flex-shrink:0;background:#fff;border-right:1px solid var(--line);padding:14px 12px;display:flex;flex-direction:column;gap:6px;overflow:auto} +.compose-btn{background:var(--brand);color:#fff;border:0;border-radius:10px;min-height:42px;font-weight:600;margin-bottom:10px;box-shadow:0 4px 12px rgba(43,100,200,.35)}.compose-btn:hover{background:var(--brand-dark)} +.folders{display:flex;flex-direction:column;gap:2px}.folders button{display:flex;align-items:center;gap:10px;border:0;background:transparent;text-align:left;border-radius:8px;padding:8px 10px;min-height:38px}.folders button:hover{background:var(--hover)}.folders button[aria-current=true]{background:var(--selected);font-weight:600;color:var(--brand-dark)}.folders .badge{margin-left:auto;background:var(--brand);color:#fff;border-radius:999px;font-size:11px;padding:1px 8px;font-weight:600}.folders button[aria-current=true] .badge{background:var(--brand-dark)}.folders.small button{min-height:34px;font-size:13px}.folders .ic{width:20px;text-align:center;opacity:.8} +.side-title{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:14px 6px 4px}.usage{margin-top:auto;padding:12px 8px 4px;font-size:12px;color:var(--muted);border-top:1px solid var(--line)}.usage .bar{height:6px;background:var(--bg);border-radius:4px;overflow:hidden;margin:6px 0}.usage .bar i{display:block;height:100%;background:var(--brand)} +.main{flex:1;display:flex;min-width:0;position:relative} +#feedback{position:absolute;top:10px;left:50%;transform:translateX(-50%);z-index:20;background:#1d2733;color:#fff;padding:10px 16px;border-radius:10px;box-shadow:var(--shadow);max-width:90%;font-size:13px}#feedback:empty{display:none} +.list-pane{width:var(--list-w);flex-shrink:0;border-right:1px solid var(--line);background:#fff;display:flex;flex-direction:column;min-height:0} +.read-pane{flex:1;min-width:0;background:#fff;display:flex;flex-direction:column;min-height:0} +.page-pane{flex:1;overflow:auto;padding:24px;background:var(--bg)} +.list-toolbar{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.list-toolbar .spacer{flex:1}.list-toolbar button{min-height:32px;padding:4px 9px;font-size:13px}.list-toolbar select{width:auto;min-height:32px;padding:3px 6px;font-size:13px} +.list-head{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--line);min-height:34px}.list-head .spacer{flex:1}.list-head .folder-name{font-weight:600;color:var(--text);font-size:13px}.list-head .select-all{margin:0;display:inline-flex;align-items:center}.list-head .select-all input{width:15px;height:15px;min-height:0;margin:0;cursor:pointer}.list-head .tag{margin-left:0;white-space:nowrap} +.mail-list{flex:1;overflow:auto} +.mail-row{display:grid;grid-template-columns:auto auto minmax(0,1fr) auto;gap:6px 10px;padding:10px 12px;border-bottom:1px solid #eef1f5;cursor:pointer;align-items:start;position:relative}.mail-row:hover{background:var(--hover)}.mail-row[aria-selected=true]{background:var(--selected)}.mail-row.unread .subject,.mail-row.unread .who{font-weight:700;color:var(--unread)} +.mail-row input[type=checkbox]{width:16px;height:16px;margin-top:3px}.mail-row .star{background:transparent;border:0;padding:0;min-height:0;font-size:16px;color:#c2c9d3;line-height:1}.mail-row .star.on{color:var(--accent)} +.mail-row .who{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.mail-row .subject{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;grid-column:3;font-size:14px}.mail-row .snippet{grid-column:3;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-row time{font-size:12px;color:var(--muted);white-space:nowrap}.mail-row .icons{grid-column:4;font-size:12px;color:var(--muted);text-align:right} +.mail-row .avatar{grid-row:1/4;grid-column:2;width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;font-size:14px} +.pager{display:flex;align-items:center;justify-content:center;gap:10px;padding:8px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}.pager button{min-height:30px;padding:3px 10px} +.empty{padding:40px 20px;text-align:center;color:var(--muted)}.empty .big{font-size:40px;opacity:.4} +/* 읽기 */ +.read-head{padding:16px 22px 10px;border-bottom:1px solid var(--line)}.read-head h2{margin:0 0 10px;font-size:20px;font-weight:600;overflow-wrap:anywhere}.read-actions{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}.read-actions button{min-height:32px;padding:4px 10px;font-size:13px} +.sender-line{display:flex;gap:12px;align-items:center}.sender-line .avatar{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;flex-shrink:0}.sender-line .lines{min-width:0}.sender-line .name{font-weight:600}.sender-line .addr{color:var(--muted);font-size:12px;overflow-wrap:anywhere}.sender-line time{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap} +.detail-box{margin-top:10px;font-size:12px;color:var(--muted);display:grid;grid-template-columns:70px 1fr;gap:2px 8px} +.read-body{flex:1;overflow:auto;padding:10px 22px 22px;position:relative}.corner-btn{position:absolute;top:8px;right:14px;z-index:2;font-size:11px;min-height:24px;padding:2px 8px;border-radius:999px;background:#fff;opacity:.9;box-shadow:0 1px 4px rgba(0,0,0,.12)}.corner-btn:hover{opacity:1}.switch{display:flex;align-items:center;gap:10px;margin:8px 0}.switch input{width:18px;height:18px;min-height:0}.mail-frame{width:100%;border:0;min-height:300px;background:#fff} +.attach-list{display:flex;flex-wrap:wrap;gap:8px;padding:10px 22px;border-top:1px solid var(--line)}.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:13px;background:#fafbfd;text-decoration:none;color:inherit;max-width:100%}.chip:hover{background:var(--hover)}.chip .sz{color:var(--muted);font-size:11px} +.tag{display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;background:#e8edf3;color:#3a4a60;margin-left:4px}.list-head .search-chip{background:#e3ecfb;color:var(--brand-dark);max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.list-head .search-chip button{border:0;background:transparent;padding:0 0 0 6px;min-height:0;font-size:11px;color:inherit;cursor:pointer;vertical-align:baseline}.tag.ok{background:#e3f3e6;color:#1d6b33}.tag.warn{background:#fde8e6;color:#9b2c1f}.tag.accent{background:#fff1dc;color:#9a5b00} +.notice{padding:8px 12px;background:#fff8e6;border:1px solid #ffe2a8;border-radius:8px;font-size:12px;margin:8px 22px 0;display:flex;gap:10px;align-items:center} +/* 작성 */ +#compose{width:min(980px,96vw);max-height:94vh;border:0;border-radius:14px;padding:0;box-shadow:var(--shadow)}#compose::backdrop{background:#14233566} +.compose-head{display:flex;align-items:center;gap:10px;padding:12px 18px;background:#1f4c9b;color:#fff;border-radius:14px 14px 0 0}.compose-head h2{margin:0;font-size:16px;font-weight:600;flex:1}.compose-head button{color:#fff;border:0;background:transparent;font-size:18px;padding:2px 8px;min-height:0} +.compose-form{padding:14px 18px 16px;display:flex;flex-direction:column;gap:8px;max-height:calc(94vh - 52px);overflow:auto;position:relative}.drop-overlay{position:absolute;inset:8px;background:rgba(43,100,200,.10);border:2px dashed var(--brand);border-radius:12px;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:16px;color:var(--brand-dark);z-index:6;pointer-events:none}.choice{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 12px;background:#fff8e6;border:1px solid #ffe2a8;border-radius:10px;font-size:13px}.choice strong{flex:1}.choice button{min-height:30px;padding:3px 10px;font-size:13px} +.addr-row{display:grid;grid-template-columns:90px 1fr auto;align-items:center;gap:8px;border-bottom:1px solid var(--line);padding:4px 0}.addr-row span{font-size:13px;color:var(--muted)}.addr-row input{border:0;min-height:32px;padding:4px 6px}.addr-row input:focus{outline:none}.addr-row .link{color:var(--brand);font-size:12px;cursor:pointer;background:transparent;border:0;min-height:0;padding:2px 6px} +.editor-tools{display:flex;gap:2px;flex-wrap:wrap;padding:6px;background:var(--bg);border-radius:8px;align-items:center}.editor-tools button{border:0;background:transparent;min-height:30px;padding:3px 7px;border-radius:6px;font-size:14px}.editor-tools button:hover{background:#fff}.editor-tools button[aria-pressed=true]{background:#fff;box-shadow:inset 0 0 0 1px var(--line)}.editor-tools select{width:auto;min-height:30px;padding:2px 4px;font-size:12px;border-color:transparent;background:transparent}.editor-tools .sep{width:1px;height:20px;background:#d5dbe4;margin:0 4px}.editor-tools input[type=color]{width:26px;height:26px;padding:0;border:0;background:transparent;min-height:0}.editor-tools label{margin:0;display:inline-flex;align-items:center;gap:3px;font-size:12px;color:var(--muted)} +/* 편집기는 .compose-form(세로 flex, max-height 로 높이 제한) 의 flex 항목입니다. flex 항목은 기본값 flex-shrink:1 이고, min-height 를 명시하면 + "내용 크기만큼은 줄어들지 않는다"는 자동 최소 크기(min-height:auto)가 사라져 창이 꽉 차면 320px 까지 눌립니다. 그 상태에서 overflow 는 visible + 이라 여러 줄을 붙여넣으면 글이 편집기 테두리를 넘어 아래의 첨부·보내기 단추 위에 겹쳐 보였습니다(0.9.9 수정). flex-shrink:0 으로 편집기는 내용 + 높이대로 자라고 스크롤은 .compose-form 이 맡습니다(이미지 크기 도구 place() 도 form.scrollTop 기준). overflow:auto 는 가로로 긴 표·white-space:pre + 본문이 테두리 밖으로 번지지 않게 편집기 안에서 가로 스크롤하게 합니다(세로는 내용 높이대로 자라므로 스크롤이 생기지 않음). */ +.html-editor{border:1px solid var(--line);border-radius:8px;background:#fff;min-height:320px;flex-shrink:0;overflow:auto;padding:16px;overflow-wrap:anywhere;line-height:1.6}.html-editor img{max-width:100%;height:auto;cursor:pointer}.html-editor img.selected{outline:2px solid var(--brand);outline-offset:1px}.img-sizer{position:absolute;z-index:7;background:#fff;border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow);padding:4px 6px;display:flex;gap:4px;align-items:center;font-size:12px;flex-wrap:wrap}.img-sizer button{min-height:26px;padding:2px 7px;font-size:12px}.img-sizer input{width:72px;min-height:26px;padding:2px 6px;font-size:12px}.img-sizer .lbl{color:var(--muted);margin-right:2px}.img-handle{position:absolute;width:14px;height:14px;background:var(--brand);border:2px solid #fff;border-radius:3px;cursor:nwse-resize;z-index:7;box-shadow:0 0 0 1px var(--brand)}.html-editor blockquote{border-left:3px solid #c8d2e0;margin:8px 0;padding-left:12px;color:#4a5566}.html-source{min-height:320px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px} +.compose-attachments{display:flex;flex-wrap:wrap;gap:6px}.compose-attachments .chip button{border:0;background:transparent;padding:0 2px;min-height:0;color:var(--muted)} +.compose-foot{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding-top:6px;border-top:1px solid var(--line)}.compose-foot .spacer{flex:1}.compose-foot .status{font-size:12px;color:var(--muted)} +/* 페이지(설정·계정) */ +.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:20px;margin-bottom:16px;max-width:1100px}h1.page-title{font-size:22px;margin:0 0 6px}h2{font-size:17px;margin:0 0 10px}h3{font-size:15px;margin:16px 0 8px} +dl.kv{display:grid;grid-template-columns:minmax(160px,260px) 1fr;gap:6px 16px;margin:0}dl.kv dt{font-weight:600;color:#3a4a60}dl.kv dd{margin:0;overflow-wrap:anywhere}code{background:#eef1f4;padding:2px 6px;border-radius:4px;font-size:13px}ol.steps li{margin-bottom:6px} +table.simple{border-collapse:collapse;width:100%}table.simple th,table.simple td{text-align:left;padding:8px 10px;border-bottom:1px solid #e8eaef;font-size:13px}table.simple th{color:var(--muted);font-weight:600}table.simple tr[data-row]{cursor:pointer}table.simple tr[data-row]:hover{background:var(--hover)} +.actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}.grid2{display:grid;grid-template-columns:1fr 1fr;gap:0 16px} +#detail{width:min(640px,94vw);border:0;border-radius:14px;padding:22px;box-shadow:var(--shadow)}#detail::backdrop{background:#14233566}#detail .close{float:right}#dialog-feedback:not(:empty){background:#fdecec;color:#8d2323;padding:10px;border-radius:8px;margin-bottom:10px} +.avatar.c0{background:#2b64c8}.avatar.c1{background:#1d9a6c}.avatar.c2{background:#d64545}.avatar.c3{background:#9a5b00}.avatar.c4{background:#6b4bc8}.avatar.c5{background:#0e8ba8} +.resizer{width:6px;flex-shrink:0;cursor:col-resize;background:transparent;position:relative;z-index:5;margin:0 -3px}.resizer:hover,.resizer.active{background:#c8d6ee}body.resizing{cursor:col-resize;user-select:none}body.resizing iframe{pointer-events:none} +@media(max-width:1100px){.list-pane{width:min(var(--list-w),340px)}} +@media(max-width:900px){.resizer{display:none}#menu-toggle{display:inline-block}.sidebar{position:absolute;left:0;top:54px;bottom:0;z-index:30;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}.sidebar.open{transform:none}.shell{position:relative}.list-pane{width:100%;border-right:0}.read-pane{position:absolute;inset:0;z-index:10;display:none}.read-pane.open{display:flex}.search{max-width:none}.who{display:none}} +@media(max-width:600px){.topbar{padding:0 8px;gap:8px}.brand span:last-child{display:none}.mail-row .avatar{display:none}.read-head{padding:12px 14px 8px}.read-body{padding:8px 14px 14px}.attach-list{padding:8px 14px}.page-pane{padding:12px}.grid2{grid-template-columns:1fr}.addr-row{grid-template-columns:70px 1fr auto}#compose{width:100vw;max-height:100vh;border-radius:0}.compose-head{border-radius:0}} +/* 0.8.4: 로그인 안내 (?) 버튼 — 로그인 화면 오른쪽 위 */ +.help-btn{position:fixed;top:14px;right:16px;width:38px;height:38px;min-height:0;padding:0;border-radius:50%;border:2px solid #ffffffaa;background:#ffffff22;color:#fff;font-weight:700;font-size:18px;line-height:1;z-index:5}.help-btn:hover{background:#ffffff44} +.login-mode{margin:0 0 14px;font-size:13px;color:var(--brand-dark);font-weight:600} +/* 0.8.4: 관리자 페이지(/admin) — 메일함 요소 숨김 */ +[data-mode=admin] .search,[data-mode=admin] #compose-btn,[data-mode=admin] #folder-nav,[data-mode=admin] #filter-nav,[data-mode=admin] .side-title[data-a=filters],[data-mode=admin] #usage,[data-mode=admin] #rz-list{display:none!important} +.brand-mode{font-weight:400;color:var(--muted)} +.folders a.nav-link{display:flex;align-items:center;gap:10px;border-radius:8px;padding:8px 10px;min-height:38px;color:inherit;text-decoration:none}.folders a.nav-link:hover{background:var(--hover)} +fieldset.services{border:1px solid var(--line);border-radius:10px;padding:8px 14px 4px;margin:4px 0 8px}fieldset.services legend{font-size:12px;color:var(--muted);padding:0 6px} +.simple .tag{font-size:11px} +/* 0.8.4: 편집기 — SVG 아이콘, 색상 팔레트 */ +.editor-tools svg{width:16px;height:16px;vertical-align:-3px;display:inline-block} +.color-tool{position:relative;display:inline-flex}.color-tool button{display:inline-flex;flex-direction:column;align-items:center;gap:1px;padding:2px 7px;line-height:1}.color-tool .glyph{font-size:14px}.color-tool .swatch{display:block;width:16px;height:4px;border-radius:2px} +.color-pop{position:absolute;top:100%;left:0;z-index:30;margin-top:4px;background:#fff;border:1px solid var(--line);border-radius:10px;padding:8px;box-shadow:var(--shadow);width:176px} +.color-pop .swatches{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.color-pop .swatches button{width:34px;height:28px;min-height:0;padding:0;border:1px solid #d5dbe4;border-radius:6px}.color-pop .swatches button:hover{outline:2px solid var(--brand);outline-offset:1px} +.color-pop .color-more{display:flex;flex-direction:column;gap:4px;margin-top:8px}.color-pop .color-more button{min-height:30px;padding:3px 8px;font-size:12px;border:1px solid var(--line);background:#fff}.color-pop .color-more button:hover{background:var(--hover)}.color-pop input[type=color]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;bottom:0;left:0} +/* 0.8.16 설정: 메일 전달·자동 응답 양식. 사이드바 아래 자동 처리 상태(.auto-note)는 켜 둔 전달·자동 응답을 잊지 않게 합니다. */ +.auto-form label:not(.switch){display:block;margin:8px 0}.auto-form textarea{min-height:120px;resize:vertical}.auto-form .period{margin:4px 0 8px}.auto-form .actions{align-items:center;margin-top:10px} +.auto-note{margin-top:8px;color:var(--brand);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.auto-note.warn{color:#b45309} +/* 비밀번호 입력 칸 + 보기 단추(0.8.25) */ +.pw-wrap{display:flex;gap:6px;align-items:center}.pw-wrap input{flex:1;min-width:0}.pw-wrap .eye{flex:none;min-height:36px;padding:0 10px;border:1px solid #cbd3de;border-radius:8px;background:#fff;cursor:pointer} +.switch-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.switch-row .switch{margin:8px 0}.auto-on{color:var(--brand);font-weight:700} +/* 0.9.0: 회원 가입 링크(로그인 화면), 안내성 피드백, 사용량 막대 경고, 승인 대기 행 */ +.link-btn{width:100%;min-height:34px;margin-top:6px;border:0;background:transparent;color:var(--brand);font-size:13px;text-decoration:underline}.link-btn:hover{color:var(--brand-dark)} +#login-feedback.info:not(:empty){background:#e6f1ff;color:#1f4c9b} +.usage .bar.full i{background:var(--danger)} +table.simple tr.pending td{background:#fff8e6} +/* 0.9.3: 대용량 첨부파일 — 칩(☁), 발송 조건 줄, 읽기 창 목록 */ +.chip.large{background:#eef5ff;border-color:#c9dbf5}.chip.large.off{opacity:.6}.chip.large button{border:0;background:transparent;padding:0 2px;min-height:0;color:var(--muted)} +.terms-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 10px;background:#eef5ff;border:1px solid #c9dbf5;border-radius:8px;font-size:13px}.terms-row label{margin:0;display:inline-flex;align-items:center;gap:4px;font-size:13px;color:var(--text)}.terms-row input{width:72px;min-height:30px;padding:2px 6px}.terms-row .meta{flex-basis:100%} +.large-list .meta{flex-basis:100%} +/* 0.9.5: 업로드 진행 칩, 참조·숨은 참조 단추 묶음 */ +.chip.large.uploading{flex-wrap:wrap;gap:4px 8px}.chip.large.uploading progress{width:160px;height:8px;accent-color:var(--brand)}.chip.large.uploading .pct{font-size:11px;color:var(--muted)} +.addr-links{display:inline-flex;gap:2px} diff --git a/modules/webmaker/README.md b/modules/webmaker/README.md new file mode 100644 index 0000000..e0937cc --- /dev/null +++ b/modules/webmaker/README.md @@ -0,0 +1,455 @@ +# AI Webmaker (WebHoting AI Agent) — v1.7.3 + +AI 로 웹페이지를 자동 생성하고 같은 서버(nginx)에서 바로 호스팅하는 에이전트. + +## 변경 이력 +전체 변경 기록은 [CHANGELOG.md](CHANGELOG.md), 오류 점검 목록은 [ERRORS.md](ERRORS.md) 에 있다. +- **1.7.3** 도메인 연결 오류 수정(배포 서버에 `log_format wmstats` 가 이미 있으면 중복 정의하지 않고, 중복으로 nginx -t 가 실패하면 우리 파일을 지워 자가 복구 · nginx 1.19.4 미만은 ssl_reject_handshake 대신 자체 서명 인증서 기본 서버) + **메일 사용량 연동**(AriMail 1.0.9 사용량 API: 관리자 콘솔에서 키 발급·보관(보관소 `arimail-usage`), 도메인별 메일함 용량·메일 수·발송/수신 표, 사용자 메일 화면 사용량 행, 사이트 정보 "메일 데이터" = 서버 메일함 용량 + 메일 사용량 구역(30일 막대)). +- **1.7.2** 도메인 관리 역할 분리 — 도메인 화면은 구매·갱신·**네임서버 이전 4단계 띠**, DNS 화면은 **사이트 ↔ 도메인 연결 표**(하위 이름 선택·연결 끊기)가 먼저이고 레코드 관리는 그 아래, 메일 화면은 계정 형식·접속 주소·초기 관리자를 한눈에; **사이트 정보 페이지**를 오른쪽 캔버스로 옮겨 방문자 막대·송수신 선 그래프·7일/월간(3개월) 막대·디스크(웹/DB/메일) 그래프를 SVG 로 표시. 두 서버 로그 0건. bear 배포 중단. +- **1.6.40** 사용자 화면 전면 재구성 — 작업 흐름 중심의 세 칸 구조(레일: 홈 / 만들기·수정·배포·운영 / 도메인·DNS·메일 / 캐시·충전·설정, 상태 점·배지), **홈**(단계별 상태·다음 할 일·의존 관계 지도), **배포** 화면(준비 점검표 + 발행·원본 서버 반영·검색 등록), **DNS**·**메일** 화면 분리, **캐시·충전** 화면(선불 충전 상품 → 결제 진행은 PG 미연동이라 결제 대기 주문만 → 관리자 "입금 확인 → 적립", 적립 = 결제 금액 + 인센티브), 관리자 콘솔 선불 충전 상품·주문 관리(`lib/prepaid.js`). 로그 점검 두 서버 0건. +- **1.6.39** 메일 서버 비밀번호 보호(덮어쓰기 확인 창·**이전 비밀번호로 되돌리기**·Arimail 로그인 잠금 중 "연결 확인" 비활성·credentials.json/config.json/mail-servers.json `.bak`), **사이트 관리자 비밀번호 ↔ 메일(Arimail) 비밀번호 동기화**(로그인 때 자동으로 맞춤, 켤 때 본인 비밀번호 확인, 동기화 상태 열), 연동 관리자 계정 **비밀번호 보기/숨기기·복사·초기화(다른 관리자 계정으로도)·삭제 보호**, Route 53 접속 정보 누락 문구가 빠진 항목을 말함(webmaker.ibit.kr 사례는 1.6.38 배포로 이미 해결 확인); 1.6.38 배포 이후 로그 점검 0건. +- **1.6.38** AI 연결 탭에서 키 없이 저장한 제공자가 설정 탭 등급 목록에서 빠지는 이유를 표·저장 문구·빨간 안내로 바로 보여 주고 키 없는 "모델 조회" 는 거절(webmaker.ibit.kr OpenRouter 사례); **메일 서버(Arimail) 등록부** — 다른 서버에 설치된 Arimail 을 여러 대 등록(닉네임·IP 로 구분, 비밀번호는 보관소)하고 사이트마다 고르거나 메일 도메인으로 자동 선택, 고른 서버 호스트가 MX 기본값; 도메인 탭 외부 도메인에 **"네임서버 정보"·"DNS 레코드 정보" 팝업**(어디를 누르는지 단계 안내, A·MX·SPF·DMARC·DKIM 값 클릭 선택·복사 버튼); Route 53 설정 칸 `ACCESS_KEY` 별칭 허용; 로그 점검 0건. 1.6.36·1.6.37 건너뜀. +- **1.6.35** 도메인 관리 프로세스 개편: 관리자는 접속 정보·플랫폼 도메인·**서브 도메인 등록 허용/금지** 만 정하고, 도메인 검색·구매(**장바구니** → 최종 선택 구매)·**이미 가진 도메인 등록**(네임서버 이전: 레코드 옮김 → 네임서버 안내 → "네임서버 변경했습니다" 확인 → 전파 중 "DNS 변경 진행 중" 레코드 잠금 / 직접 DNS 관리: 필요한 레코드 안내 → "설정 완료 — 확인" → 미반영이면 배포 불가 안내)·**도메인 상태**(배포·메일이 안 되는 이유 단계별)는 사용자 "도메인" 탭에서 스스로; 관리자 DNS 영역 화면은 조회·정리만; 로그 점검 0건. +- **1.6.33** DNS 영역 관리 API 404 회귀 수정(1.5.36 부터 `dns` 경로 누락), "이전된 영역 받기" 가 실패 원인(미등록·위임 없음·네임서버 무응답)을 설명하고 빈 영역은 "zone 파일 구성을 찾을 수 없습니다" 로 안내, 배포 직후 접속 확인 재시도 + `vhost-wait` 단계, 발행 카드 "다시 확인"(해결된 오류 갱신); 로그 점검. +- **1.6.32** 등록 업체에 **Amazon Route 53 Domains** 추가(검색·가격·등록·갱신·네임서버, 비동기 등록은 PENDING → 완료 확인 뒤 DNS 영역 자동, 운영 복구 "등록 상태 확인"), 보관소 Route 53 연결 확인에 도메인 등록 권한 표시, 모의 서버 검증 6개; 로그 점검 0건. +- **1.6.31** 보안·안정성 개선판: Claude 가드 훅 실효화(서버 절대 경로 설정 생성, Node 가드)·AI 환경 변수 허용 목록·작업 시간/동시 상한·codex fail-closed, 내부 주소 차단 + 허용 목록, 생성 사이트 페이지의 세션 무시 + CSRF 토큰, 고정 관리자 admin/admin 폐지(토큰 1회성·1회용 비밀번호), 로그인 속도 제한·잠금, 원자적 JSON 저장·손상 표시, 캐시 중복 차감 금지, Arimail 계정 등록부, 배포 단계(dns-wait·cert-wait…)·배포본 보관·이전 배포본으로, 도메인 구매 원장, 관리자 **운영 복구**·**작업 기록** 탭, 업데이트 전 백업, `npm test` 자동 검증. 설치: 최초 관리자는 설정 페이지(토큰)에서 만든다 (docs/FIRST-INSTALL.md). +- **1.5.36** 좌측 위·아래 영역 구분(배경·경계), 모듈 위젯 다국어(data-title·data-button 등 속성 번역, 파일 선택 버튼 문구표), 등급 표 특징 칸 2줄, **도메인 등록 업체 모듈**(Openprovider·Porkbun) 과 **DNS 공급자 모듈**(Route 53·Google Cloud DNS·Porkbun·Openprovider — 영역·레코드·MX·ACME TXT·이전 영역·감사 기록, 관리자 DNS 영역 관리) — [docs/DOMAIN-DNS-PROVIDERS.md](docs/DOMAIN-DNS-PROVIDERS.md). +- **1.5.31** 빌더 위쪽 진행 상자 **한 줄**(상태 + 오른쪽 보조 문구), 배포 결과 상자를 없애고 메시지창으로, 재배포 접속 확인의 301 오판 수정(HTTP→HTTPS 리디렉션 정상 판정, SNI), 서비스 유닛 권한 점검·보정 도구 `scripts/unit-check.js --fix`(드롭인) 와 갱신 스크립트 연동. +- **1.5.27** 빌더 위쪽 **메시지창**(결과 메시지 누적·최신 위·삭제), 재배포 때 vhost 가 이미 같으면 sudo 없이 유지(sudo 가 막힌 서버의 "도메인 연결 실패" 경고 제거), 사이트 도메인 vhost 에도 **발행 취소 안내 규칙**(`.unpublished` → 503), 안내 페이지 다국어(`lib/maintpage.js`), 와일드카드 인증서 감지를 nginx 참조 파일·/etc/ssl(Cloudflare 원본 인증서)까지, **회원가입 규칙**(가입 ON/OFF · 관리자 승인 후 이용, 승인/거절), 문의 메일 발신 주소를 검증된 상위 도메인으로(Resend 403 수정). +- **1.5.19** 생성 탭 등급 특징 문구(기본 정적 웹페이지 / 중급 UI·UX 심미성 / 고급 복잡한 프런트엔드 아키텍처), 배포 오류 안내 개선 — sudo 가 막힌 **원인**(유닛 `NoNewPrivileges`·`ProtectSystem=strict`·sudoers)을 구분해 안내(`sudoHint`), 종류 local 배포 서버의 DNS 비교를 127.0.0.1 이 아니라 서버 공인 IP 로(`localServerIps`), 설치 점검표 "배포 서버 도메인 연결" 항목, 갱신 스크립트의 유닛 경고. +- **1.5.17** 서버에 **미리 발급된 와일드카드 인증서**(`*.<플랫폼 도메인>`, 이름 무관)를 인지해 서브 도메인 사이트에 자동 적용 — SAN 기반 감지(`deploy.findWildcardCert`), 기동 때 HTTP 로 배포돼 있던 사이트 재구성(`ssl.autoApply`), 설치 점검표 항목, `scripts/ssl-wildcard.js`(설치·갱신 스크립트가 호출). +- **1.5.16** 관리자 콘솔 — **AI 연결** 탭 신설(Claude 인증·연결 테스트·**사용할 Claude 모델**·AI 제공자를 설정 탭에서 분리; 여기서 켠 모델만 등급·내부 호출에 지정 가능, 서버도 저장 때 검사), "Claude 기본 모델" 을 등급 지정과 같은 목록에서 고르는 **내부 호출 모델**(`internalRef`, 사용자 문의 AI 분석 등)로 대체, 시스템 설정 › 설치 점검표 글자 색을 배경의 보색 계열로 고정(어두운 테마에서 읽을 수 없던 문제). +- **1.5.13** 관리자 콘솔 › 배포 서버 — 공개키를 **별도 창에서 버튼으로 복사**(키 생성·재생성·보기), 추가·수정 폼을 **대화상자**로 분리("배포 서버 추가"·"수정" 때만 열림). +- **1.5.11** 첫 설치 — **기본 관리자 `admin / admin`**(사용자가 없으면 기동 때 생성, 첫 로그인 뒤 비밀번호 변경 강제), **설치 점검표**(관리자 비밀번호·AI 엔진 인증·공개 주소 — 끝나기 전에는 첫 화면 `/`·`/login` 이 `/setup` 으로 넘어감), `/setup` 을 토큰으로도 열기, 설정 페이지 **기본 포트 8795 · 모든 인터페이스**(겹치면 다음 포트 자동), 설정 페이지에 관리자 계정·AI 엔진 인증 카드, `remote-install.sh` FQDN 없이 IP 로 처음 설치, 회원가입 설정(`service.allowSignup`), 생성 사이트 런타임 `wm-modules.js` 캐시 `no-cache`+ETag ([docs/FIRST-INSTALL.md](docs/FIRST-INSTALL.md)). +- **1.5.7** 운영 구조 — **설정 파일 분리**(`data/config.json`, 유닛의 환경 변수를 첫 기동 때 자동 이전), **초기 설정 페이지**(별도 포트 3081 `webmaker-setup`, 토큰/관리자 로그인; `/setup`·관리자 콘솔 시스템 설정 탭과 같은 화면), **연동 모듈**(`lib/connectors/` — Gitea 소스 보관, **Arimail** 메일 서버: 사이트 관리자 계정의 메일함 생성·웹메일 연결), **연동 서비스 설치 스크립트**(`install-services.sh`, `install-arimail.sh`, `install-gitea.sh` 설정 파일 등록, 설치·갱신 스크립트에 포함). 오류 수정: codex CLI 0.159+ 의 `--full-auto` 제거로 OpenAI 서버 로그인 작업이 모두 실패하던 문제, 모듈 키워드(일정관리 등) 수정 지시에 표준 위젯 코드가 빠져 AI 가 가짜 모듈(`schedule`)을 만들던 문제(런타임 별칭으로 기존 페이지도 복구). [docs/CONFIG.md](docs/CONFIG.md) · [docs/ARIMAIL.md](docs/ARIMAIL.md) +- **1.4.18** 기본 제공 모듈에 **일정관리(캘린더)** 추가 — 월간·주간·일간 보기, 시·분/일/주 단위 일정, 반복(매일·매주·매월·매년), **만세력 기준 음력 날짜·사주식 간지(병오년 무술월 신유일)·24절기 표시 옵션**, 음력 기준 일정(음력 매년·매월). 음력·간지는 자료표 없이 천문 계산(`lib/lunar.js`, [docs/CALENDAR.md](docs/CALENDAR.md)). +- **1.4.17** 미리보기 안에서 페이지를 옮기면 위 도구 줄의 페이지 선택과 소스 편집기가 따라간다 (생성 페이지의 `wm-stats.js` 가 부모 창에 현재 경로를 알림, sandbox iframe 이라 직접 읽을 수 없음). +- **1.4.15** 1.4.14 회귀 수정 — 사이트를 고르지 않은 상태의 상태 줄 클래스(`empty`)가 미리보기 빈 화면의 전체 덮개 규칙과 겹쳐 빌더가 클릭에 반응하지 않던 문제. "사용자 첫 화면 페이지" 는 빌더 오른쪽 빈 창에만 보이는 페이지임을 관리자 콘솔 문구와 동작(전용 상자)으로 분명히 했다. +- **1.4.14** 빌더 탭을 **생성/수정/관리/도메인/설정** 으로 — 새 "수정" 탭(수정 지시·발행·다국어·검색 최적화·버전·첨부), "관리" 탭은 사이트 관리자 기능·현황 전담이며 오른쪽 창이 사이트 관리 창(사이트 관리자 페이지 + 현황)으로 바뀜, 상태·운영 주소 줄 높이 고정(탭 들썩임 제거). 관리자: OpenAI 서버 로그인 모델 전체 조회(codex 모델 목록 파일), 등급 지정에 추가 모델이 바로 보이도록(제공자별 묶음·빠진 이유 표시). 모델이 바뀌어도 맥락 유지("엔진 변경 안내" 섹션). 사이트 소스 저장소를 서비스 서버의 **로컬 Gitea**(계정 webmaker, 저장소 webmaker/sites)로 — `deploy/install-gitea.sh`·`scripts/migrate-gitstore.sh`, [docs/GITEA-LOCAL.md](docs/GITEA-LOCAL.md). 플랫폼 도메인 **와일드카드 인증서**(`*.ibit.kr`)로 서브 도메인 사이트 HTTPS, 고객 도메인은 사이트별 인증서로 분리 — [docs/SSL-WILDCARD.md](docs/SSL-WILDCARD.md). +- **1.4.5** 관리자 지정 사용자 첫 화면 페이지, 숫자 입력·표시 3자리 쉼표, Claude 세션 누적 비용을 작업마다 청구하던 오류 수정(이번 실행 비용 = 누적 차이 또는 토큰 × 단가, 캐시 쓰기 단가·보정 배율 수정, [점검 보고서](reports/token-billing-2026-10-10.md)). +- **1.4.3** 도메인 서비스 준비 목록·만드는 방법 문서 [docs/DOMAIN-SETUP.md](docs/DOMAIN-SETUP.md) (Openprovider 계정·접속 정보, `*.ibit.kr` DNS, SSL, 메일, 점검 순서). +- **1.4.1** 사이트 복사가 같은 서비스의 다른 사이트를 복사할 때 로그인 화면만 복사되던 문제 수정(시작 폴더 범위만 복사), 관리자 빌더의 "캐시 충전 요청" 안내 제거, 캐시 표시 단위 설정(원·크레딧·D 등), 게시판 [글쓰기] 버튼 한 줄. +- **1.4.0** 빌더 "도메인" 탭(Openprovider 도메인 검색·구매·갱신, 가격 = 원가 × 요율 250% 를 사용 전에 표시·캐시 차감, DNS 레코드 관리, 사이트 연결), 계정 종류(서브 도메인 전용 / 자체 도메인 — 서브 도메인은 `이름.ibit.kr` 등록·메일 `admin@이름.ibit.kr`·`/mail`), 메일(MX)·Resend 레코드 자동 설정과 DNS 전파 대기 안내, 블로그 모듈(검색·댓글), 자료실 모듈(파일 내려받기), 다국어 선택 기본 드롭다운, 생성 탭 언어 상자 여백, API 키 재입력·비노출 점검, 다국어 언어 선택 링크 오류(1.3.16) 수정. +- **1.3.17** 코드 점검 보고서(2026-10-07) 46건 조치 — 세션 위조(S01)·세션 무효화·미리보기 sandbox·바깥 주소 가져오기 보호(netguard)·가져오기 경로 탈출·번역 마크업 차단 등 보안 14건, 지도 모듈·발행·폐기·청구·편집기 등 기능 32건. 관리자 콘솔 비밀번호 규칙(기본 6자 이상). 대응표 `reports/code-review-2026-10-07-response.md`. +- **1.3.16** 관리자 설정 값마다 실행 버튼(청구 마진 저장 안 됨 수정·빈 값 0 저장 방지·저장 안 된 변경 표시), 다국어 로케일 코드 URL 원칙·x-default 지정, 로케일별 메타·구조화 데이터 관리, 생성 사이트 시스템 문구 카탈로그(위젯·폼·오류·동의문·예약·문의·메일 알림 하드코딩 제거), 번역 품질 게이트(누락률·잔존율·링크 대응·검수), 현지 형식(날짜·시간대·통화·주소·전화·이름 순서·법적 고지·RTL), 사이트 관리자 다국어와 서비스 기본 언어 강제(사용자 선택 우선). +- **1.3.10** 구글 검색 등록 완전 자동(서비스 계정 키)·네이버 수동 단계 최소화·배포 후 IndexNow 자동, 관리자 사이트 템플릿(디렉터리·해시태그·사용 허용, 생성 탭 "템플릿에서 시작"), 다국어 사이트 생성(/ko/ /en/ 로케일 폴더·같은 구조 번역·RTL·언어별 날짜와 SEO/AEO/GEO·hreflang), 빌더 번역 개선(남은 한국어 제거·언어 파일 형식 2·속도), 관리자 화면 다국어, 좁은 화면 번역문 넘침 수정, SEO 초안 JSON 파싱 오류 수정. +- **1.3.5** 생성 옵션 SEO·AEO·GEO(AI 초안 → 관리 탭 검토 후 반영), 관리 탭 검색사이트 등록(구글·네이버 단계·IndexNow, 관리자 검색 등록 탭), 관리자 언어 탭 실제 상태·서비스 버전 표시와 버전 감시, 문의 메일 발송 칸 삭제. +- **1.3.4** 생성 탭에서 "사이트 관리하기(서버에서 가져오기)" 삭제, "사이트 복사하기" 만 남기고 상세 설명(기본 HTML 페이지만 복사·기능 미복사·기존 모양을 살린 새 사이트 제작용). +- **1.3.3** 가져온·복사한 사이트 미리보기의 "페이지를 찾을 수 없습니다" 수정(루트 기준 주소를 Referer 로 사이트 폴더에 연결), 미리보기 없음 이유 표시. +- **1.3.2** 메일 시스템 사이트 단위 켜기(관리자 메일 시스템 탭, "메일시스템 설정중"→"사용 가능"), 키 파일 올리기, 화면 설정 탭(실제 화면 미리보기), 로그 보기(종류·기간·회사·사이트·검색), 사용자 문의(문의 종류·종류별 AI 1차 분석·답변 초안), 관리자 새로고침 수정, 템플릿 문장 번역(패턴). +- **1.3.1** 외부 연동 접속 정보 암호화 보관소(`data/credentials.json`, 키 파일 가져오기, 기존 메일·Claude 키 자동 이전), 웹메일 보내기(메일 발송 키·Resend 도메인 등록·확인). +- **1.3.0** 사이트 메일 시스템(운영자 켜기/끄기, 아이디@사이트도메인, DNS 안내·확인·자동 재확인, 사이트 관리자 계정과 공유하는 메일 계정, 메일 사용 끄기 시 재확인·비밀번호, `사이트/webmail` 웹메일 — 송수신 백엔드는 mail-gateway 연동 대기), 언어 파일 업로드 "JSON 파싱 실패" 수정. +- **1.2.0** 생성 탭 "기존 사이트 관리" 분리(사이트 복사하기 신규·사이트 관리하기), 가져오기·복사 후 사이트 현황 분석, 웹서버 설정(nginx·Apache)에서 서비스 중인 사이트 찾기·선택, 크기 제한을 서버 용량 10% 기준으로, 위쪽 사이트 선택·잔여 캐시만 표시, 제안·오류 3단계 흐름(답변 시 처리 완료), 생성 코드 주석(화면 언어), AI 입력창 강조, 관리자 화면 외형(색·구성) 설정, 캐시 상세 가로 스크롤 제거(사용자 제안 반영). +- **1.1.6** 캐시 소진 즉시 중지(모델별 비용 보정·부분 스트리밍 반영·97% 기준)와 파이프라인·기획 단계 이어하기, 캐시 상세 폭 조정, 관리자 충전 폼 위치, 제안·오류 신고 환경 정보 수집(사전 고지), 관리자 "다음 작업 반영" 결정과 작업 서버 수집(`scripts/collect-backlog.js`). +- **1.1.4** 좌측 창 상하 분할(상태 창 위쪽), 발행 카드 간소화, 발행 취소 403 수정(.unpublished 표식·작업 폴더 보호), 수정 완료 배너 간소화, 검증 작업 표시, 캐시 내역 5행 스크롤 + 상세 보기(일·주·월), 사이트 관리자 계정 생성 갱신·직접 주소 표시, ? 풍선 화면 안 배치. +- **1.1.2** 다국어(원본 카탈로그 내려받기 → 번역 파일·ZIP 드래그앤드롭 업로드, 언어 켜기/끄기, 구버전 번역 자동 숨김), 등급 표 간결화, 페이지 수 옵션, 재배포 버튼 안전장치, 수정 완료 배너·상세 내역, 작업 단위 캐시 합계, 사용자 제안·오류 신고와 AI 자동 분석(관리자 탭). +- **1.1.0** 오류 점검(엔진 준비 확인·안내), 버전 표시, 발행 취소 시 "수정 중" 안내 페이지, 수정마다 모델 등급 선택과 맥락 유지, ? 도움말, 생성 탭 독립·중단 후 새로 만들기, 수정 진행 단계 표시, 제품명 AI Webmaker, 사이트 폐기, 큰 수정 지시 입력창, 재배포 버튼 정리, 모바일 미리보기, 모델 등급 비용 가이드. +- **1.0.3** 배포 후 운영 주소 표시, 공통 기능 모듈(회원·게시판·문의·설문·예약·갤러리·지도·후기·FAQ + 사이트 관리자 페이지, `modules/`), AI에게 맡기기 단순화(추가 입력만 받고 끝까지 자동 생성, 수정 횟수 한도·발행 대기), 생성/관리/설정 탭, 내가 고르기 속도 개선·사실정보 단순화·버튼 정리, 수정 요청 오류 수정. +- **1.0.2** 신규 회원 기본 캐시, 사이트별 배포 위치 지정(관리자 전용), 문의 메일 실제 발송 검증·재발송, 사이트 접속 통계(서버 로그 측정 + 브라우저 비컨 추정), 사용자 화면의 "사이트 정보" 카드(접속·네트워크 인/아웃·디스크·DB 사용량). +- **1.0.1** 캐시(크레딧) 도입: 관리자가 사용자별 캐시를 설정·추가하고 작업 비용이 캐시에서 차감됨. 사용자 화면의 토큰·예상 금액 표시를 캐시 사용액/잔액으로 대체. 캐시 소진 시 작업을 멈추고(지금까지의 작업 저장) 충전되면 자동으로 이어서 진행. 모델은 관리자가 지정하는 등급(기본·중급·고급)으로만 선택하며 모델명은 사용자에게 보이지 않음. 사용자 자신의 LLM 연결 기능 삭제. 외부 서비스용 캐시 조정 API(`X-Service-Key`). + +- UI: `http://192.168.20.66/` (로그인 필요, 계정이 없으면 회원가입: 아이디·비밀번호·이름(회사명)) +- 생성된 사이트: `http://192.168.20.66/<32자리 16진 ID>/` (메인 `index.html`), 하위 페이지는 `//page.html` + +## 구조 + +``` +브라우저 ──> nginx:80 ─┬─ / ──> Node 서버 127.0.0.1:3080 (UI + API + SSE) + └─ /<32hex>/... ──> /var/www/sites/<32hex>/ 정적 파일 +Node 서버 ──> claude -p --model claude-opus-5-5 (작업 폴더 = /var/www/sites/) +``` + +| 경로 | 역할 | +|---|---| +| `server.js` | HTTP 서버. 사이트 생성/수정 API, SSE 진행 스트림, 정적 UI | +| `lib/claude.js` | Claude Code 헤드리스 실행(`stream-json` 파싱, 토큰 사용량 정규화) | +| `lib/prompt.js` | 입력 항목(제목·목적·주요 컨텐츠·디자인·기타)을 프롬프트로 정리 | +| `lib/store.js` | `data/sites/.json` 에 명령 이력·첨부 목록·토큰 합계 저장 | +| `lib/auth.js` | 회원(scrypt 해시) 저장 `data/users.json`, HMAC 서명 세션 쿠키 | +| `lib/gitstore.js` | 사이트별 git 저장소(`data/git/`) 커밋과 Gitea 브랜치 푸시 | +| `lib/uploads.js` | 첨부 파일 저장(`/var/www/sites//uploads/`), 형식·용량 검사, docx 텍스트 추출 | +| `hooks/guard.sh` | PreToolUse 훅. 사이트 폴더 밖 Write/Edit 차단 | +| `public/` | 좌측 탭(생성·수정·관리·도메인·설정, v1.4.14)·진행 상태 / 우측 미리보기 iframe(관리 탭에서는 사이트 관리 창 `manage-ui.js`). 상세 진행 로그는 고객에게 보이지 않고 스피너(진행 중)/체크(완료)/경고(오류) 표시기만 노출 | +| `lib/config.js` · `scripts/config.js` | v1.5.7 설정 파일(`data/config.json`) — 항목표·우선순위·환경 변수 이전, 명령줄 도구 ([docs/CONFIG.md](docs/CONFIG.md)) | +| `setup-server.js` · `public/setup.*` · `lib/setupapi.js` | v1.5.7 초기 설정 페이지(별도 포트, systemd `webmaker-setup`; v1.5.11 기본 8795·0.0.0.0, 겹치면 다음 포트)와 설정·연동 상태 API (서비스 `/setup`·관리자 콘솔 시스템 설정 탭과 공용) | +| `lib/setupstate.js` · `lib/setupauth.js` | v1.5.11 설치 점검표(관리자 비밀번호·AI 인증·공개 주소 → 첫 화면 포워딩)와 설정 페이지 인증(접속 토큰·세션·시도 제한, 두 서버 공용) ([docs/FIRST-INSTALL.md](docs/FIRST-INSTALL.md)) | +| `lib/connectors/` | v1.5.7 외부 서비스 연동 모듈 — `gitea.js`(소스 보관), `arimail.js`(메일 서버), `index.js`(등록소) ([docs/ARIMAIL.md](docs/ARIMAIL.md)) | +| `deploy/` | nginx 설정, systemd 유닛(`webhosting-agent`, `webmaker-setup`), 설치 스크립트(`install.sh`, `remote-install.sh`, `remote-update.sh`, `install-services.sh`, `install-gitea.sh`, `install-arimail.sh`) | + +## 로그인과 사용자별 사이트 + +- `/login` 에서 로그인하거나 회원가입한다. 회원가입은 아이디(영문 소문자·숫자 3~32자), 비밀번호(6자 이상), 이름(회사명)만 받는다. +- 비밀번호는 scrypt 로 해시해 `data/users.json` 에 저장하고, 세션은 `data/session.secret` 로 서명한 HttpOnly 쿠키(30일)다. +- 사이트에는 `ownerId` 가 기록되어 목록·조회·수정 모두 본인 것만 가능하다. 회원의 이름(회사명)은 생성 프롬프트에 "운영 주체"로 전달된다. +- 생성된 페이지 자체(`//`)는 공개 URL 이므로 로그인 없이 열린다. + +## Gitea 소스 보관 — v1.4.14 서비스 서버의 로컬 Gitea + +사이트마다 로컬 git 저장소(`data/git/`, work-tree 는 `/var/www/sites/`, 사이트 폴더에 `.git` 없음)를 두고, 변경이 생길 때마다 커밋해 Gitea 로 푸시한다. + +- 원격(`SITES_GIT_REMOTE`): **v1.4.14 부터 서비스 서버 안의 전용 Gitea** — Docker 컨테이너 `webmaker-gitea`(HTTP `:3030`, SSH `127.0.0.1:2230`), 서비스 전용 계정 `webmaker`, 비공개 저장소 `webmaker/sites`, ssh 별칭 `webmaker-gitea`. `deploy/install-gitea.sh` 가 컨테이너·계정·저장소·키 등록·서비스 유닛 설정을 한 번에 하고, `--migrate-from <옛 원격>` 으로 1.4.5 까지 회사 소스 저장소(`source.ibit.kr` `OZ-Group/WebHosting_AI_agent`)에 섞여 있던 `sites/*` 브랜치를 옮긴다(`scripts/migrate-gitstore.sh`, 옛 브랜치는 지우지 않음). 구성·확인·백업: [docs/GITEA-LOCAL.md](docs/GITEA-LOCAL.md). 관리자 콘솔 › 설정 › 소스 저장소에서 연결 상태와 이 서버의 공개키를 본다. +- 브랜치 구조(사이트 랜덤코드로 분리): + - `sites//main` — 항상 최신 + - `sites//changes/0001-create`, `0002-ai-update`, `0003-manual-edit` … — 생성, AI 수정 지시, 소스 편집기 저장 등 변경마다 하나 +- 커밋 작성자는 회원 이름(회사명)과 `<아이디>@webhosting.local`. 브랜치·푸시 상태는 사용자에게 보이지 않고, UI 에는 "이전 버전으로 되돌리기" 목록만 표시된다. 되돌리기(`POST /api/sites/:id/versions/:no/restore`)는 그 시점의 파일(과 Brief 스냅샷)을 복원하고 복원 자체를 새 버전(`restore`)으로 기록한다. 푸시 실패분은 다음 변경 때 자동 재시도한다. + +## 관리자 콘솔 (`/admin`) + +v1.3.2 탭: 사이트 관리 · 로그 보기(종류·기간·회사·사이트·검색) · 회원·청구 · 배포 서버 · 사용자 문의(문의/제안/오류/요구, AI 1차 분석·답변 초안) · 메일 시스템(사이트 단위) · 언어 · 화면 설정(실제 화면 미리보기) · 설정. "새로고침" 은 지금 탭과 개요를 다시 불러오며, 열린 탭은 주소 #탭 으로 유지된다. + +관리자 계정은 로그인 후 곧바로 `/admin` 으로 이동하며 사이트 생성 화면은 쓰지 않는다(사이트 관리 전용). 일반 화면(밝은 배경·파란 포인트)과 구분되도록 짙은 남색 배경·호박색 포인트의 다크 테마다. + +- **사이트 관리**: 모든 회원의 사이트 목록·상태·오류·토큰·비용, 열기/중단/삭제, 상세(명령 이력·모델·상세 로그·프롬프트·버전·기획 상태·검증 리포트). +- **오류 로그**: 사용자에게 숨긴 상세 원인 전체. +- **회원 · 청구**: 모델별 호출·토큰·비용(USD)과 단가, 회원별 비용(USD) → 원화 정산 → 청구 금액(마진 포함) → 선결제 충전 → 잔액. 회원을 누르면 일자별 정산(적용 환율 표시)과 충전 내역, 충전 추가/취소. 역할 변경. +- **제안 · 오류**(v1.2.0 흐름): 새 글(접수) → AI 분석이 붙으면 검토 중 → 관리자가 답변을 저장하면 처리 완료(반려 없음). 답변은 사용자 설정 탭 "내 제안 · 오류 신고" 에 펼쳐 보인다. "다음 수정에 반영" 으로 결정한 글은 작업 서버가 수집하고, 릴리스에 반영하면 `node scripts/collect-backlog.js --implemented --version ` 로 반영 완료 표시한다. +- **설정 › Webmaker 화면 외형**(v1.2.0): 색 구성 미리 설정·색 10가지·왼쪽 패널 폭·글자 크기·모서리·간격·패널 위치·위쪽 영역 스타일. `/theme.css` 로 사용자 화면·로그인·사이트 관리자 페이지에 적용(저장 전에는 기존 화면 그대로). +- **AI 연결**(v1.5.16, 설정 탭에서 분리): Claude 모델 연결 방식(서버 로그인 / `claude setup-token` 으로 만든 OAuth 토큰 / Anthropic API 키) 저장과 연결 테스트, **사용할 Claude 모델** 체크(`settings.claudeModels`), AI 제공자(OpenAI·Gemini·Groq·OpenRouter·호환 서버) 키·모델 조회·제공 모델 선택. 여기서 켠 모델만 설정 탭의 등급·내부 호출 모델에 지정할 수 있다(서버도 저장 때 검사). +- **설정 › 회원가입 규칙**(v1.5.27): 회원가입 허용 ON/OFF(`service.allowSignup`)와 가입 후 이용 방식(`settings.signupApproval` — 즉시 / 관리자 승인). 승인 방식이면 회원 · 청구 탭에서 "가입 승인"·"거절(삭제)", 탭 배지에 대기 수. +- **설정**: 모델 등급 지정과 **내부 호출 모델**(v1.5.16 — 등급과 무관한 서비스 내부 호출(사용자 문의 AI 분석 등)에 쓰는 모델, 등급 지정과 같은 목록에서 선택; 1.5.13 까지의 "Claude 기본 모델" 대체), 환율 모드(자동: ECB·시장 중간환율 + 스프레드 % / 직접 입력: 당일 USD 구입 가격)와 마진 %. + +청구 계산: 명령별 비용(USD, Claude Code 보고값) × 그 명령 실행일(KST)의 USD 구입 가격(원) × (1 + 마진%). 환율은 `data/fx.json` 에 날짜별로 캐시된다(자동 모드: frankfurter.dev → open.er-api 순, 조회 실패 시 직전 값). 선결제는 `data/billing.json` 에 충전 내역으로 기록된다. 설정은 `data/settings.json`(0600)에 저장되며 토큰/키는 화면에 마스킹된다. + + +- 회원에는 `role`(`user`|`admin`)이 있다. 관리자는 `ADMIN_USERS` 환경변수(아이디 쉼표 구분)나 `node scripts/admin.js grant ` 로 지정한다. 관리자 계정은 메인 화면 상단의 "관리자" 링크로 `/admin` 에 들어간다. +- 관리자 화면: 모든 회원의 사이트 목록(방식·단계·상태·명령 수·오류·토큰·비용), 사이트별 명령 이력과 **상세 로그**(도구 호출, 도구 오류, 프롬프트, 실시간 스트림), 전체 오류 로그, 회원 역할 변경. +- 사용자 화면에는 상세 로그와 원인 메시지가 보이지 않는다. 사용자에게는 "작업을 완료하지 못했습니다. 다시 시도해 주세요." 같은 일반 안내만 표시되고, 원인(`도구 오류: self signed certificate`, 종료 코드 등)은 `debug` 레벨로 명령 이력(`commands[].log`)에 남아 관리자만 본다. SSE 도 역할에 따라 필터링된다. + +## 참고 사이트 수집 + +참고 사이트와 기존 사이트 URL 은 AI 가 WebFetch 로 직접 열지 않고, 서버가 먼저 본문을 가져와 사이트 폴더의 `.refs/ref-N.txt` 에 저장한 뒤 AI 가 Read 로 읽는다(`lib/refs.js`). 인증서 검증 실패(자체 서명·만료), EUC-KR 인코딩, 리디렉션을 처리하며 실패하면 사용자가 적은 참고 포인트만 반영한다. WebFetch 는 보조 수단이며, 국내 사이트의 인증서 문제로 실패하지 않도록 Claude 실행 환경에 `NODE_TLS_REJECT_UNAUTHORIZED=0` 을 준다(`CLAUDE_ALLOW_INSECURE_TLS=0` 으로 끌 수 있음). `.refs/` 는 nginx 가 서비스하지 않고 git 보관에서도 제외된다. + +## 문의 접수 위젯 (사이트 안에서 메일 발송) + +생성 사이트의 문의·예약 폼은 `mailto:` 대신 **표준 위젯**(`templates/contact-widget.html`)을 그대로 삽입한다. 생성·수정 프롬프트에 사이트별 엔드포인트(`<폼 접수 서버>/api/forms/<사이트ID>/contact`)가 채워진 위젯 코드가 포함되고, AI 는 data-* 속성과 script 를 바꾸지 않고 CSS 로만 꾸민다. 검증은 `mailto:` 사용과 위젯 존재 여부를 검사하며, 자동 수정은 mailto 를 위젯으로 대체한다. + +접수 흐름: 방문자 제출 → 서비스의 공개 엔드포인트(CORS: 서비스·사이트 도메인·배포 서버 공개 URL 출처만 허용, 허니팟, IP/사이트별 속도 제한, 길이 검증) → Resend API 로 메일 발송(발신 `webmaster@사이트도메인`이 Resend 검증 도메인이면 그것, 아니면 기본 발신 도메인; `reply_to` 는 문의자) → `data/inquiries/<사이트ID>.json` 에 기록. 수신 주소는 사이트의 "문의 수신 이메일"(기본 사항 / Brief 사실정보 `email`)이며 HTML 에는 노출되지 않는다. 사용자 화면의 "문의 접수 내역" 카드에서 접수·발송 여부를 본다. + +관리자 콘솔 › 설정 › 메일 발송: 메일 에이전트 키 파일 경로(.key 의 `RESEND_API_KEY`) 또는 키 직접 입력(암호화 저장), 기본 발신 도메인, 발신자 이름, 폼 접수 서버 주소(공개 URL; 사설 IP 서비스에서는 공개 인스턴스 주소를 지정), 테스트 메일 발송. + +## 오류 페이지 + +존재하지 않는 페이지·숨김 파일·서버 오류는 서비스 서버 nginx, 생성되는 호스팅 vhost, Node 앱 모두 "페이지를 찾을 수 없습니다" / "일시적인 오류가 발생했습니다" 라는 일반 안내 페이지(`public/404.html` 과 동일 내용)로 응답하고 `server_tokens off` 로 서버 버전을 숨긴다. 다른 서비스의 내용이나 서버 종류가 드러나지 않는다. + +## 배포 서버 (여러 웹호스팅 서버 관리) + +관리자 콘솔 › 배포 서버에서 사이트를 실제로 호스팅할 서버를 여러 개 등록하고 기본 배포 서버를 지정한다. 종류는 두 가지다. + +- **로컬**: 이 서비스 서버의 디렉터리(예: `/var/www/sites`, 즉 지금 서비스되는 위치 — 같은 경로면 이미 배포된 것으로 처리). +- **SSH 원격**: 주소·포트·사용자·사이트 저장 경로(공개 URL 은 선택)를 입력하고, "키 생성"으로 이 서버가 만든 전용 ed25519 키의 공개키를 원격 서버 `~/.ssh/authorized_keys` 에 등록하거나 기존 개인키를 붙여넣는다. "연결 테스트"는 접속 → 사용자 → 저장 경로 존재/생성 → 쓰기 권한 → tar 순으로 진단하고, 실패 시 원인과 서버에서 실행할 명령(예: `sudo chown <경로>`)을 알려 준다. 배포는 `tar | ssh tar` 로 전송한 뒤 임시 디렉터리를 원자적으로 교체한다(`.git`, `.claude`, `.refs` 제외). 원격 서버에는 `tar` 만 있으면 된다. + +사용자가 "최종 확정(발행)"하면 사이트의 지정 서버(없으면 기본 서버)로 자동 배포되고 결과 URL 이 표시된다. 확정 전에 **도메인을 여러 개**(예: `www.ibit.kr`, `ibit.kr`) 입력하면 배포 서버의 nginx 에 `server_name` 으로 모두 등록한 vhost(`/etc/nginx/sites-available/wh-.conf` 또는 `conf.d/`)를 만들어 어느 도메인으로 접속해도 열리게 한다(대상 서버에 nginx 와 비밀번호 없는 `sudo` 필요, 배포 서버 설정의 "vhost 자동 설정"으로 끌 수 있음). DNS 는 운영자가 배포 서버로 가리켜야 한다. + +**기본 오류 서버**: 매 배포마다(sudo 허용·vhost 자동 설정인 서버) 배포 서버 nginx 에 `default_server` 가 있는지 점검하고, 없으면 `wh-default.conf` 로 80 포트는 "페이지를 찾을 수 없습니다" 안내 페이지(404)를, 443 포트는 `ssl_reject_handshake` 로 미등록 호스트의 HTTPS 요청을 거부하도록 설정한 뒤, 미등록 호스트 요청이 실제로 안내/거부되는지 검증해 결과를 배포 결과에 표시한다. 기존 default_server 가 있으면 유지하되 다른 내용이 노출되면 경고한다. 관리자 배포 서버 탭의 "오류 서버 점검"으로 수동 점검도 가능하다. + +**첫 배포와 재배포**: 같은 서버에 성공한 배포가 있으면 재배포로 처리해 파일 갱신·기본 오류 서버 점검·vhost 재적용만 하고, 대상 서버에 이미 사이트 인증서가 있고 모든 도메인을 포함하면 HTTPS 설정을 유지한 채 인증서 재발급을 건너뛴다(도메인이 추가되었거나 인증서가 없을 때만 발급). 도메인 연결이 끝나면 **Let's Encrypt SSL** 을 자동 발급한다(배포 서버 설정 "SSL 자동 발급", 기본 켜짐): DNS 가 배포 서버를 가리키는 도메인만 골라 `certbot --nginx --redirect` 로 인증서를 받고 HTTPS 서버 블록과 HTTP→HTTPS 리디렉션을 만든다(certbot 이 없으면 apt 로 설치 시도, 갱신은 certbot 타이머). 알림 이메일은 설정 탭에서 지정한다. nginx 설정 쓰기와 certbot 은 **sudo** 가 필요하므로 배포 서버마다 "sudo 사용 허용"을 운영자가 켜야 하며, 관리자 배포 시 허용되지 않은 서버면 확인 대화상자에서 허용 여부를 묻는다. 허용하지 않으면 파일만 배포된다. + +배포 후에는 접속 확인을 자동으로 수행한다: 도메인별 DNS(A 레코드가 배포 서버를 가리키는지)와 배포 서버의 vhost 응답, 공개 URL 응답을 검사해 관리자 배포 결과 패널과 사용자 발행 상자에 ✓/✗ 와 조치 방법(예: A 레코드 추가)을 표시한다. 관리자 사이트 상세의 "접속 확인"으로 언제든 다시 검사할 수 있다. 배포 서버의 공개 URL 은 그 서버의 웹서버가 저장 경로를 `공개URL//` 로 직접 서비스할 때만 설정한다. + +배포가 완료되면 되돌리기용 이전 버전(로컬·Gitea 의 `sites//changes/*` 브랜치)을 모두 삭제하고 현재 배포본 하나만 남긴다(`sites//main` 강제 갱신). 이후 수정 과정의 버전은 다음 배포 때 다시 정리된다. 관리자는 사이트 상세에서 서버를 바꾸거나 즉시 배포할 수 있으며, 사이트를 삭제하면 배포본도 제거한다. 설정은 `data/deploy-targets.json`, 키는 `data/deploy-keys/`(0600). + +원격 호스팅 서버의 nginx 예시(저장 경로 `/var/www/sites`, 공개 URL `https://host.example.com`): + +```nginx +server { + listen 80; server_name host.example.com; + location ~ "^/[a-f0-9]{32}(/|$)" { root /var/www/sites; index index.html; try_files $uri $uri/ =404; location ~ /\. { deny all; } } +} +``` + +### 도메인 HTTPS — 사이트별 인증서와 플랫폼 와일드카드 인증서 (v1.4.14) + +- **고객 도메인**(example.com, www.example.com): 발행(배포) 때 DNS 가 배포 서버를 가리키는 도메인에 Let's Encrypt 인증서(`wh-<사이트ID>`, webroot)를 발급하고 HTTP→HTTPS 로 돌린다(1.0.x 부터). +- **플랫폼 서브 도메인**(이름.ibit.kr): 사이트마다 받지 않고 **와일드카드 인증서 `*.ibit.kr`**(`wh-wild-ibit.kr`) 하나로 HTTPS. 관리자 콘솔 › 설정 › SSL 에서 운영자가 발급·갱신한다(DNS 인증 — 플랫폼 DNS 가 Openprovider 면 자동, 수동이면 화면에 뜨는 TXT 를 DNS 에 넣음). 와일드카드가 없으면 서브 도메인은 HTTP 로 열리고 발급되면 재배포 없이 HTTPS 가 적용된다. **v1.5.17**: 서버에 다른 절차로 받아 둔 `*.<도메인>` 인증서(이름 무관)도 SAN 으로 찾아 같은 방식으로 쓰며, 서비스 기동 때 HTTP 로 배포돼 있던 서브 도메인 사이트를 자동으로 HTTPS 로 바꾼다(`node scripts/ssl-wildcard.js --apply`). nginx 설정은 두 묶음을 별도 server 블록으로 쓴다(`lib/deploy.js vhostConf`, `lib/ssl.js`, `scripts/acme-dns-hook.js`). 절차·메시지: [docs/SSL-WILDCARD.md](docs/SSL-WILDCARD.md). + +## 여러 AI 제공자와 사용자 자기 모델 + +- 관리자 콘솔 › **AI 연결** 탭(v1.5.16, 이전 설정 탭) › **AI 제공자**: OpenAI(ChatGPT), Google Gemini, Groq, OpenRouter, 기타 OpenAI 호환 서버의 API 키를 저장하고 "모델 조회"로 목록을 불러온 뒤 이 서비스에서 쓸 모델과 단가($/1M)를 고른다. 키가 있고 활성화된 제공자의 선택 모델만 등급·내부 호출 모델 목록에 나타난다(Claude 는 같은 탭의 "Claude 모델 연결"·"사용할 Claude 모델" 을 따름). 키는 AES-256-GCM 으로 암호화해 `data/settings.json` 에 저장한다. +- 사용자 화면 상단 **AI 모델** 선택: 사이트마다 사용할 모델을 고르며(`modelRef`, 예: `openai:gpt-5.2`), 생성·수정·기획 단계 모두 그 모델로 실행된다. +- **내 AI 연결**: 사용자가 자기 API 키(OpenAI·Gemini·Groq·OpenRouter·호환 서버) 또는 Claude 구독의 `claude setup-token` 토큰을 직접 등록한다. "연결 확인"으로 모델 목록을 받아 쓸 모델을 고르고 저장하면 `data/user-connections.json`(암호화, 0600)에 보관되어 다음에도 재사용된다. 내 연결 모델로 실행한 작업은 사용자 키로 과금되므로 청구액이 0 원으로 기록된다. +- OpenAI 는 API 키 대신 **서버 로그인**(OpenAI Codex CLI 의 ChatGPT 계정 로그인)으로도 쓸 수 있다. 서버에서 `npm i -g @openai/codex` 후 `codex login --device-auth` 로 로그인하고, 관리자 콘솔의 OpenAI 제공자에서 "서버 로그인"을 고르면 정적 모델 목록(gpt-5.2-codex, gpt-5.2, gpt-5.1 등)이 제공되며 `lib/engine-codex.js` 가 `codex exec --json` 으로 실행한다(파일 변경 사용량은 turn.completed 의 토큰으로 집계). +- 서버 로그인 **모델 조회**(v1.4.14): codex CLI 가 백엔드에서 받아 두는 모델 목록 파일(`~/.codex/models_cache.json`)을 읽어 그 계정에 보이는 모델 전체(이름·설명·숨김·종료 예정)를 한 번에 보인다. 파일이 없으면 기본 모델로 한 번 실행해 만든다. +- 실행 엔진: Anthropic 은 Claude Code CLI(파일 도구), 그 외는 `lib/engine-openai.js` 의 OpenAI 호환 Chat Completions 도구 호출 에이전트(list/read/write/edit/delete_file, 사이트 폴더 밖 접근 차단)로 같은 프롬프트·진행 이벤트·사용량 집계를 제공한다. Gemini 는 OpenAI 호환 엔드포인트를 사용한다. + +## 인증(Claude)과 모델 + +API 키 대신 서버에 로그인된 Claude Code CLI(구독 계정)를 그대로 사용한다. 모델은 `CLAUDE_MODEL`(기본 `claude-opus-5-5`). +모델에 노출되는 도구는 `Write, Edit, MultiEdit, Read, Glob, Grep` 뿐이며(`--tools`), Bash·웹 도구는 비활성화된다. + +## 다국어 — v1.1.2 (v1.3.2 템플릿 패턴) + +- v1.3.2: 값이 들어가는 문장(템플릿 문자열)은 카탈로그에 `현재 사이트: {1} · 화면: {2}` 같은 패턴(pattern: true)으로 들어간다. 번역문에도 같은 {숫자}를 넣어야 하며(순서 변경 가능), 다르면 업로드 때 제외된다. 런타임은 정확한 문장이 없으면 패턴으로 맞춰 값을 끼워 넣는다. + +- 번역 키 대신 **원문(한국어) 자체**를 키로 쓴다. `lib/i18n.js` 가 `public/index.html`, `login.html`, `site-admin.html`, `app.js`, `wizard.js`, `import.js`, `modules-ui.js`, `site-admin.js` 에서 한글이 든 텍스트·속성(placeholder/title/data-tip/alt)·문자열 리터럴을 추출해 카탈로그를 만든다(변수가 든 템플릿 문자열은 제외). `node scripts/i18n-extract.js` 로 `locales/ko.json` 을 갱신해 소스에 함께 둔다. +- 관리자 콘솔 › 언어에서 원본 파일을 내려받아 `translation` 을 채우고 `lang`/`name` 을 적어 올린다(JSON 여러 개·ZIP, 드래그앤드롭). 저장 위치 `data/locales/.json`. `sourceVersion` 이 서비스 버전보다 낮으면 구버전으로 숨겨진다. +- 화면은 `public/i18n.js` 가 사전으로 치환한다(텍스트 노드·속성, MutationObserver). 언어 선택은 헤더 `#langSelect`(언어가 둘 이상일 때만 표시), `localStorage.wm_lang`, `?lang=`. +- v1.3.10: 관리자 콘솔(`admin.html`·`admin.js`)·사이트 관리자도 번역 대상(관리자 콘솔에 언어 선택). 서버 목록·메시지 파일(`SERVER_FILES`)과 백틱·HTML 템플릿까지 추출해 사이트 안 모든 한국어가 `locales/ko.json` 에 들어간다. AI 프롬프트·사이트 파일 내용은 `// i18n-ignore-start` … `// i18n-ignore-end` 로 제외. +- v1.3.10 언어 파일 형식 2: `guide`(번역 방법)·`stats`, 항목마다 `status`("번역 필요"/"번역됨")·`group`·`placeholders`. 화면 코드는 값과 문구를 나눠 `T('문구')`, `TF('{1}원', 값)`, 날짜·숫자는 `uiLocale()` 로 쓰고, 언어가 바뀌면 `wm:i18n` 이벤트로 다시 그린다. 서버가 쿠키 `wm_lang` 으로 첫 HTML 을 미리 번역해 보내고, 사전은 브라우저에 버전 표시와 함께 저장한다. + +## 접속 통계와 사이트 정보 — v1.0.2 + +- 측정(서버 로그): `deploy/nginx-wmstats.conf` 의 `wmstats` 형식을 `/etc/nginx/conf.d/` 에 두고, 서비스 서버의 사이트 경로 블록은 `/var/log/nginx/webmaker-sites.log`, 원격 배포 서버의 사이트 vhost 는 `/var/log/nginx/wh-.access.log` 로 남긴다. `lib/stats.js` 가 로컬 5분·원격 15분(SSH, sudo 허용 서버만) 주기로 새 부분만 읽어 `data/stats/.json` 에 일자별 요청·페이지뷰·방문자(일자+IP+UA 해시)·수신/송신 바이트·상위 페이지·유입 경로를 집계한다. 봇 UA 는 따로 센다. 서비스 사용자는 `adm` 그룹이어야 로그를 읽을 수 있다(Ubuntu 기본 nginx 로그 권한 0640 www-data:adm). +- 추정(비컨): 모든 HTML 에 `\n`; out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${tag}`) : out + tag; + } + out = out.replace(/]*)>/i, (all, attrs) => ``); + // v1.4.0 수정: 원문 언어 폴더를 가리키는 내부 링크(../ko/…)를 이 언어 폴더로 바꾸는 일은 언어 선택 링크를 넣기 "전에" 한다. + // (1.3.16 에서는 선택 링크를 넣은 뒤에 바꿔, 영어 페이지의 "한국어" 링크까지 ../en/ 으로 바뀌어 원문 언어로 돌아갈 수 없었다) + if (to !== from) out = out.replace(new RegExp(`(\\s(?:href|src)=")(\\/|\\.\\.\\/)(${from.replace(/[-]/g, '\\-')})\\/`, 'g'), `$1$2${to}/`); + out = out.replace(/(]*data-wm-langs(?:=["']([\w-]*)["'])?[^>]*>)[\s\S]*?(<\/nav>)/gi, (all, a, style, b) => `${a}${switcherHtml(langs, to, file, style)}${b}`); + if (!/data-wm-langs/.test(out)) out = out.replace(/]*>/i, (b) => `${b}\n`); + return renderFormats(out, to, { ...ctx, dict }); +} +/** 예전(1.3.10 이전) 문의 위젯 스크립트에 한국어로 박혀 있던 문구의 id — 다른 언어 페이지에서 글자를 바로 바꾼다 */ +const LEGACY_WIDGET_IDS = ['common.requiredCheck', 'common.sending', 'common.receivedContact', 'common.sendFailed', 'common.networkFailed']; +/** + * 사이트 맨 위 언어 선택 페이지. 브라우저 언어와 맞는 언어 폴더로 이동하고, 맞는 언어가 없으면 x-default 언어로 간다. + * x-default 가 'root' 이면 브라우저 언어와 맞더라도 이동하지 않고 이 선택 페이지를 그대로 보인다(v1.3.17 B30 — 예전에는 맞는 언어면 이동했다). + * 스크립트가 없어도 링크로 고를 수 있다. + */ +const ROOT_MARK = 'data-wm-lang-root'; +function rootPage(site, langs, def, xDefault) { + const title = (site.title || '').replace(/ + +${title} +${fallback ? '' : ''}${!fallback ? '' : ``} +${fallback ? `` : ''} +

    ${title}

    ${langs.map((c) => `${LOCALES[c].name}`).join(' · ')}

    +`; +} + +/** + * 다국어 맞추기: 기본 언어 폴더의 모든 HTML 을 다른 언어 폴더로 번역·복사한다. + * @param {object} site + * @param {string} siteDir + * @param {(texts: string[], to: string, from: string) => Promise} translateFn AI 번역(서버 syncLanguages 가 구현, 비용 청구) + * @param {(msg: string) => void} log + * @param {object} [opts] { values: 번역할 모듈 설정 값(문의 유형·후기 분류 등 방문자에게 보이는 값) } + * @returns {Promise<{languages: object, translated: number, runtime: number, pages: number}>} + */ +async function sync(site, siteDir, translateFn, log = () => {}, opts = {}) { + if (!isMulti(site)) return null; + const { default: def, list } = site.langs; const baseDir = path.join(siteDir, def); const xd = xDefaultOf(site); + if (!fs.existsSync(baseDir)) throw httpError(`기본 언어 폴더(${def}/)가 없습니다. 다국어 사이트는 페이지를 ${def}/ 폴더에 만들어야 합니다`, 409); + const files = htmlUnder(baseDir); if (!files.length) throw httpError(`기본 언어 폴더(${def}/)에 HTML 이 없습니다`, 409); + // 번역 원본은 기본 언어 페이지에서 검색·AI 최적화 관리 구역(언어별로 따로 넣음)을 걷어 낸 것 + const raw = Object.fromEntries(files.map((f) => [f, fs.readFileSync(path.join(baseDir, f), 'utf8')])); + const sources = Object.fromEntries(files.map((f) => [f, raw[f].replace(MANAGED_RE.seo, '').replace(MANAGED_RE.faq, '').replace(/(]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2')])); // 언어 선택 링크(언어 이름)는 번역하지 않고 다시 채운다 + const values = [...new Set((opts.values || []).filter((v) => typeof v === 'string' && /\p{L}/u.test(v)))]; + const all = [...new Set([...files.flatMap((f) => [...segments(sources[f]), ...formatSegments(sources[f])]), ...values])]; + const ctx = { site, home: siteLocale(site), values }; + const result = { languages: {}, translated: 0, runtime: 0, pages: files.length }; + /** memo 에 없는 문장만 120개씩 번역 */ + const fill = async (memo, to, texts, from) => { + const missing = texts.filter((t) => !memo[t]); + for (let i = 0; i < missing.length; i += 120) { + const batch = missing.slice(i, i + 120); log(`${LOCALES[to].label} 번역 중 (${Math.min(i + 120, missing.length)}/${missing.length})`); + const tr = await translateFn(batch, to, from); + batch.forEach((t, k) => { if (typeof tr[k] === 'string' && tr[k].trim()) memo[t] = tr[k].trim(); }); + saveMemo(site.id, to, memo); result.translated += batch.length; + } + }; + site.langs.review = site.langs.review || {}; + for (const to of list) { + const memo = loadMemo(site.id, to); + if (to !== def) await fill(memo, to, all, def); + // 시스템 문구(위젯·오류·동의문·법적 고지) — 서비스 공용 번역 캐시에 없으면 채운다(한 언어당 한 번) + if (to !== 'ko' && sitemsg.missing(to).length) { log(`${LOCALES[to].label} 시스템 문구 번역 중`); result.runtime += await sitemsg.ensure(to, translateFn); } + const hash = crypto.createHash('sha1'); + if (to === def) { + // 기본 언어 페이지는 글자를 바꾸지 않고 lang·dir·언어 선택 링크·현지 형식(+ 한국어가 아니면 위젯 번역표)만 맞춘다 + for (const f of files) { const out = buildPage(raw[f], def, def, memo, list, f, ctx); hash.update(out); if (out !== raw[f]) fs.writeFileSync(path.join(baseDir, f), out, { mode: 0o644 }); } + } else { + const dir = path.join(siteDir, to); fs.mkdirSync(dir, { recursive: true, mode: 0o755 }); + for (const f of files) { const fp = path.join(dir, f); fs.mkdirSync(path.dirname(fp), { recursive: true, mode: 0o755 }); const out = buildPage(sources[f], def, to, memo, list, f, ctx); hash.update(out); fs.writeFileSync(fp, out, { mode: 0o644 }); } + // 기본 언어에서 지워진 페이지는 다른 언어에서도 지운다 + for (const f of htmlUnder(dir)) if (!files.includes(f)) { try { fs.unlinkSync(path.join(dir, f)); } catch (_) {} } + result.languages[to] = { pages: files.length, memo: Object.keys(memo).length }; + } + // 검수 상태: 이 언어 페이지 내용이 바뀌면 "검수 대기" 로 되돌린다 (품질 게이트의 조건) + const h = hash.digest('hex').slice(0, 16); const rv = site.langs.review[to] || {}; + if (rv.hash !== h) site.langs.review[to] = { state: to === def ? 'approved' : 'pending', hash: h, at: new Date().toISOString(), by: to === def ? 'source' : null, note: to === def ? '기본 언어(원문)' : '' }; + } + // 언어 선택 페이지 (사용자가 직접 만든 맨 위 index.html 은 건드리지 않음) + const rootFp = path.join(siteDir, 'index.html'); let cur = null; try { cur = fs.readFileSync(rootFp, 'utf8'); } catch (_) {} + if (cur === null || cur.includes(ROOT_MARK) || site.langs.forceRoot) { fs.writeFileSync(rootFp, rootPage(site, list, def, xd), { mode: 0o644 }); delete site.langs.forceRoot; } else log('사이트 맨 위 index.html 이 직접 만든 파일이라 언어 선택 페이지로 바꾸지 않았습니다'); + return result; +} + +// ---------- 번역 품질 게이트 (v1.3.16) ---------- +/** 합격 기준 — 관리 탭 다국어 카드와 검증 리포트에 함께 보인다 */ +const QUALITY = { maxMissing: 0.01, maxResidue: 0.02 }; +/** 사이트 안 링크 (외부·메일·전화·앵커·스크립트 제외), 언어 폴더 이름은 {L} 로 바꿔 언어끼리 비교 */ +function internalLinks(html, lang) { + const out = []; const re = /\shref=["']([^"'#]+)["']/gi; let m; + while ((m = re.exec(html))) { const h = m[1].trim(); if (/^(?:[a-z][\w+.-]*:|\/\/)/i.test(h)) continue; out.push(h.replace(new RegExp(`(^|/)${lang.replace('-', '\\-')}/`), '$1{L}/')); } + return out.sort(); +} +/** + * 번역 품질 검사 (언어마다): + * - missing : 번역 누락률 = 번역 메모에 없는 원문 문장 / 전체 원문 문장 (+ 시스템 문구 누락 개수 runtimeMissing) + * - residue : 원문 잔존율 = 번역 페이지에 원문 그대로 남은 문장 / 번역 페이지 문장 (AI 가 일부러 그대로 둔 고유명사는 제외) + * - links : 링크 대응 = 페이지마다 사이트 안 링크가 기본 언어 페이지와 같은 짝(언어 폴더만 다름)인지, 페이지가 빠짐없이 있는지 + * - review : 언어별 검수 상태(pending|approved|rejected) — 내용이 바뀌면 pending 으로 돌아간다 + * 모두 통과해야 그 언어가 합격(pass). 기본 언어는 원문이라 검수만 확인한다. + */ +function quality(site, siteDir) { + if (!isMulti(site)) return null; + const { default: def, list } = site.langs; const files = htmlUnder(path.join(siteDir, def)); const rv = site.langs.review || {}; + const base = Object.fromEntries(files.map((f) => { let h = ''; try { h = fs.readFileSync(path.join(siteDir, def, f), 'utf8').replace(MANAGED_RE.seo, '').replace(MANAGED_RE.faq, ''); } catch (_) {} return [f, h]; })); + const baseSegs = new Set(files.flatMap((f) => segments(base[f].replace(/(]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2')))); + const languages = {}; + for (const to of list) { + const r = { review: (rv[to] && rv[to].state) || 'pending', reviewNote: (rv[to] && rv[to].note) || '', reviewBy: (rv[to] && rv[to].by) || null, reviewAt: (rv[to] && rv[to].at) || null }; + if (to === def) { r.source = true; r.pass = true; languages[to] = r; continue; } + const memo = loadMemo(site.id, to); const total = baseSegs.size; const miss = [...baseSegs].filter((t) => !memo[t]); + r.missing = { count: miss.length, total, rate: total ? miss.length / total : 0, samples: miss.slice(0, 5) }; + r.runtimeMissing = sitemsg.missing(to).length; + let segTotal = 0; const resid = []; const linkIssues = []; const missingPages = []; + for (const f of files) { + let h; try { h = fs.readFileSync(path.join(siteDir, to, f), 'utf8'); } catch (_) { missingPages.push(f); continue; } + const segs = segments(h.replace(MANAGED_RE.seo, '').replace(MANAGED_RE.faq, '').replace(/(]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2')); segTotal += segs.length; + for (const t of segs) if (baseSegs.has(t) && memo[t] !== t && t.replace(/[^\p{L}]/gu, '').length >= 2) resid.push(`${f}: ${t.slice(0, 60)}`); + const noNav = (x) => x.replace(/(]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2'); // 언어 선택 링크는 언어마다 다른 게 정상 + const a = internalLinks(noNav(base[f]), def); const b = internalLinks(noNav(h), to); + if (a.join('\n') !== b.join('\n')) linkIssues.push(`${f}: ${a.filter((x) => !b.includes(x)).concat(b.filter((x) => !a.includes(x))).slice(0, 3).join(', ') || '링크 수가 다름'}`); + } + r.residue = { count: resid.length, total: segTotal, rate: segTotal ? resid.length / segTotal : 0, samples: resid.slice(0, 5) }; + r.links = { ok: !linkIssues.length && !missingPages.length, issues: linkIssues.slice(0, 5), missingPages }; + r.checks = { missing: r.missing.rate <= QUALITY.maxMissing && r.runtimeMissing === 0, residue: r.residue.rate <= QUALITY.maxResidue, links: r.links.ok, review: r.review === 'approved' }; + r.pass = Object.values(r.checks).every(Boolean); + languages[to] = r; + } + return { thresholds: QUALITY, languages, pass: Object.values(languages).every((x) => x.pass), at: new Date().toISOString() }; +} +/** 언어별 검수 결정 (approved | rejected | pending) */ +function setReview(site, lang, state, by, note) { + if (!isMulti(site) || !site.langs.list.includes(lang)) throw httpError('사이트의 언어가 아닙니다'); + if (!['approved', 'rejected', 'pending'].includes(state)) throw httpError('검수 상태는 approved·rejected·pending 중 하나입니다'); + site.langs.review = site.langs.review || {}; const cur = site.langs.review[lang] || {}; + site.langs.review[lang] = { ...cur, state, by: by || null, at: new Date().toISOString(), note: String(note || '').slice(0, 300) }; + return site.langs.review[lang]; +} + +/** + * 단일 언어 → 다국어 전환 뒤 정리: AI 는 파일을 "복사" 만 할 수 있어(삭제 도구 없음) 맨 위에 옛 파일이 남는다. + * - 기본 언어 폴더에 같은 경로가 있는 맨 위 HTML(맨 위 index.html 제외 — 언어 선택 페이지) 은 지운다. + * - assets/ 에 옮긴 사본이 있고 기본 언어 페이지가 더는 참조하지 않는 맨 위 공용 파일은 지운다. + * @returns {string[]} 지운 파일 + */ +function cleanupAfterConvert(site, siteDir) { + const def = site.langs.default; const skip = new Set([...site.langs.list, 'assets', 'uploads', 'webmail']); const removed = []; + const baseHtml = htmlUnder(path.join(siteDir, def)).map((f) => { try { return fs.readFileSync(path.join(siteDir, def, f), 'utf8'); } catch (_) { return ''; } }).join('\n'); + const walk = (dir, rel) => { let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const e of ents) { if (e.name.startsWith('.')) continue; const r = rel ? `${rel}/${e.name}` : e.name; if (!rel && skip.has(e.name)) continue; const fp = path.join(dir, e.name); + if (e.isDirectory()) { walk(fp, r); try { if (!fs.readdirSync(fp).length) fs.rmdirSync(fp); } catch (_) {} continue; } + if (r === 'index.html') continue; + if (/\.html?$/i.test(e.name)) { if (fs.existsSync(path.join(siteDir, def, r))) { fs.unlinkSync(fp); removed.push(r); } continue; } + // assets/ 에 옮긴 사본이 있고(AI 가 주석을 덧붙여 내용은 다를 수 있음) 기본 언어 페이지가 옛 경로(../css/x.css)를 더는 쓰지 않으면 지운다 + const used = new RegExp(`["'(\\s]\\.\\./${r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(baseHtml); + if (fs.existsSync(path.join(siteDir, 'assets', r)) && !used) { fs.unlinkSync(fp); removed.push(r); } + } }; + walk(siteDir, ''); return removed; +} +/** 파일 경로 → 언어 (다국어 사이트에서 'en/about.html' → 'en') */ +function langOf(site, file) { if (!isMulti(site)) return null; const c = String(file).split('/')[0]; return site.langs.list.includes(c) ? c : null; } + +module.exports = { repairSwitchers, codeOf, xDefaultOf, siteLocale, renderFormats, formatTel, formatAddress, formatName, quality, setReview, QUALITY, cleanupAfterConvert, LOCALES, normalize, isMulti, promptRules, mapText, segments, buildPage, rootPage, sync, langOf, translatePrompt, removeSite, htmlUnder, ROOT_MARK }; diff --git a/modules/webmaker/lib/netguard.js b/modules/webmaker/lib/netguard.js new file mode 100644 index 0000000..242542d --- /dev/null +++ b/modules/webmaker/lib/netguard.js @@ -0,0 +1,139 @@ +'use strict'; +// ============================================================================ +// 바깥 주소 가져오기 보호 (v1.3.17, reports/code-review-2026-10-07.md S08·S09·S12·B16) +// +// 서비스가 사용자가 준 주소(참고 사이트, 복사할 사이트, 가져온 사이트의 운영 주소)를 서버에서 내려받을 때: +// 1) 주소마다(리다이렉트 한 단계마다) DNS 를 풀어 사설·예약 주소가 하나라도 있으면 거부한다(관리자 작업만 예외). +// 2) 확인한 그 IP 로만 접속한다(lookup 고정). 확인과 접속 사이에 DNS 가 내부 주소로 바뀌는 경우(DNS 리바인딩)를 막는다. +// 3) 리다이렉트는 자동으로 따르지 않고 직접 따른다. Location 이 잘못된 값이어도 예외가 콜백 밖으로 새지 않고 +// 실패 결과로 돌아온다(예전에는 응답 콜백 안에서 new URL 이 던져 Node 프로세스가 종료될 수 있었다 — S12). +// 4) 본문은 흘려받으며 바이트 상한을 넘는 순간 끊는다(전부 메모리에 받은 뒤 크기를 재던 것 — B16). gzip·br·deflate 는 풀어서 센다. +// 의존성 없음 (http/https/zlib/dns). +// ============================================================================ +const http = require('http'); +const https = require('https'); +const zlib = require('zlib'); +const net = require('net'); +const dns = require('dns').promises; + +const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s }); + +/** IPv4 사설·예약 대역 */ +function privateV4(ip) { + const [a, b] = ip.split('.').map(Number); + return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || (a === 198 && (b === 18 || b === 19)) || (a === 192 && b === 0) || a >= 224; +} +/** 사설·루프백·링크 로컬·멀티캐스트·예약 주소인지 (IPv4, IPv6, IPv4 대응 IPv6) */ +function isPrivateIp(ip) { + if (net.isIPv4(ip)) return privateV4(ip); + const x = String(ip).toLowerCase(); + const mapped = x.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (mapped) return privateV4(mapped[1]); + return x === '::' || x === '::1' || /^f[cd]/.test(x) || /^fe[89ab]/.test(x) || x.startsWith('ff') || x.startsWith('64:ff9b:') || x.startsWith('2001:db8:') || x.startsWith('::ffff:'); +} +/** + * 호스트 이름 → 접속할 IP 하나. 사설 주소가 하나라도 섞이면(allowPrivate 가 아니면) 거부한다. + * @returns {Promise<{address: string, family: number}>} + */ +async function resolvePublic(hostname, allowPrivate) { + const h = String(hostname || '').replace(/^\[|\]$/g, ''); + let addrs; + if (net.isIP(h)) addrs = [{ address: h, family: net.isIP(h) }]; + else { try { addrs = await dns.lookup(h, { all: true, verbatim: true }); } catch (_) { throw httpError(`주소를 찾을 수 없습니다: ${h}`); } } + if (!addrs.length) throw httpError(`주소를 찾을 수 없습니다: ${h}`); + if (!allowPrivate && addrs.some((a) => isPrivateIp(a.address)) && !allowedInternal(h, null)) throw httpError('내부(사설) 네트워크 주소는 가져올 수 없습니다. 관리자가 허용 목록(설정 service.internalAllow)에 넣은 서비스만 가능합니다.', 403); // v1.6.31 허용 목록 + return addrs[0]; +} +/** http(s).request 의 lookup 을 확인한 주소 하나로 고정한다 (다시 DNS 를 풀지 않음) */ +const pinned = (addr) => (host, opts, cb) => { if (typeof opts === 'function') { cb = opts; opts = {}; } if (opts && opts.all) cb(null, [{ address: addr.address, family: addr.family }]); else cb(null, addr.address, addr.family); }; + +/** + * 한 번 요청 (리다이렉트를 따르지 않음). 오류는 모두 reject 로만 나온다. + * @returns {Promise<{status, headers, buf, url}>} + */ +function requestOnce(u, addr, o) { + return new Promise((resolve, reject) => { + const mod = u.protocol === 'https:' ? https : http; + let done = false; const fail = (e) => { if (!done) { done = true; reject(e); } }; + let req; + try { + req = mod.request(u, { method: 'GET', lookup: pinned(addr), timeout: o.timeout, rejectUnauthorized: o.rejectUnauthorized !== false, servername: net.isIP(u.hostname) ? undefined : u.hostname, headers: { 'User-Agent': o.userAgent || 'Mozilla/5.0 (compatible; AIWebmaker/1.0)', Accept: o.accept || '*/*', 'Accept-Encoding': 'gzip, deflate, br', ...(o.headers || {}) } }, (res) => { + try { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); done = true; return resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.alloc(0), url: u }); } + const enc = String(res.headers['content-encoding'] || '').toLowerCase(); + let stream = res; + if (/gzip/.test(enc)) stream = res.pipe(zlib.createGunzip()); else if (/br/.test(enc)) stream = res.pipe(zlib.createBrotliDecompress()); else if (/deflate/.test(enc)) stream = res.pipe(zlib.createInflate()); + const chunks = []; let size = 0; + stream.on('data', (c) => { size += c.length; if (size > o.maxBytes) { req.destroy(); fail(Object.assign(httpError(o.tooBigMessage || '응답이 너무 큽니다', 413), { tooBig: true })); return; } chunks.push(c); }); + stream.on('end', () => { if (!done) { done = true; resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.concat(chunks), url: u }); } }); + stream.on('error', fail); res.on('error', fail); + } catch (e) { fail(e); } + }); + } catch (e) { return fail(e); } + req.on('timeout', () => req.destroy(Object.assign(new Error('응답 시간 초과'), { name: 'AbortError' }))); + req.on('error', fail); + if (o.signal) { if (o.signal.aborted) req.destroy(new Error('취소됨')); else o.signal.addEventListener('abort', () => req.destroy(new Error('취소됨')), { once: true }); } + req.end(); + }); +} + +/** + * 안전하게 가져오기. + * @param {string} url + * @param {object} [o] + * allowPrivate : 관리자 작업 — 사설 주소 허용 + * sameSite(url) : 리다이렉트 대상이 허용되는지 (false 면 offsite 로 끝냄, 복사하기의 "같은 사이트만") + * maxBytes : 본문 상한(압축을 푼 크기, 기본 3MB) maxRedirects: 기본 5 timeout: ms (기본 15000) + * rejectUnauthorized: 인증서 검증 (기본 true) headers·accept·userAgent + * @returns {Promise<{ok, status, type, buf, finalUrl, offsite?, error?}>} — 네트워크·주소 오류는 throw (호출 쪽 try/catch) + */ +async function safeFetch(url, o = {}) { + o = { maxBytes: 3 * 1024 * 1024, maxRedirects: 5, timeout: 15000, ...o }; + let cur; try { cur = new URL(url); } catch (_) { throw httpError('주소 형식이 올바르지 않습니다'); } + for (let hop = 0; hop <= o.maxRedirects; hop += 1) { + if (!/^https?:$/.test(cur.protocol)) throw httpError('http:// 또는 https:// 주소만 가져올 수 있습니다'); + if (o.sameSite && !o.sameSite(cur)) return { ok: false, status: 0, offsite: true, type: '', buf: Buffer.alloc(0), finalUrl: cur.href }; + const addr = await resolvePublic(cur.hostname, o.allowPrivate); // 단계마다 다시 확인 (S08·S09) + const r = await requestOnce(cur, addr, o); + if (r.status >= 300 && r.status < 400 && r.headers.location) { + let next; try { next = new URL(r.headers.location, cur); } catch (_) { return { ok: false, status: r.status, type: '', buf: Buffer.alloc(0), finalUrl: cur.href, error: '잘못된 리다이렉트 주소' }; } // S12 + cur = next; continue; + } + return { ok: r.status >= 200 && r.status < 300, status: r.status, type: String(r.headers['content-type'] || ''), buf: r.buf, finalUrl: cur.href, headers: r.headers }; + } + return { ok: false, status: 310, type: '', buf: Buffer.alloc(0), finalUrl: cur.href, error: '리다이렉트가 너무 많음' }; +} + + +// ---------- v1.6.31 (요구 5): 내부 주소 허용 목록과 범용 검증 ---------- +/** + * 관리자가 허용한 내부 대상 — 설정 service.internalAllow ("host:port, host, 10.0.0.5:8080" 쉼표 구분) + 설정 파일에 등록된 연동 서비스 주소 + * (source.remote/web, GITEA API, mail.url/api, AI 제공자 기본 URL 은 관리자만 적는 값이라 자동 포함). 목록에 없는 내부 서비스로는 어떤 사용자 입력 주소도 연결하지 않는다. + * @returns {Array<{host:string, port:number|null}>} + */ +function internalAllowList() { + const out = []; const add = (h, p) => { h = String(h || '').toLowerCase().replace(/^\[|\]$/g, ''); if (h) out.push({ host: h, port: p ? Number(p) : null }); }; + let cfg = null; try { cfg = require('./config'); } catch (_) {} + if (cfg) { + for (const item of String(cfg.get('service.internalAllow') || '').split(/[,\s]+/).filter(Boolean)) { const m = item.match(/^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/); if (m) add(m[1], m[2]); } + for (const key of ['source.web', 'source.apiUrl', 'mail.url', 'mail.apiUrl']) { try { const u = new URL(cfg.get(key) || ''); add(u.hostname, u.port || (u.protocol === 'https:' ? 443 : 80)); } catch (_) {} } + try { const r = String(cfg.get('source.remote') || ''); const m = r.match(/@([^:/]+)/); if (m) add(m[1], null); } catch (_) {} + } + return out; +} +function allowedInternal(hostname, port) { const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, ''); const p = port ? Number(port) : null; return internalAllowList().some((a) => a.host === h && (a.port == null || a.port === p)); } +/** + * 사용자가 입력한 외부 주소 검증: http(s) 만, localhost/.local/.internal 금지, DNS 가 사설·예약 주소면 거절 — 단 관리자 허용 목록(host:port)은 통과. + * @returns {Promise} 정규화된 주소 + */ +async function assertExternalUrl(raw, o = {}) { + let u; try { u = new URL(String(raw)); } catch (_) { throw httpError('주소 형식이 올바르지 않습니다'); } + if (!(o.allowHttp ? ['http:', 'https:'] : ['https:']).includes(u.protocol)) throw httpError(`허용되지 않는 프로토콜(${u.protocol})입니다`); + const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, ''); const port = u.port || (u.protocol === 'https:' ? 443 : 80); + if (allowedInternal(h, port)) return u.toString(); + if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) throw httpError('내부 호스트 이름(localhost/.local/.internal)으로는 연결할 수 없습니다. 관리자가 허용 목록(service.internalAllow)에 넣은 서비스만 가능합니다', 403); + await resolvePublic(h, false); + return u.toString(); +} +/** ssh/ftp 처럼 URL 이 아닌 호스트:포트 검증 (가져온 사이트의 원본 서버 접속 등) */ +async function assertExternalHost(host, port) { const h = String(host || '').toLowerCase(); if (allowedInternal(h, port)) return true; if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal')) throw httpError('내부 호스트로는 연결할 수 없습니다', 403); await resolvePublic(h, false); return true; } +module.exports = { isPrivateIp, resolvePublic, safeFetch, internalAllowList, allowedInternal, assertExternalUrl, assertExternalHost }; diff --git a/modules/webmaker/lib/openprovider.js b/modules/webmaker/lib/openprovider.js new file mode 100644 index 0000000..34a0e10 --- /dev/null +++ b/modules/webmaker/lib/openprovider.js @@ -0,0 +1,116 @@ +'use strict'; +// ============================================================================ +// Openprovider API 클라이언트 (v1.4.0) — 도메인 검색·가격·등록·갱신, DNS 영역(레코드) 관리 +// +// 문서: https://docs.openprovider.com/doc/all (swagger: https://docs.openprovider.com/swagger.json, v1beta) +// - 인증: POST /auth/login {username, password, ip} → data.token (Bearer). 토큰은 오래 유지되지만 만료·폐기될 수 있어 +// 401 이 오면 한 번 다시 로그인한다. 토큰은 메모리에만 둔다. +// - 접속 정보는 관리자 콘솔 › 연동 키 의 "Openprovider" 항목(lib/vault.js, 종류 openprovider)에 암호화 보관한다: +// 주소(기본 https://api.openprovider.eu/v1beta), 설정 USERNAME=…, 키(secret)=비밀번호. 한 번 넣으면 화면에 다시 보이지 않는다. +// - 시험용 대체 주소: 환경 변수 OPENPROVIDER_API_BASE (모의 서버) +// 응답 형식: { code: 0, desc, data } — code 가 0 이 아니면 오류(desc). 여기서는 오류를 한국어 안내와 함께 던진다. +// ============================================================================ +const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s }); +const DEFAULT_BASE = 'https://api.openprovider.eu/v1beta'; + +let tokenCache = { key: '', token: '', at: 0 }; // 계정(사용자 이름+주소)별 토큰 1개, 6시간마다 새로 받음 + +/** 보관소에서 접속 정보 읽기. 없으면 null (화면에서 "관리자 설정 필요" 안내) */ +function creds() { + let x = null; try { x = require('./vault').resolve('openprovider'); } catch (_) {} + const user = x && x.settings && (x.settings.USERNAME || x.settings.OPENPROVIDER_USERNAME); + if (!x || !x.secret || !user) return null; + return { base: (process.env.OPENPROVIDER_API_BASE || x.url || DEFAULT_BASE).replace(/\/+$/, ''), username: user, password: x.secret, nsGroup: x.settings.NS_GROUP || 'dns-openprovider' }; +} +const ready = () => !!creds(); + +async function login(c, force) { + const key = `${c.base}|${c.username}`; + if (!force && tokenCache.key === key && tokenCache.token && Date.now() - tokenCache.at < 6 * 3600000) return tokenCache.token; + let r; try { r = await fetch(`${c.base}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: c.username, password: c.password, ip: '0.0.0.0' }), signal: AbortSignal.timeout(20000) }); } catch (e) { throw httpError(`Openprovider 에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); } + const j = await r.json().catch(() => ({})); + if (!r.ok || j.code || !j.data || !j.data.token) throw httpError(`Openprovider 로그인 실패 (${j.desc || r.status}) — 관리자 콘솔 › 연동 키 의 Openprovider 사용자 이름·비밀번호를 확인하세요`, 502); + tokenCache = { key, token: j.data.token, at: Date.now() }; return j.data.token; +} +/** + * API 호출. 401 이면 다시 로그인해 한 번 더 시도한다. + * @param {string} method GET|POST|PUT|DELETE + * @param {string} path '/domains/check' 처럼 버전 뒤 경로 + * @param {object} [body] JSON 본문 + * @param {object} [query] 쿼리 문자열 값 + */ +async function call(method, path, body, query) { + const c = creds(); if (!c) throw httpError('도메인 서비스가 아직 준비되지 않았습니다. 관리자에게 문의하세요 (관리자: 연동 키에 Openprovider 접속 정보를 넣어야 합니다)', 503); + const qs = query ? '?' + new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString() : ''; + for (let attempt = 0; attempt < 2; attempt++) { + const token = await login(c, attempt > 0); + let r; try { r = await fetch(`${c.base}${path}${qs}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(60000) }); } catch (e) { throw httpError(`Openprovider 에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); } + if (r.status === 401 && attempt === 0) continue; + const j = await r.json().catch(() => ({})); + if (!r.ok || (j.code && j.code !== 0)) throw Object.assign(httpError(`도메인 서비스 오류: ${j.desc || `HTTP ${r.status}`}`, r.status >= 500 ? 502 : 400), { opCode: j.code, opData: j.data }); + return j.data; + } + throw httpError('Openprovider 인증이 거부되었습니다', 502); +} +/** "example.com" → { name: 'example', extension: 'com' } (co.kr 같은 2단계 확장자 포함) */ +function split(fqdn) { + const d = String(fqdn || '').trim().toLowerCase().replace(/\.$/, ''); + const m = d.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.((?:co|or|ne|go|ac|re|pe)\.kr|com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,24})$/); + if (!m) throw httpError('도메인 이름은 영문 소문자·숫자·하이픈으로 된 example.com 형식이어야 합니다'); + return { name: m[1], extension: m[2] }; +} + +/** + * 등록 가능 여부와 가격 (여러 개 한 번에). + * @returns {Array<{domain, status: 'free'|'active'|…, premium: boolean, price: {currency, amount}|null, reason}>} + * price 는 우리(리셀러)가 내는 가격(reseller) — 사용자 청구는 lib/domains.js 가 마진을 붙인다 + */ +async function check(fqdns) { + const data = await call('POST', '/domains/check', { domains: fqdns.map(split), with_price: true }); + return ((data && data.results) || []).map((x) => { + const pr = (x.price && (x.price.reseller || x.price.product)) || null; const prem = x.premium && x.premium.price && (x.premium.price.reseller || x.premium.price.product); + const use = x.is_premium && prem ? prem : pr; + return { domain: x.domain, status: x.status, premium: !!x.is_premium, price: use && Number.isFinite(Number(use.price)) ? { currency: use.currency || 'EUR', amount: Number(use.price) } : null, reason: x.reason || '' }; + }); +} +/** 작업별 가격 (create|renew|transfer, 기간 년) — 리셀러 가격 */ +async function price(fqdn, operation = 'create', period = 1) { + const { name, extension } = split(fqdn); + const data = await call('GET', '/domains/prices', null, { 'domain.name': name, 'domain.extension': extension, operation, period }); + const g = data && data.price; const p = g && (g.reseller || g.product); + if (!p || !Number.isFinite(Number(p.price))) throw httpError('도메인 가격을 받지 못했습니다', 502); + return { currency: p.currency || 'EUR', amount: Number(p.price), premium: !!(data && data.is_premium) }; +} +/** + * 등록자(소유자) 연락처 만들기 → handle (XX123456-XX). 도메인 등록에 필요하다. + * @param {object} c { firstName, lastName, company, email, phoneCountry('+82'), phoneArea, phoneNumber, street, number, zipcode, city, state, country('KR') } + */ +async function createCustomer(c) { + const body = { name: { first_name: c.firstName, last_name: c.lastName, full_name: `${c.firstName} ${c.lastName}`.trim() }, company_name: c.company || undefined, email: c.email, + phone: { country_code: c.phoneCountry || '+82', area_code: c.phoneArea || '', subscriber_number: c.phoneNumber }, + address: { street: c.street, number: c.number || '', zipcode: c.zipcode, city: c.city, state: c.state || '', country: (c.country || 'KR').toUpperCase() } }; + const data = await call('POST', '/customers', body); + if (!data || !data.handle) throw httpError('등록자 정보를 만들지 못했습니다', 502); return data.handle; +} +/** + * 도메인 등록 (Openprovider 네임서버 그룹으로 — DNS 를 이 서비스가 관리할 수 있게). + * @returns {{ id: number, status: string, expiresAt: string|null }} + */ +async function createDomain(fqdn, { handle, period = 1, autorenew = 'off' } = {}) { + const c = creds(); const data = await call('POST', '/domains', { domain: split(fqdn), period, unit: 'y', owner_handle: handle, admin_handle: handle, tech_handle: handle, billing_handle: handle, ns_group: (c && c.nsGroup) || 'dns-openprovider', autorenew, is_private_whois_enabled: true }); + return { id: data.id, status: data.status || 'REQ', expiresAt: data.expiration_date || null }; +} +async function renewDomain(id, fqdn, period = 1) { const data = await call('POST', `/domains/${id}/renew`, { id, domain: split(fqdn), period }); return { status: (data && data.status) || 'ACT' }; } +async function getDomain(id) { return call('GET', `/domains/${id}`); } + +// ---------- DNS 영역 ---------- +/** 영역 만들기 (없을 때). records: [{name:'', type:'A', value, ttl}] — name '' = 도메인 자신(@) */ +async function createZone(fqdn, records) { return call('POST', '/dns/zones', { domain: split(fqdn), type: 'master', records: records.map(rec) }); } +async function getZone(fqdn) { try { return await call('GET', `/dns/zones/${encodeURIComponent(fqdn)}`); } catch (e) { if (e.status === 400 || e.status === 404) return null; throw e; } } +async function listRecords(fqdn) { const data = await call('GET', `/dns/zones/${encodeURIComponent(fqdn)}/records`, null, { limit: 500 }); return ((data && data.results) || []).map((r) => ({ name: r.name, type: r.type, value: r.value, ttl: r.ttl, prio: r.prio })); } +/** 레코드 더하기·빼기 (영역의 NS·SOA 는 Openprovider 가 관리하므로 건드리지 않는다) */ +async function updateRecords(fqdn, { add = [], remove = [] }) { return call('PUT', `/dns/zones/${encodeURIComponent(fqdn)}`, { domain: split(fqdn), name: fqdn, type: 'master', records: { add: add.map(rec), remove: remove.map(rec) } }); } +/** 레코드 이름을 영역 기준(상대 이름)으로: 'www.example.com' → 'www', 'example.com' → '' */ +const rec = (r) => ({ name: r.name || '', type: r.type, value: String(r.value), ttl: Number(r.ttl) || 900, ...(r.prio !== undefined && r.prio !== null && r.prio !== '' ? { prio: Number(r.prio) } : {}) }); + +module.exports = { ready, creds, call, split, check, price, createCustomer, createDomain, renewDomain, getDomain, createZone, getZone, listRecords, updateRecords, DEFAULT_BASE }; diff --git a/modules/webmaker/lib/passpolicy.js b/modules/webmaker/lib/passpolicy.js new file mode 100644 index 0000000..bd4e02e --- /dev/null +++ b/modules/webmaker/lib/passpolicy.js @@ -0,0 +1,51 @@ +'use strict'; +// ============================================================================ +// 비밀번호 규칙 (v1.3.17) +// +// 서비스 계정(빌더 사용자·관리자)과 사이트 관리자 계정의 비밀번호 규칙을 한곳에서 정한다. +// 관리자 콘솔 › 설정 › "비밀번호 규칙" 에서 바꾸며(settings.passwordPolicy), 기본값은 1.3.16 까지의 규칙 +// "6자 이상" 그대로다(기존 사용자·화면 동작이 바뀌지 않게). 규칙은 새로 정하는 비밀번호(가입·변경·재설정)에만 적용하고, +// 이미 쓰고 있는 비밀번호로 로그인하는 것은 막지 않는다. +// ============================================================================ +const DEFAULT = { minLength: 6, maxLength: 256, requireLetter: false, requireDigit: false, requireSymbol: false, requireMixedCase: false, forbidId: false }; +const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s }); + +/** 지금 규칙 (설정 파일 값 + 기본값). settings 를 늦게 불러 순환 의존을 피한다 */ +function current() { + let p = {}; try { p = require('./settings').load().passwordPolicy || {}; } catch (_) {} + return normalize(p); +} +/** 관리자 입력 정리: 최소 길이 4~64, 최대 길이 최소 길이 이상 256 이하, 나머지는 켜기/끄기 */ +function normalize(p) { + p = p || {}; const on = (v) => v === true || v === 'true' || v === 'on' || v === 1 || v === '1'; + const min = Math.round(Number(p.minLength)); const max = Math.round(Number(p.maxLength)); + const out = { ...DEFAULT }; + if (Number.isFinite(min)) { if (min < 4 || min > 64) throw httpError('비밀번호 최소 길이는 4~64 사이로 정하세요'); out.minLength = min; } + if (Number.isFinite(max)) { if (max < out.minLength || max > 256) throw httpError('비밀번호 최대 길이는 최소 길이 이상 256 이하로 정하세요'); out.maxLength = max; } + for (const k of ['requireLetter', 'requireDigit', 'requireSymbol', 'requireMixedCase', 'forbidId']) if (p[k] !== undefined) out[k] = on(p[k]); + return out; +} +/** 규칙 설명 문장 (오류 메시지·화면 안내) */ +function describe(pol = current()) { + const parts = [`${pol.minLength}자 이상`]; + if (pol.maxLength < 256) parts.push(`${pol.maxLength}자 이하`); + if (pol.requireLetter) parts.push('영문자 포함'); if (pol.requireMixedCase) parts.push('대문자·소문자 모두 포함'); if (pol.requireDigit) parts.push('숫자 포함'); if (pol.requireSymbol) parts.push('특수문자 포함'); if (pol.forbidId) parts.push('아이디를 포함하지 않음'); + return parts.join(', '); +} +/** + * 새 비밀번호 검사. 맞지 않으면 어떤 규칙인지 적은 오류를 던진다. + * @param {string} pw + * @param {object} [ctx] { id: 아이디(forbidId 검사), label: 오류 문장 앞말('비밀번호'·'새 비밀번호') } + */ +function check(pw, ctx = {}) { + const pol = current(); const s = String(pw || ''); const label = ctx.label || '비밀번호'; + const fail = () => { throw httpError(`${label}는 ${describe(pol)} 이어야 합니다`); }; + if (s.length < pol.minLength || s.length > pol.maxLength) fail(); + if (pol.requireLetter && !/[A-Za-z]/.test(s)) fail(); + if (pol.requireMixedCase && !(/[a-z]/.test(s) && /[A-Z]/.test(s))) fail(); + if (pol.requireDigit && !/\d/.test(s)) fail(); + if (pol.requireSymbol && !/[^A-Za-z0-9]/.test(s)) fail(); + if (pol.forbidId && ctx.id && s.toLowerCase().includes(String(ctx.id).toLowerCase())) fail(); + return true; +} +module.exports = { DEFAULT, current, normalize, describe, check }; diff --git a/modules/webmaker/lib/planner.js b/modules/webmaker/lib/planner.js new file mode 100644 index 0000000..33844d0 --- /dev/null +++ b/modules/webmaker/lib/planner.js @@ -0,0 +1,222 @@ +'use strict'; +// AI 기획 단계: 요구 분석/이해 확인, 구조 제안, 디자인 3안, 결과 검증, Brief 선갱신 수정. +// 각 단계는 Claude 헤드리스 단발 호출로 JSON 을 받아 온다(파일 쓰기 도구 없음). +const { runModel } = require('./run'); +const brief = require('./brief'); + +const PLAN_TOOLS = 'Read,Glob,Grep,WebFetch'; +const PLAN_SYSTEM = `당신은 웹사이트 기획 컨설턴트입니다. 사용자와 대화할 수 없으므로 질문 대신 결과를 내야 합니다. +반드시 마지막 답변을 하나의 JSON 객체로만 출력합니다(설명 문장, 마크다운 코드펜스 금지). JSON 문자열 안의 한국어는 그대로 씁니다. +사실정보(전화·주소·영업시간·가격·자격·후기)는 사용자가 제공한 텍스트·첨부 파일·기존 사이트에서 확인된 것만 추출하고 출처를 표시합니다. 추측으로 만들어내지 않습니다.`; + +/** + * 문자열·이스케이프를 고려해 균형 잡힌 최상위 JSON 객체 후보들을 찾는다 (v1.3.10). + * 1.3.5 까지는 "첫 { 부터 마지막 } 까지" 를 잘라 파싱해, 모델이 JSON 뒤에 설명이나 두 번째 JSON 을 붙이면 + * "Unexpected non-whitespace character after JSON" 으로 실패했다(webmaker SEO 초안 오류). + * @returns {string[]} 나타난 순서대로의 {…} 조각 + */ +function jsonCandidates(text) { + const out = []; + for (let i = 0; i < text.length; i++) { + if (text[i] !== '{') continue; + let depth = 0, inStr = false, esc = false, j = i; + for (; j < text.length; j++) { + const c = text[j]; + if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; } + if (c === '"') inStr = true; else if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) break; } + } + if (depth === 0 && j < text.length) { out.push(text.slice(i, j + 1)); i = j; } // 다음 후보는 이 객체 뒤에서부터 + } + return out; +} +/** + * 흔한 형식 실수 고치기: 끝 쉼표, 문자열 안의 실제 줄바꿈·탭, 스마트 따옴표로 감싼 키. + * v1.3.17 (B17): 모든 수정은 문자열 "밖" 에서만 한다. 예전에는 끝 쉼표·스마트 따옴표를 문서 전체에 정규식으로 바꿔 + * "literal,]" 같은 문자열 값까지 바뀌었다. + */ +function repairJson(t) { + let out = ''; let inStr = false; let esc = false; let smart = false; // smart: 스마트 따옴표(“ ”)로 연 문자열 — 닫는 것도 스마트 따옴표로 받는다 + for (let c of t) { + if (inStr) { + if (esc) { esc = false; out += c; continue; } + if (c === '\\') { esc = true; out += c; continue; } + if (c === '"' || (smart && c === '”')) { inStr = false; smart = false; out += '"'; continue; } + if (c === '\n') { out += '\\n'; continue; } if (c === '\r') continue; if (c === '\t') { out += '\\t'; continue; } + out += c; continue; // 보통 문자열 안의 스마트 따옴표는 글자 그대로 + } + if (c === '“' || c === '”') { inStr = true; smart = true; out += '"'; continue; } // 문자열 밖의 스마트 따옴표 = 따옴표로 쓴 것 + if (c === '"') { inStr = true; out += c; continue; } + if (c === '}' || c === ']') out = out.replace(/,\s*$/, ''); // 끝 쉼표 (문자열 밖) + out += c; + } + return out; +} +/** + * AI 응답에서 JSON 객체 하나를 꺼낸다. + * 순서: 코드펜스 안 → 균형 잡힌 후보 중 파싱되는 가장 큰 것 → 고친 뒤 다시 시도. 모두 실패하면 원문 일부를 오류에 담는다. + */ +function extractJson(text) { + if (!text) throw new Error('AI 응답이 비어 있습니다'); + const pools = []; const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map((m) => m[1]); pools.push(...fences, text); + let firstErr = null; + for (const src of pools) { + const cands = jsonCandidates(src).sort((a, b) => b.length - a.length); // 큰 것(본문 JSON) 먼저 — 설명 속 작은 {…} 보다 우선 + for (const c of cands) { for (const t of [c, repairJson(c)]) { try { const v = JSON.parse(t); if (v && typeof v === 'object') return v; } catch (e) { if (!firstErr) firstErr = e; } } } + } + const err = new Error('AI 응답 JSON 파싱 실패: ' + (firstErr ? firstErr.message : 'JSON 객체를 찾지 못했습니다')); + err.rawText = String(text).slice(0, 20000); throw err; +} +/** 두 실행 결과의 사용량·비용 합치기 (JSON 고침 재요청 비용까지 청구에 넣기 위해) */ +function mergeResults(a, b) { + const u = { ...a.usage }; for (const [k, v] of Object.entries(b.usage || {})) if (typeof v === 'number') u[k] = (u[k] || 0) + v; + return { ...a, usage: u, costUsd: (a.costUsd || 0) + (b.costUsd || 0), numTurns: (a.numTurns || 0) + (b.numTurns || 0), durationMs: (a.durationMs || 0) + (b.durationMs || 0) }; +} + +async function runJson({ siteDir, prompt, onEvent, onStart, ref, userId, toolset = 'read' }) { // toolset 'none': 파일을 읽지 않는 짧은 호출 (v1.3.10 다국어 번역) + const r = await runModel({ ref, userId, siteDir, prompt, systemPrompt: PLAN_SYSTEM, toolset, noPersist: true, onEvent, onStart }); + if (!r.ok) throw Object.assign(new Error(`AI 호출 실패 (${r.subtype})`), { result: r }); + try { return { data: extractJson(r.resultText), result: r }; } + catch (e) { + // v1.3.10: 그래도 못 읽으면 같은 모델에 "이 텍스트를 올바른 JSON 하나로만" 고쳐 달라고 한 번만 다시 요청한다 + // (파일을 다시 읽지 않는 짧은 호출이라 비용이 작다). 그 비용도 이번 작업 청구에 합친다. + // v1.3.17 (B18): 고침 호출이 실패·취소되면 그 상태(cancelled 등)와 사용량을 그대로 넘긴다 — 첫 호출 결과로 덮어 성공처럼 보이거나 + // 고침 호출 비용이 청구에서 빠지던 문제. 고침 호출이 ok 가 아니면 그 응답의 JSON 은 쓰지 않는다. + const fixPrompt = `아래 텍스트는 JSON 하나여야 하는데 형식이 깨졌습니다. 내용은 바꾸지 말고 올바른 JSON 객체 하나로만 다시 출력하세요(설명·코드펜스 금지). 중간에 잘렸으면 마지막 항목을 빼고 닫으세요.\n\n${String(r.resultText || '').slice(0, 60000)}`; + let r2; + try { r2 = await runModel({ ref, userId, siteDir, prompt: fixPrompt, systemPrompt: PLAN_SYSTEM, toolset: 'none', noPersist: true, onEvent, onStart }); } + catch (e2) { + // 취소·실행 오류: 그 오류를 그대로 던지되, 첫 호출 사용량을 함께 담아 청구가 빠지지 않게 한다 + if (e2 && e2.usage && r.usage) { const u = { ...r.usage }; for (const [k, v] of Object.entries(e2.usage)) if (typeof v === 'number') u[k] = (u[k] || 0) + v; e2.usage = u; } else if (e2 && !e2.usage) e2.usage = r.usage; + throw Object.assign(e2, { result: e2.result ? mergeResults(r, e2.result) : r, rawText: e.rawText }); + } + const merged = mergeResults(r, r2 || {}); // 성공 여부와 관계없이 사용량·비용은 합친다 + if (!r2 || !r2.ok) throw Object.assign(new Error(`AI 응답 JSON 고침 실패 (${(r2 && r2.subtype) || 'error'})`), { result: merged, rawText: e.rawText }); + try { return { data: extractJson(r2.resultText), result: merged, repaired: true }; } catch (e3) { throw Object.assign(e, { result: merged }); } + } +} + +function refsText(refs, fetched) { + if (!refs || !refs.length) return ''; + const byUrl = new Map((fetched || []).map((f) => [f.url, f])); + const lines = refs.map((r, i) => { const f = byUrl.get(r.url); return `${i + 1}. ${r.url}${r.notes ? ` — 참고/반영할 점: ${r.notes}` : ''}${f ? (f.ok ? ` → 본문 텍스트가 ${f.file} 에 저장되어 있음 (Read 로 확인)` : ` → 가져오기 실패(${f.error}); 사용자가 적은 참고 포인트만 반영`) : ''}`; }); + return '[참고 사이트]\n' + lines.join('\n') + '\n저장된 텍스트 파일을 Read 로 읽어 구성·메뉴·문구 스타일을 파악하고, 사용자가 적은 참고 포인트를 반영 방안에 포함하세요. WebFetch 는 저장 파일이 없거나 부족할 때만 보조로 사용하고, 실패하면 그냥 넘어갑니다.\n'; +} +function attachText(atts) { + if (!atts || !atts.length) return ''; + return '[첨부 파일] (Read 로 내용 확인)\n' + atts.map((a) => `- ${a.rel} (${a.kind}${a.textSidecar ? `, 텍스트 추출본 ${a.textSidecar}` : ''})`).join('\n') + '\n'; +} +const CAT = brief.catalog(); +const industryList = CAT.INDUSTRIES.map((i) => `${i.major}: ${i.minors.join(', ')}`).join('\n'); + +/** 1~2단계: 요구 분석 → 이해 확인 카드 */ +/** 사용자가 시작 화면에서 고른 디자인 컨셉(주 색상·톤·방향) 을 프롬프트용 문장으로 */ +function designText(inp) { + const d = (inp && inp.design) || {}; const parts = []; + if (d.color) parts.push(`주 색상 ${d.color}`); if (d.tone) parts.push(`톤 ${d.tone}`); if (d.direction) parts.push(`방향: ${d.direction}`); + return parts.length ? parts.join(' · ') : '(없음 — 업종과 고객에 맞게 추천)'; +} +function analyzePrompt(site) { + const inp = site.plan.input; + return `다음 요구를 분석해 웹사이트 기획의 "이해 확인 카드"를 JSON 으로 작성하세요. + +[사용자 요구(자유 서술)] +${inp.text || '(없음)'} + +[디자인 요구 (사용자 입력)] +${designText(inp)} +[기존 사이트 URL] +${inp.existingUrl || '(없음)'}${inp.existingUrl ? (() => { const f = (site.plan.refsFetched || []).find((x) => x.existing); return f && f.ok ? ` — 본문 텍스트가 ${f.file} 에 저장되어 있음. Read 로 읽어 사업 정보와 사실정보(출처 url:${inp.existingUrl})를 추출` : ' — 가져오기 실패. 사용자 서술만 근거로 함'; })() : ''} + +${attachText(site.attachments)}${refsText(inp.references, site.plan.refsFetched)}${inp.answers && inp.answers.length ? '[추가 답변]\n' + inp.answers.map((a) => `Q: ${a.q}\nA: ${a.a}`).join('\n') + '\n' : ''} +[업종 분류표] (major 는 아래 대분류 문자열 중 하나, minor 는 그 소분류 중 하나) +${industryList} + +[규제 업종] medical(병원·의원·치과·한의원 등), legal(변호사·세무·법무 등), finance(금융·대부). 해당하면 regulated.category 에 표기. + +출력 JSON 형식: +{ + "business": {"name": "", "industry": {"major": "", "minor": ""}, "region": {"sido": "", "sigungu": ""}, "description": "한 줄 사업 요약"}, + "customers": {"types": ["..."], "ages": ["20대","30대"]}, + "goals": ["inquiry|booking|intro|portfolio|news|sales" 중 복수], + "features": {"contactForm": bool, "booking": bool, "board": bool, "gallery": bool, "map": bool, "reviews": bool, "faq": bool}, + "recommendedStyle": {"tone": "minimal|modern|warm|luxury|vivid", "reason": ""}, + "regulated": {"flag": bool, "category": "medical|legal|finance|null"}, + "facts": {"phone": {"value": "", "source": "user_text|attachment:<파일명>|url:<주소>|null", "status": "provided|missing"}, "address": {...}, "hours": {...}, "prices": {...}, "credentials": {...}, "reviews": {...}}, + "origin": {"business.name": "user|ai", "business.industry": "user|ai", "goals": "user|ai", "features": "user|ai", "customers": "user|ai", "recommendedStyle": "ai"}, + "referenceInsights": [{"url": "", "adopt": "이 사이트에서 반영할 점 요약"}], + "questions": ["업종이나 목표가 불명확할 때만 1~2개, 아니면 빈 배열"] +} +origin 은 사용자가 명시한 값이면 "user", 추론이면 "ai" 로 표시합니다. 사실정보가 확인되지 않으면 value 를 비우고 status 를 "missing" 으로 둡니다.`; +} + +/** 3단계: 구조 제안 */ +/** 사용자가 고른 페이지 수(plan.input.pageCount, 0=자동) → 구조 제안 규칙 문장 */ +function pageRule(site) { + const n = parseInt((site.plan.input || {}).pageCount, 10) || 0; + if (n === 1) return '페이지는 정확히 1개(원페이지 사이트): 모든 내용을 home 한 페이지의 섹션으로 구성하고 내비게이션은 섹션 앵커(#id)로.'; + if (n > 1) return `페이지는 정확히 ${n}개(home 포함).`; + return '페이지 3~6개.'; +} +function structurePrompt(site) { + const b = site.plan.brief; + return `아래 Brief(부분)를 바탕으로 웹사이트 페이지 트리와 섹션 구성을 제안하세요. + +[Brief] +${JSON.stringify({ business: b.business, goals: b.goals, features: b.features, customers: b.customers, regulated: b.regulated, facts: Object.fromEntries(Object.entries(b.facts).map(([k, v]) => [k, v.status])) }, null, 1)} + +${refsText(site.plan.input.references, site.plan.refsFetched)} +[섹션 ID 목록] ${Object.entries(CAT.SECTIONS).map(([k, v]) => `${k}(${v.label}${v.feature ? `, 기능:${v.feature}` : ''})`).join(', ')} +[목표 ID] ${CAT.GOALS.map((g) => `${g.id}(${g.label})`).join(', ')} + +규칙: 켜진 기능(features=true)은 반드시 어떤 페이지의 섹션에 배치. 꺼진 기능의 섹션은 넣지 않음. 규제 업종 제약(예: 의료 후기 금지) 준수. 첫 페이지는 id "home". ${pageRule(site)} 페이지당 섹션 3~7개. +출력 JSON: +{"pages": [{"id": "home", "title": "홈", "purpose": "이 페이지의 역할", "sections": [{"id": "hero", "title": "섹션 제목", "goal": "inquiry|booking|intro|portfolio|news|sales|null", "feature": "기능ID|null", "note": "무엇을 담는지 한 줄"}]}], "rationale": "구조 선택 이유 2~3문장"}`; +} + +/** 4단계: 디자인 3안 */ +function designPrompt(site) { + const b = site.plan.brief; + return `아래 Brief 로 만들 웹사이트의 디자인 방향 3안을 제안하세요. 2안 이상은 서로 뚜렷이 달라야 하며, 첫 번째 안은 추천 톤(${b.design.recommendedTone || '없음'})을 따릅니다. +[사용자 디자인 요구] ${designText(site.plan.input)} — 요구가 있으면 첫 번째 안(추천안)은 반드시 이 색상·톤·방향을 반영하고 palette.colors 의 포인트 색을 주 색상으로 씁니다. + +[Brief 요약] ${JSON.stringify({ business: b.business, goals: b.goals, customers: b.customers, recommendedTone: b.design.recommendedTone, pages: b.pages.map((p) => p.id) })} +${refsText(site.plan.input.references, site.plan.refsFetched)} +[톤 ID] ${CAT.TONES.map((t) => `${t.id}(${t.label}: ${t.desc})`).join(' / ')} +[폰트 세트 ID] ${CAT.FONT_SETS.map((f) => `${f.id}(${f.label})`).join(' / ')} + +출력 JSON: +{"options": [{"id": "A", "name": "안 이름", "tone": "톤ID", "palette": {"name": "", "colors": ["#텍스트", "#보조", "#배경", "#포인트"]}, "fonts": "폰트세트ID", "density": "wide|standard|dense", "imagery": "photo|illustration", "summary": "한두 문장", "heroIdea": "히어로 구성 아이디어"}, {...}, {...}], "recommended": "A"}`; +} + +/** 7/11단계: 결과 ↔ Brief 대조 (AI) */ +function verifyPrompt(site) { + const b = site.plan.brief; + const ml = site.langs && site.langs.list && site.langs.list.length > 1 ? `다국어 사이트입니다 — 기본 언어 폴더 ${site.langs.default}/ 의 파일만 검사하세요(다른 언어 폴더는 서비스가 번역해 만든 사본입니다).\n` : ''; // v1.3.10 + return `현재 디렉터리의 생성된 웹사이트 파일(Read/Glob 으로 확인)을 아래 Brief 와 대조하세요. +${ml} +[Brief] +${JSON.stringify(b, null, 1)} + +검사 항목: (1) 목표별 CTA 존재 (2) 켜진 기능별 섹션 존재 (3) 페이지별 파일 존재와 섹션 반영 (4) 디자인(톤·팔레트·폰트·밀도) 반영 (5) 어조·언어 준수 (6) Brief 에 없는 사실정보(전화·주소·가격·후기)가 만들어졌는지 (7) 규제 제약 위반 표현. +출력 JSON: +{"items": [{"category": "cta|feature|page|style|copy|facts|regulation", "name": "", "status": "pass|fail|partial", "detail": "근거(파일명 포함)"}], "score": 0~100, "fixes": ["실패 항목을 고치기 위한 구체 지시 문장"]}`; +} + +/** 8/12단계: 채팅 수정 — Brief 선갱신 후 해당 부분만 재생성 (파일 도구 사용, 최종 답변에 JSON 포함) */ +function editPrompt(site, instruction, attachments) { + const b = site.plan.brief; + const history = require('./prompt').historySection(site); + return `현재 디렉터리의 웹사이트를 아래 지시에 따라 수정합니다. 순서: (1) 지시가 Brief 의 어떤 항목을 바꾸는지 판단해 Brief 를 먼저 갱신 (2) 갱신된 Brief 에 맞춰 영향을 받는 파일/섹션만 수정 (3) 영향 없는 부분은 건드리지 않음. + +${history} +[수정 지시] +${instruction} + +[현재 Brief] +${JSON.stringify(b, null, 1)} +${attachments} +규칙: 사실정보는 Brief 값만 사용(지시에 새 사실정보가 있으면 Brief.facts 에 source:"user_text", status:"provided" 로 반영 후 사용). 없으면 "[입력 필요]". 가짜 후기 금지. 규제 제약 준수. +작업을 마친 뒤 최종 답변은 다음 형식으로만: 첫 줄부터 3줄 이내 한국어 요약, 그 다음 줄에 갱신된 Brief 전체 JSON. Brief 가 바뀌지 않았어도 전체를 다시 출력합니다.`; +} + +module.exports = { jsonCandidates, repairJson, mergeResults, runJson, analyzePrompt, structurePrompt, designPrompt, verifyPrompt, editPrompt, extractJson }; diff --git a/modules/webmaker/lib/prepaid.js b/modules/webmaker/lib/prepaid.js new file mode 100644 index 0000000..b9fd7f5 --- /dev/null +++ b/modules/webmaker/lib/prepaid.js @@ -0,0 +1,154 @@ +'use strict'; +/* ============================================================================ + * 선불 충전(선불금) 상품 · 주문 (v1.6.40, 요구 1) + * + * 비용은 지금처럼 선불 적립금(캐시, lib/credits.js)으로 쓴다. 이 모듈은 그 캐시를 "사용자가 스스로 사는" 길을 만든다. + * - 상품(package): 관리자 콘솔 › 회원·청구 › 선불 충전 상품 에서 만든다. 금액(원)과 추가 인센티브(정률 % 또는 정액 원)를 정하며, + * 적립되는 캐시 = 결제 금액 + 인센티브 (creditOf). 비활성(active=false) 상품은 사용자에게 보이지 않지만 지난 주문 기록은 그대로 남는다. + * - 주문(order): 사용자가 캐시·충전 화면에서 상품을 골라 "결제 진행" 을 누르면 pending 주문이 생긴다. + * **PG(결제 대행)가 아직 연동되지 않아 실제 결제는 일어나지 않는다** — 주문은 "결제 대기" 로 남고, 관리자가 입금(결제) 확인 뒤 + * markPaid 로 적립하면 credits.apply(add, 적립 금액) 가 원장에 기록된다(ref: { orderId } 로 어느 주문의 적립인지 남긴다). + * PG 가 붙으면 결제 콜백에서 markPaid 를 부르는 구조로 그대로 이어 쓸 수 있다. + * + * 저장: data/prepaid.json = { packages: [...], orders: [...] } — jsonstore 로 원자적 쓰기 + .bak 보관(깨지면 .bak 으로 읽는다). + * package = { id, name, amount, bonusType: 'percent'|'fixed', bonusValue, active, sort, note, createdAt, updatedAt } + * order = { id, userId, packageId, name, amount, bonus, credit, status: 'pending'|'paid'|'cancelled', at, paidAt, cancelledAt, by, memo } + * ========================================================================== */ +const path = require('path'); +const crypto = require('crypto'); +const js = require('./jsonstore'); + +const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data'); +const FILE = path.join(DATA_ROOT, 'prepaid.json'); +/** 사용자 한 명이 동시에 열어 둘 수 있는 결제 대기 주문 수 — 버튼을 연달아 눌러 주문이 쌓이는 것을 막는다 */ +const MAX_PENDING_PER_USER = 5; +/** 상품 금액 범위(원): 너무 작은 값·입력 실수로 생긴 거대 금액을 막는다 */ +const AMOUNT_MIN = 1000, AMOUNT_MAX = 100000000; +const BONUS_TYPES = ['percent', 'fixed']; +const STATUS = ['pending', 'paid', 'cancelled']; + +function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); } +function load() { const d = js.readJson(FILE, null); return d && typeof d === 'object' ? { packages: Array.isArray(d.packages) ? d.packages : [], orders: Array.isArray(d.orders) ? d.orders : [] } : { packages: [], orders: [] }; } +function save(d) { js.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true }); } +const id = () => crypto.randomBytes(6).toString('hex'); +const now = () => new Date().toISOString(); + +/** + * 상품의 인센티브 금액(원). percent 는 결제 금액의 비율(소수점은 버림 — 원 단위), fixed 는 그대로. + * @param {object} p 상품 { amount, bonusType, bonusValue } + */ +function bonusOf(p) { + const amt = Math.round(Number(p.amount) || 0); const v = Number(p.bonusValue) || 0; + if (!(v > 0)) return 0; + return p.bonusType === 'percent' ? Math.floor(amt * v / 100) : Math.round(v); +} +/** 적립되는 캐시(원) = 결제 금액 + 인센티브 */ +function creditOf(p) { return Math.round(Number(p.amount) || 0) + bonusOf(p); } +/** 화면용 상품 (적립 금액·인센티브 금액 계산값 포함) */ +function pub(p) { return { ...p, bonus: bonusOf(p), credit: creditOf(p) }; } + +/** + * 상품 목록. 기본은 사용자 화면용(활성 상품만, sort → amount 순). all=true 면 관리자용 전체. + */ +function listPackages({ all = false } = {}) { + const d = load(); + return d.packages.filter((p) => all || p.active !== false).sort((a, b) => (a.sort || 0) - (b.sort || 0) || a.amount - b.amount).map(pub); +} +function getPackage(pid) { const p = load().packages.find((x) => x.id === pid); return p ? pub(p) : null; } + +/** + * 상품 만들기·고치기 (관리자). input.id 가 있으면 수정, 없으면 새 상품. + * 검증: 이름 1~40자, 금액 AMOUNT_MIN~AMOUNT_MAX 의 정수(원), 인센티브 종류 percent|fixed, 값 0 이상(정률은 500% 까지, 정액은 금액의 10배까지). + * @param {object} input { id?, name, amount, bonusType, bonusValue, active, sort, note } + * @param {string} by 처리한 관리자 ID (기록용) + */ +function savePackage(input, by) { + const d = load(); const cur = input.id ? d.packages.find((p) => p.id === input.id) : null; + if (input.id && !cur) throw httpError('상품이 없습니다', 404); + const name = String(input.name == null ? (cur ? cur.name : '') : input.name).trim(); + if (!name || name.length > 40) throw httpError('상품 이름은 1~40자로 입력하세요'); + const amount = input.amount == null ? (cur ? cur.amount : NaN) : Math.round(Number(input.amount)); + if (!Number.isFinite(amount) || amount < AMOUNT_MIN || amount > AMOUNT_MAX) throw httpError(`금액은 ${AMOUNT_MIN.toLocaleString('ko-KR')}~${AMOUNT_MAX.toLocaleString('ko-KR')}원 사이 정수로 입력하세요`); + const bonusType = input.bonusType == null ? (cur ? cur.bonusType : 'percent') : String(input.bonusType); + if (!BONUS_TYPES.includes(bonusType)) throw httpError('인센티브 종류는 percent(정률) 또는 fixed(정액)이어야 합니다'); + const bonusValue = input.bonusValue == null || input.bonusValue === '' ? (cur ? cur.bonusValue : 0) : Number(input.bonusValue); + if (!Number.isFinite(bonusValue) || bonusValue < 0) throw httpError('인센티브는 0 이상 숫자로 입력하세요'); + if (bonusType === 'percent' && bonusValue > 500) throw httpError('정률 인센티브는 500% 까지입니다'); + if (bonusType === 'fixed' && bonusValue > amount * 10) throw httpError('정액 인센티브가 결제 금액의 10배를 넘습니다 — 입력을 확인하세요'); + const next = { + id: cur ? cur.id : id(), name, amount, bonusType, bonusValue: bonusType === 'percent' ? Math.round(bonusValue * 100) / 100 : Math.round(bonusValue), + active: input.active == null ? (cur ? cur.active !== false : true) : !!(input.active === true || input.active === 'true'), + sort: input.sort == null || input.sort === '' ? (cur ? cur.sort || 0 : d.packages.length) : Math.round(Number(input.sort) || 0), + note: String(input.note == null ? (cur ? cur.note || '' : '') : input.note).trim().slice(0, 200), + createdAt: cur ? cur.createdAt : now(), updatedAt: now(), updatedBy: by || null, + }; + if (cur) Object.assign(cur, next); else d.packages.push(next); + save(d); return pub(next); +} +/** + * 상품 삭제 (관리자). 주문 기록이 그 상품을 가리키면 지우지 않고 비활성으로 돌린다 — 지난 주문의 상품 정보가 사라지지 않게. + * @returns {{removed: boolean, deactivated: boolean, package: object}} + */ +function removePackage(pid) { + const d = load(); const p = d.packages.find((x) => x.id === pid); if (!p) throw httpError('상품이 없습니다', 404); + const referenced = d.orders.some((o) => o.packageId === pid); + if (referenced) { p.active = false; p.updatedAt = now(); save(d); return { removed: false, deactivated: true, package: pub(p) }; } + d.packages = d.packages.filter((x) => x.id !== pid); save(d); return { removed: true, deactivated: false, package: pub(p) }; +} + +/** + * 주문 만들기 (사용자). 활성 상품만, 결제 대기 주문은 MAX_PENDING_PER_USER 개까지. + * 주문에는 그 시점의 상품 이름·금액·인센티브를 복사해 둔다 — 나중에 상품을 고쳐도 주문 금액은 바뀌지 않는다. + */ +function createOrder(userId, packageId) { + if (!userId) throw httpError('로그인이 필요합니다', 401); + const d = load(); const p = d.packages.find((x) => x.id === packageId && x.active !== false); + if (!p) throw httpError('선택한 상품이 없거나 판매가 끝났습니다', 404); + const open = d.orders.filter((o) => o.userId === userId && o.status === 'pending'); + if (open.length >= MAX_PENDING_PER_USER) throw httpError(`결제 대기 주문이 ${MAX_PENDING_PER_USER}건 있습니다. 먼저 기존 주문을 취소하거나 결제를 마쳐 주세요`, 409); + const o = { id: id(), userId, packageId: p.id, name: p.name, amount: p.amount, bonusType: p.bonusType, bonusValue: p.bonusValue, bonus: bonusOf(p), credit: creditOf(p), status: 'pending', at: now(), paidAt: null, cancelledAt: null, by: null, memo: '' }; + d.orders.push(o); if (d.orders.length > 20000) d.orders.splice(0, d.orders.length - 20000); save(d); return { ...o }; +} +/** 내 주문 (최신순) */ +function myOrders(userId, limit = 50) { return load().orders.filter((o) => o.userId === userId).slice(-limit).reverse(); } +/** 관리자용 주문 목록 (최신순). filter.status 로 상태를 고를 수 있다 */ +function listOrders({ status, limit = 200 } = {}) { return load().orders.filter((o) => !status || o.status === status).slice(-limit).reverse(); } +function getOrder(oid) { return load().orders.find((o) => o.id === oid) || null; } + +/** + * 입금(결제) 확인 → 적립 (관리자, 또는 나중에 PG 콜백). 결제 대기 주문만 처리하며 캐시 원장에 add 로 기록한다. + * 같은 주문을 두 번 적립하지 못하게 상태를 먼저 바꾼 뒤 원장에 쓴다(원장 기록이 실패하면 상태를 되돌린다). + * @param {string} oid 주문 ID + * @param {string} by 처리자 (관리자 ID 또는 'pg:') + * @param {string} [memo] 메모(입금자명·승인번호 등. 비밀값은 넣지 않는다) + */ +function markPaid(oid, by, memo) { + const d = load(); const o = d.orders.find((x) => x.id === oid); if (!o) throw httpError('주문이 없습니다', 404); + if (o.status !== 'pending') throw httpError(`이미 ${o.status === 'paid' ? '적립된' : '취소된'} 주문입니다`, 409); + o.status = 'paid'; o.paidAt = now(); o.by = by || null; o.memo = String(memo || '').slice(0, 200); save(d); + try { + const credits = require('./credits'); + const r = credits.apply({ userId: o.userId, type: 'add', amount: o.credit, memo: `선불 충전 ${o.name} — 결제 ${o.amount.toLocaleString('ko-KR')}원${o.bonus ? ` + 인센티브 ${o.bonus.toLocaleString('ko-KR')}원` : ''}`, by: by || 'system', ref: { orderId: o.id } }); + return { order: { ...o }, balance: r.balance, entry: r.entry }; + } catch (e) { + const d2 = load(); const o2 = d2.orders.find((x) => x.id === oid); if (o2) { o2.status = 'pending'; o2.paidAt = null; o2.by = null; save(d2); } + throw e; + } +} +/** + * 주문 취소. 사용자는 자기 결제 대기 주문만(userId 를 넘긴다), 관리자는 어떤 결제 대기 주문이든(userId 생략). + * 이미 적립된(paid) 주문은 취소할 수 없다 — 캐시 조정은 관리자 콘솔의 캐시 조정으로 한다. + */ +function cancelOrder(oid, by, { userId, reason } = {}) { + const d = load(); const o = d.orders.find((x) => x.id === oid); if (!o || (userId && o.userId !== userId)) throw httpError('주문이 없습니다', 404); + if (o.status === 'paid') throw httpError('이미 적립된 주문은 취소할 수 없습니다. 캐시 조정은 관리자에게 요청하세요', 409); + if (o.status === 'cancelled') return { ...o }; + o.status = 'cancelled'; o.cancelledAt = now(); o.by = by || null; o.memo = String(reason || '').slice(0, 200); save(d); return { ...o }; +} +/** 관리자 요약: 상품 수·결제 대기 건수·금액 */ +function summary() { + const d = load(); const pending = d.orders.filter((o) => o.status === 'pending'); + return { packages: d.packages.length, activePackages: d.packages.filter((p) => p.active !== false).length, pendingOrders: pending.length, pendingAmount: pending.reduce((a, o) => a + o.amount, 0), paidOrders: d.orders.filter((o) => o.status === 'paid').length }; +} + +module.exports = { FILE, MAX_PENDING_PER_USER, AMOUNT_MIN, AMOUNT_MAX, BONUS_TYPES, STATUS, bonusOf, creditOf, listPackages, getPackage, savePackage, removePackage, createOrder, myOrders, listOrders, getOrder, markPaid, cancelOrder, summary }; diff --git a/modules/webmaker/lib/prompt.js b/modules/webmaker/lib/prompt.js new file mode 100644 index 0000000..a394332 --- /dev/null +++ b/modules/webmaker/lib/prompt.js @@ -0,0 +1,227 @@ +'use strict'; +// 사용자 입력과 첨부 파일 정보를 Claude 에 전달할 프롬프트로 정리한다. +const { humanSize } = require('./uploads'); +const fs = require('fs'); +const path = require('path'); +const WIDGET_TPL = fs.readFileSync(path.join(__dirname, '..', 'templates', 'contact-widget.html'), 'utf8'); + +const sitemsg = require('./sitemsg'); +/** + * 표준 문의 위젯 코드 (사이트별 엔드포인트 삽입). + * v1.3.16: 화면 글자는 코드에 두지 않고 시스템 문구 카탈로그(lib/sitemsg)에서 사이트 언어(lang)로 채운다. + * @param {string} [lang] 사이트 본문(기본) 언어 — 다국어 사이트는 기본 언어, 다른 언어 페이지는 번역 단계에서 바뀐다 + */ +function contactWidget(site, kind = 'contact', endpointBase, lang = 'ko') { + const endpoint = `${String(endpointBase || '').replace(/\/$/, '')}/api/forms/${site.id}/contact`; + const t = sitemsg.forLang(lang); + return sitemsg.renderTemplate(WIDGET_TPL.replace(/{{ENDPOINT}}/g, endpoint).replace(/{{SITE_ID}}/g, site.id).replace(/{{KIND}}/g, kind).replace(/{{MESSAGE_LABEL}}/g, t(kind === 'booking' ? 'contact.bookingMessageLabel' : 'contact.messageLabel')).replace(/{{BUTTON_LABEL}}/g, t(kind === 'booking' ? 'contact.bookingButton' : 'inquiry.button')), lang); +} +function widgetSection(site, endpointBase, opts = {}) { + if (!site || !endpointBase) return ''; + return `[문의·예약 접수 표준 위젯 — 반드시 이 코드 그대로 사용] +문의, 상담 신청, 예약 접수 등 사용자가 내용을 보내는 폼은 mailto: 링크나 임의의
    을 만들지 말고 아래 표준 위젯 코드를 그대로 붙여 넣습니다(사이트 안에서 메일이 발송됩니다). data-* 속성과 끊김 방지) */ +const ld = (obj) => ``; +/** 빈 값 제거 (구조화 데이터 검사기 경고 방지) */ +function prune(o) { if (Array.isArray(o)) { const a = o.map(prune).filter((x) => x !== undefined); return a.length ? a : undefined; } if (o && typeof o === 'object') { const r = {}; for (const [k, v] of Object.entries(o)) { const pv = prune(v); if (pv !== undefined && pv !== '') r[k] = pv; } return Object.keys(r).length ? r : undefined; } return o === '' || o == null ? undefined : o; } + +/** + * FAQ 영역 제목: v1.3.16 부터 코드에 언어별 글자를 두지 않고 사이트 시스템 문구 카탈로그(lib/sitemsg 'faq.title')에서 가져온다. + * @param {string|null} lang 로케일 코드 (다국어 사이트의 언어 폴더) + * @param {string} [locale] 단일 언어 사이트의 og:locale(ko_KR 등) — 언어를 모를 때 여기서 추정 + */ +const faqTitleFor = (lang, locale) => sitemsg.tr(lang || multilang.codeOf(String(locale || 'ko').split('_')[0]), 'faq.title'); + +/** + * 한 폴더의 페이지들에 관리 구역( 의 wm-seo, 본문 wm-aeo-faq)을 다시 쓴다. + * @param {string} dir 페이지 폴더 (단일 언어: 사이트 폴더, 다국어: 언어 폴더) + * @param {string} prefix 바뀐 파일 이름 앞에 붙일 경로 ('' 또는 'en/') + * @param {string[]} files dir 기준 HTML 목록 + * @param {object|null} fin 이 언어의 반영 값 (없으면 관리 구역을 걷어 내기만 — 대체 링크·확인 태그는 넣는다) + * @param {string|null} baseUrl 이 폴더의 운영 주소 (https://도메인/ 또는 https://도메인/en/) + * @param {object} ctx { verify: 검색사이트 확인 태그(searchReg), ogLocale, altLocales: [], alternates: [{hreflang, base}], faqTitle, homeUrl } + */ +function writePages(dir, prefix, files, fin, baseUrl, ctx) { + fin = fin || {}; const changed = []; + const seoPages = new Map(((fin.seo && fin.seo.pages) || []).filter((p) => p.use).map((p) => [p.file, p])); + const faqs = ((fin.aeo && fin.aeo.faqs) || []).filter((f) => f.use); + const geo = fin.geo || null; const reg = ctx.verify || null; + for (const file of files) { + const fp = path.join(dir, file); let html; try { html = fs.readFileSync(fp, 'utf8'); } catch (_) { continue; } + const orig = html; html = html.replace(SEO_RE, '').replace(FAQ_RE, ''); // 지난번에 넣은 관리 구역부터 걷어 내고 원래 페이지를 기준으로 판단 + const p = seoPages.get(file); const isHome = file === 'index.html'; const url = pageUrl(baseUrl, file); + const lines = []; + if (p) { + if (p.title) { if (/]*>[\s\S]*?<\/title>/i.test(html)) html = html.replace(/]*>[\s\S]*?<\/title>/i, `${esc(p.title)}`); else lines.push(`${esc(p.title)}`); } + if (p.description) { html = html.replace(/]*>/gi, ''); lines.push(``); } + const kw = [...new Set([...(p.keywords || []), ...((fin.seo && fin.seo.keywords) || []).slice(0, 4)])]; if (kw.length) lines.push(``); + lines.push(''); + if (url) lines.push(``); + lines.push(``, ``, ``); + if (fin.seo.siteName) lines.push(``); lines.push(``); + for (const a of ctx.altLocales || []) lines.push(``); + if (url) lines.push(``); + lines.push(''); + } else if (ctx.always) { + // v1.3.16 다국어: 이 언어의 검색 값을 아직 반영하지 않았어도 로케일 정보(canonical·og:locale·og:url)는 언어마다 분명히 넣는다 + if (url) lines.push(``); + lines.push(``); for (const a of ctx.altLocales || []) lines.push(``); + if (url) lines.push(``); + } + // 다국어: 같은 페이지의 모든 언어 버전 (검색엔진이 언어별 주소를 알아보게) — 절대 주소가 필요해 운영 주소가 있을 때만 + for (const a of ctx.alternates || []) lines.push(``); + // 검색사이트 소유 확인 태그 (사이트 첫 페이지만) + if (isHome && reg) { if (reg.google && reg.google.method === 'meta' && reg.google.code) lines.push(``); if (reg.naver && reg.naver.code) lines.push(``); } + // GEO: 사업체 정보 구조화 데이터 (첫 페이지) + if (isHome && geo && geo.useEntity && geo.entity && geo.entity.name) { + // v1.3.16 로케일별 사업체 정보: 언어 폴더마다 그 언어 값(이름·소개·주소)으로, 같은 사업체임을 @id 로 묶고 url 은 그 언어 첫 페이지 + const e = geo.entity; lines.push(ld(prune({ '@context': 'https://schema.org', '@type': e.type || 'Organization', '@id': ctx.entityId || undefined, name: e.name, description: e.description, url: baseUrl || ctx.homeUrl || undefined, telephone: e.telephone, email: e.email, address: e.address && Object.values(e.address).some((v) => v && v !== 'KR') ? { '@type': 'PostalAddress', ...e.address } : undefined, openingHours: e.openingHours, priceRange: e.priceRange, sameAs: e.sameAs, knowsLanguage: ctx.knowsLanguage, currenciesAccepted: ctx.currency }))); + if (fin.aeo && fin.aeo.summary) lines.push(ld(prune({ '@context': 'https://schema.org', '@type': 'WebSite', name: (fin.seo && fin.seo.siteName) || e.name, url: baseUrl || undefined, inLanguage: ctx.htmlLang || undefined, description: fin.aeo.summary }))); + } + // AEO: 이 페이지 FAQ 의 FAQPage 구조화 데이터 + 사이트에 없던 질문은 보이는 FAQ 영역으로 + const pageFaqs = faqs.filter((f) => f.page === file); + if (pageFaqs.length) lines.push(ld({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pageFaqs.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) })); + // 관리 구역 다시 쓰기 (내용이 없으면 구역 없이 둔다) + if (lines.length) { const block = `\n${lines.join('\n')}\n\n`; html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, `${block}`) : block + html; } + const visible = pageFaqs.filter((f) => !f.onPage); + if (visible.length) { + const sec = `\n

    ${esc(ctx.faqTitle || FAQ_TITLE.ko)}

    ${visible.map((f) => `
    ${esc(f.q)}

    ${esc(f.a)}

    `).join('')}
    \n\n`; + if (/]/i.test(html)) html = html.replace(/]/i, (m) => `${sec}${m}`); else if (/<\/main>/i.test(html)) html = html.replace(/<\/main>/i, `${sec}`); else html = html.replace(/<\/body>/i, `${sec}`); + } + if (html !== orig) { fs.writeFileSync(fp, html, { mode: 0o644 }); changed.push(prefix + file); } + } + return changed; +} + +/** + * 반영 값(final)과 검색 등록 값(searchReg)을 사이트 파일에 넣는다. 같은 입력이면 결과가 같다(멱등). + * @param {object} site + * @param {string} siteDir + * @param {string|null} baseUrl 운영 주소(https://도메인/). 없으면 canonical·og:url·sitemap·hreflang 의 절대 주소를 생략한다 + * — 배포할 때마다 배포 주소로 다시 부른다(server.js deploySiteTo). + * @returns {{changed: string[]}} + */ +function materialize(site, siteDir, baseUrl) { + const opt = site.optimize || {}; const fin = opt.applied ? (opt.final || {}) : {}; const reg = site.searchReg || {}; + const base = baseUrl ? baseUrl.replace(/\/?$/, '/') : null; let changed = []; let sitemapUrls = []; + const multi = multilang.isMulti(site); + const finOf = (lang) => (!opt.applied ? {} : lang === site.langs.default ? (opt.final || {}) : ((opt.langFinals || {})[lang] || {})); + if (!multi) { + const files = htmlFiles(siteDir); + changed = writePages(siteDir, '', files, fin, base, { verify: reg, faqTitle: faqTitleFor(null, fin.seo && fin.seo.locale) }); + sitemapUrls = files.map((f) => ({ loc: pageUrl(base, f), home: f === 'index.html' })); + } else { + // 다국어: 언어 폴더마다 그 언어의 반영 값 + 모든 언어 버전 대체 링크, 맨 위 언어 선택 페이지에는 확인 태그와 대체 링크 + const { list, default: def } = site.langs; const L = multilang.LOCALES; + // v1.3.16 x-default: 사이트 설정(site.langs.xDefault)이 가리키는 언어 폴더, 'root' 이면 맨 위 언어 선택 페이지 + const xd = multilang.xDefaultOf(site); const xBase = (file) => (xd === 'root' ? (file === 'index.html' ? base : null) : `${base}${xd}/`); + const files = multilang.htmlUnder(path.join(siteDir, def)); const geoDef = finOf(def).geo || null; const home = multilang.siteLocale(site); + for (const c of list) { + const f = finOf(c); + // 이 언어 GEO 값이 없으면 기본 언어 사업체 정보를 쓴다(로케일 현황에 "기본 언어 값 사용" 으로 표시) + const fin2 = f.geo || !geoDef ? f : { ...f, geo: geoDef }; + const alt = (file) => (base ? [...list.map((x) => ({ hreflang: L[x].html, base: `${base}${x}/` })), ...(xBase(file) ? [{ hreflang: 'x-default', base: xBase(file) }] : [])] : []); + const dirC = path.join(siteDir, c); + for (const file of multilang.htmlUnder(dirC)) changed = changed.concat(writePages(dirC, `${c}/`, [file], fin2, base ? `${base}${c}/` : null, { ogLocale: L[c].og, altLocales: list.filter((x) => x !== c).map((x) => L[x].og), alternates: alt(file), faqTitle: faqTitleFor(c), htmlLang: L[c].html, homeUrl: base ? `${base}${c}/` : undefined, always: true, knowsLanguage: list.map((x) => L[x].html), currency: home.currency, entityId: base ? `${base}#business` : undefined })); + } + const rootAlt = base ? [...list.map((c) => ({ hreflang: L[c].html, base: `${base}${c}/` })), { hreflang: 'x-default', base: xd === 'root' ? base : `${base}${xd}/` }] : []; + changed = changed.concat(writePages(siteDir, '', fs.existsSync(path.join(siteDir, 'index.html')) ? ['index.html'] : [], null, base, { verify: reg, alternates: rootAlt })); + sitemapUrls = list.flatMap((c) => files.map((file) => ({ loc: pageUrl(`${base}${c}/`, file), home: file === 'index.html' && c === def, alts: [...list.map((x) => ({ hreflang: L[x].html, href: pageUrl(`${base}${x}/`, file) })), ...(xBase(file) ? [{ hreflang: 'x-default', href: pageUrl(xBase(file), file) }] : [])] }))); + } + // 파일들 — 우리가 쓴 적 없는(사용자가 만든) 파일은 덮어쓰지 않는다 + const written = (site.optimize = site.optimize || {}).written || (site.optimize.written = {}); + const put = (name, content) => { const fp = path.join(siteDir, name); let cur = null; try { cur = fs.readFileSync(fp, 'utf8'); } catch (_) {} if (cur !== null && written[name] !== sha(cur)) return; if (content == null) { if (cur !== null && written[name]) { try { fs.unlinkSync(fp); } catch (_) {} delete written[name]; changed.push(name); } return; } if (cur === content) return; fs.writeFileSync(fp, content, { mode: 0o644 }); written[name] = sha(content); changed.push(name); }; + const anySeo = multi ? site.langs.list.some((c) => finOf(c).seo) : !!fin.seo; + const wantSitemap = !!(anySeo || (reg.google && reg.google.started) || (reg.naver && reg.naver.started)); + const hasAlts = sitemapUrls.some((u) => u.alts); + if (wantSitemap && base) put('sitemap.xml', `\n\n${sitemapUrls.map((u) => ` ${esc(u.loc)}${(u.alts || []).map((a) => ``).join('')}${u.home ? '1.0' : ''}`).join('\n')}\n\n`); + // i18n-ignore-start (사이트 파일 내용 — 화면 문구 아님) + if (!wantSitemap) { put('sitemap.xml', null); put('robots.txt', null); } // 더 이상 쓰지 않으면 우리가 만든 파일만 지운다 + if (wantSitemap) put('robots.txt', `# AI Webmaker 자동 생성 (관리 탭 › 검색·AI 최적화)\nUser-agent: *\nAllow: /\n${base ? `Sitemap: ${base}sitemap.xml\n` : ''}`); + const geo = multi ? finOf(site.langs.default).geo : fin.geo; + const langLinks = multi && base ? `\n## Languages\n${site.langs.list.map((c) => `- [${multilang.LOCALES[c].name}](${base}${c}/)`).join('\n')}\n` : ''; + put('llms.txt', geo && geo.useLlms && geo.llmsTxt ? geo.llmsTxt.trim() + (geo.facts && geo.facts.length ? `\n\n## 주요 사실\n${geo.facts.map((f) => `- ${f}`).join('\n')}\n` : '\n') + langLinks : null); + if (reg.indexnow && reg.indexnow.key) put(`${reg.indexnow.key}.txt`, reg.indexnow.key); + // i18n-ignore-end + return { changed }; +} + +// ---------- 검색사이트 등록 ---------- +/** 등록할 수 있는 상태인지: 배포가 끝났고(운영 주소 있음) 생성·수정·배포가 진행 중이 아님 */ +function regAvailability(site, liveUrl, busy) { + if (busy) return { ok: false, reason: '생성·수정·배포가 진행 중입니다. 끝난 뒤에 검색사이트 등록을 할 수 있습니다.' }; + if (site.plan && !['generated', 'published'].includes(site.plan.stage)) return { ok: false, reason: '사이트 생성이 끝나지 않았습니다.' }; + if (!liveUrl) return { ok: false, reason: '배포가 끝난 사이트만 검색사이트에 등록할 수 있습니다. 먼저 발행·배포하세요.' }; + return { ok: true }; +} +const ENGINES = { + google: { label: '구글 (Search Console)', console: 'https://search.google.com/search-console', meta: 'google-site-verification' }, + naver: { label: '네이버 (서치어드바이저)', console: 'https://searchadvisor.naver.com/console/board', meta: 'naver-site-verification' }, +}; +/** meta 태그 통째로 붙여넣어도 content 값만 뽑는다 */ +function codeFrom(input) { const s = String(input || '').trim(); const m = s.match(/content=["']([^"']+)["']/i); const v = (m ? m[1] : s).replace(/^google-site-verification=/, ''); if (!/^[A-Za-z0-9_\-=]{8,200}$/.test(v)) throw httpError('확인 코드 형식이 올바르지 않습니다. 검색사이트가 준 meta 태그의 content 값(또는 태그 전체)을 붙여넣으세요'); return v; } +/** + * 엔진별 단계와 상태. actor: auto(서비스가 함) | manual(사람이 검색사이트·DNS 에서 함) + * done 표시는 site.searchReg[engine].steps[stepId] 에 날짜로 남는다. + * v1.3.10: opts.googleAuto(구글 서비스 계정 키가 있음)이면 구글 단계는 모두 자동(코드 받기 → 태그·재배포 → 소유 확인 → 속성 추가·사이트맵 제출). + * 네이버는 공개 API 가 없어 "코드 받기·확인 누르기" 만 사람이 하고, 코드를 넣으면 태그·재배포·운영 확인은 자동, + * 사이트맵은 robots.txt 의 Sitemap 줄과 IndexNow(네이버 참여)로 알려지므로 제출은 선택 단계다. + */ +function steps(site, engine, liveUrl, opts = {}) { + const r = (site.searchReg || {})[engine] || {}; const E = ENGINES[engine]; const s = r.steps || {}; const base = liveUrl ? liveUrl.replace(/\/?$/, '/') : ''; + const list = []; const auto = r.auto || {}; + if (engine === 'google' && r.method !== 'dns' && opts.googleAuto) { + const at = (k) => (s[k] ? s[k].slice(0, 16).replace('T', ' ') : ''); + list.push({ id: 'code', actor: 'auto', title: '구글 API 로 확인 코드(meta 태그)를 받습니다', detail: r.code ? '받음' : '등록을 시작하면 자동으로 받습니다', done: !!r.code }); + list.push({ id: 'apply', actor: 'auto', title: '확인 태그를 첫 페이지에 넣고 다시 배포합니다', detail: r.appliedAt ? `넣고 배포함 (${r.appliedAt.slice(0, 16).replace('T', ' ')})` : '자동', done: !!r.appliedAt }); + list.push({ id: 'verify', actor: 'auto', title: '구글에 소유 확인을 요청합니다', detail: s.verify ? `확인됨 (${at('verify')})${r.ownerAdded ? ` · 소유자 추가: ${r.ownerAdded}` : ''}` : '자동', done: !!s.verify }); + list.push({ id: 'sitemap', actor: 'auto', title: 'Search Console 에 사이트를 추가하고 사이트맵을 제출합니다', detail: s.sitemap ? `제출함 (${at('sitemap')}) — ${base}sitemap.xml` : '자동', done: !!s.sitemap }); + return list; + } + if (engine === 'google' && r.method === 'dns') { + list.push({ id: 'code', actor: 'manual', title: 'Search Console 에서 "도메인" 속성을 추가하고 TXT 확인 값을 받습니다', detail: `${E.console} › 속성 추가 › 도메인 › ${(liveUrl || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '')} 입력 → 표시되는 google-site-verification=… 값을 아래에 입력`, done: !!r.code }); + list.push({ id: 'dns', actor: 'manual', title: '도메인 DNS 에 TXT 레코드를 등록합니다', detail: r.code ? `호스트 @ · 종류 TXT · 값 google-site-verification=${r.code}` : '먼저 확인 값을 입력하세요', done: !!s.dns, dnsCheck: true }); + } else { + list.push({ id: 'code', actor: 'manual', title: `${E.label} 에 사이트를 추가하고 HTML 태그(meta) 확인 코드를 받습니다`, detail: engine === 'google' ? `${E.console} › 속성 추가 › URL 접두어 › ${base} 입력 › 다른 확인 방법 › HTML 태그 → content 값 복사 (관리자 콘솔 › 연동 키에 구글 서비스 계정 키를 넣으면 이 단계부터 모두 자동)` : `${E.console} › 사이트 등록 › ${base} 입력 › HTML 태그 → content 값 복사`, done: !!r.code }); + list.push({ id: 'apply', actor: 'auto', title: '확인 태그를 첫 페이지에 넣고 다시 배포합니다', detail: r.appliedAt ? `넣고 배포함 (${r.appliedAt.slice(0, 16).replace('T', ' ')})${r.liveOk ? ' · 운영 페이지에서 태그 확인됨' : ''}` : '코드를 저장하면 자동으로 넣고 재배포합니다', done: !!r.appliedAt }); + } + list.push({ id: 'verify', actor: 'manual', title: `${E.label} 에서 "확인" 을 누릅니다`, detail: r.liveOk || r.method === 'dns' ? '소유 확인이 성공하면 완료 표시' : '태그가 운영 페이지에 보인 뒤 누르세요', done: !!s.verify }); + if (engine === 'naver') list.push({ id: 'sitemap', actor: 'manual', optional: true, title: '(선택) 사이트맵을 제출합니다', detail: `서치어드바이저 › 요청 › 사이트맵 제출 에 ${base}sitemap.xml — robots.txt 의 Sitemap 줄과 IndexNow 자동 색인 요청으로도 알려지므로 하지 않아도 됩니다`, done: !!s.sitemap }); + else list.push({ id: 'sitemap', actor: 'manual', title: '사이트맵을 제출합니다', detail: `Search Console › Sitemaps 에 ${base}sitemap.xml 입력`, done: !!s.sitemap }); + return list; +} +/** 엔진 상태 요약 (필수 단계만 완료 판정 — 선택 단계 제외) */ +function engineStatus(site, engine, liveUrl, opts = {}) { + const r = (site.searchReg || {})[engine] || {}; if (!r.started) return { engine, label: ENGINES[engine].label, started: false, state: '시작 안 함', autoAvailable: engine === 'google' && !!opts.googleAuto }; + const st = steps(site, engine, liveUrl, opts); const req = st.filter((x) => !x.optional); const doneAll = req.every((x) => x.done); const next = req.find((x) => !x.done); + const auto = r.auto || null; const running = auto && auto.state === 'running'; + return { engine, label: ENGINES[engine].label, started: true, method: r.method || 'meta', code: r.code || '', steps: st, auto, autoAvailable: engine === 'google' && !!opts.googleAuto, + state: doneAll ? '등록 완료' : running ? '자동 진행 중' : auto && auto.state === 'error' ? '자동 진행 실패' : (next.actor === 'manual' ? '수동 작업 대기' : '자동 진행 대기'), next: next ? next.title : null, console: ENGINES[engine].console }; +} +/** DNS TXT 확인 (google 도메인 속성) */ +async function checkTxt(domain, value) { try { const recs = (await dns.resolveTxt(domain)).map((r) => r.join('')); return recs.includes(value); } catch (_) { return false; } } +/** 운영 첫 페이지에 확인 태그가 실제로 보이는지 (배포 반영 확인용) */ +async function liveHasMeta(liveUrl, name, code) { try { const r = await require('./netguard').safeFetch(liveUrl, { timeout: 15000, maxBytes: 1024 * 1024, userAgent: 'AI-Webmaker-SearchReg' }); const t = r.buf.toString('utf8'); /* v1.6.31 (요구 5): 사설·내부 주소 차단, 리다이렉트마다 재검증 */ return new RegExp(`]+name=["']${name}["'][^>]+content=["']${code.replace(/[^\w-]/g, '')}["']`, 'i').test(t); } catch (_) { return false; } } +/** IndexNow 키 (사이트별, 처음 한 번 만든다) */ +function ensureIndexNowKey(site) { site.searchReg = site.searchReg || {}; site.searchReg.indexnow = site.searchReg.indexnow || {}; if (!site.searchReg.indexnow.key) site.searchReg.indexnow.key = crypto.randomBytes(16).toString('hex'); return site.searchReg.indexnow.key; } +/** + * IndexNow 색인 요청 (빙·네이버·얀덱스 등 참여 검색엔진이 공유). 운영 주소의 키 파일로 소유를 확인한다. + * @param {string} liveUrl https://도메인/ + * @param {string[]} files HTML 파일 목록 + */ +async function pingIndexNow(liveUrl, key, files) { + const base = liveUrl.replace(/\/?$/, '/'); const host = new URL(base).host; + const body = { host, key, keyLocation: `${base}${key}.txt`, urlList: files.slice(0, 100).map((f) => pageUrl(base, f)) }; + const r = await fetch(process.env.INDEXNOW_ENDPOINT || 'https://api.indexnow.org/indexnow', // INDEXNOW_ENDPOINT: 시험용 대체 주소 + { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body), signal: AbortSignal.timeout(20000) }); + return { status: r.status, ok: r.status === 200 || r.status === 202, detail: r.status === 200 || r.status === 202 ? `색인 요청 접수 (${body.urlList.length}개 주소)` : r.status === 403 ? '키 파일을 찾지 못했습니다 — 재배포 후 다시 시도하세요' : r.status === 422 ? '주소가 키 파일의 도메인과 맞지 않습니다' : `응답 ${r.status}` }; +} + +/** + * v1.3.16 로케일별 메타·구조화 데이터 현황 (관리 탭 다국어 카드 표). 실제 페이지 파일을 읽어 언어 × 페이지마다 + * title·description·canonical·og:locale·og:title·hreflang·sitemap 포함 여부, 첫 페이지의 사업체(LocalBusiness 등) 구조화 데이터와 + * 그 값이 이 언어 것인지(기본 언어 값 대체 여부)를 돌려준다. + */ +function localeMatrix(site, siteDir, baseUrl) { + if (!multilang.isMulti(site)) return null; + const { list, default: def } = site.langs; const L = multilang.LOCALES; const files = multilang.htmlUnder(path.join(siteDir, def)); + let sm = ''; try { sm = fs.readFileSync(path.join(siteDir, 'sitemap.xml'), 'utf8'); } catch (_) {} + const opt = site.optimize || {}; const langFin = (c) => (c === def ? opt.final : (opt.langFinals || {})[c]) || null; + const meta = (h, re) => { const m = h.match(re); return m ? m[1].replace(/"/g, '"').replace(/&/g, '&') : ''; }; + return { baseUrl: baseUrl || null, xDefault: multilang.xDefaultOf(site), languages: list.map((c) => { + const lf = langFin(c); + const pages = files.map((file) => { + let h = ''; try { h = fs.readFileSync(path.join(siteDir, c, file), 'utf8'); } catch (_) { return { file, missing: true }; } + const url = baseUrl ? pageUrl(`${baseUrl.replace(/\/?$/, '/')}${c}/`, file) : null; + return { file, title: meta(h, /]*>([\s\S]*?)<\/title>/i), description: meta(h, /${esc(url)}`) : null, canonicalOk: url ? meta(h, /:" 이며 AI 연결 탭에서 "사용" 으로 켠 모델만 고를 수 있다. 비어 있으면 anthropic:. + internalRef: '', + // v1.5.16 (요구 3): Claude 모델 중 이 서비스에서 "사용" 으로 켠 것(id 배열). 비어 있으면 MODELS 전체. 등급·내부 호출에 고를 수 있는 Claude 모델이 이것으로 제한된다. + claudeModels: [], + tiers: { ...DEFAULT_TIERS }, + defaultTier: 'standard', + serviceApiKey: '', // 외부 서비스가 캐시를 조정할 때 쓰는 키 (X-Service-Key) + mailSystem: null, // v1.3.0: 사이트 메일 시스템 { enabled, mxHost, spfInclude, dmarc } (null 이면 꺼짐, lib/mailsys.js) + theme: null, // v1.2.0: AI Webmaker 화면 색상·외형 (null 이면 기본). THEME_DEFAULTS 참고 + backlogKey: '', // 작업 서버가 "다음 작업 반영 목록" 을 가져갈 때 쓰는 읽기 전용 키 (X-Backlog-Key, v1.1.6) + signupCredit: 0, // 신규 회원 가입 시 자동 지급하는 기본 캐시(원). 관리자 콘솔에서 설정 + // v1.5.27 (요구 6): 회원가입 뒤 이용 방식 — immediate(가입 즉시 이용) | approval(관리자가 승인해야 로그인 가능). 가입 자체의 ON/OFF 는 설정 파일 service.allowSignup + signupApproval: 'immediate', + maxEdits: 10, // AI에게 맡기기: 생성 후 발행 전까지 허용하는 수정 횟수(0 = 무제한). 한도에 이르면 발행 대기 + auth: { mode: 'server-login', apiKey: '', oauthToken: '' }, // server-login | oauth-token | api-key + fx: { mode: 'auto', manualRate: 0, spreadPct: 1.0 }, // auto: ECB/시장 중간환율 + 스프레드(구입가 근사) | manual: 관리자 입력 + marginPct: 30, + letsEncryptEmail: '', + mail: { keyFile: '', apiKey: '', defaultDomain: '', fromName: 'Webmaster', formBase: '' }, + updatedAt: null, +}; + +let cache = null; +function load() { + if (cache) return cache; + try { cache = { ...DEFAULTS, ...JSON.parse(fs.readFileSync(FILE, 'utf8')) }; cache.auth = { ...DEFAULTS.auth, ...(cache.auth || {}) }; cache.fx = { ...DEFAULTS.fx, ...(cache.fx || {}) }; } + catch (_) { cache = JSON.parse(JSON.stringify(DEFAULTS)); } + return cache; +} +function save(patch) { + // v1.3.16: 사본에 적용하고 끝까지 검증을 통과했을 때만 파일·캐시에 반영 (중간에 검증 오류가 나면 아무것도 바뀌지 않음) + const s = JSON.parse(JSON.stringify(load())); + // v1.3.17 (B14): 보관소(키) 저장처럼 되돌릴 수 없는 일은 모아 두었다가 모든 검증이 끝난 뒤에 실행한다 + // (예전에는 키를 먼저 보관소에 쓰고 뒤의 숫자 검증에서 실패해, 오류 응답인데 키는 바뀌어 있었다) + const after = []; + // 옛 형식(1.5.13 까지의 화면·API): Claude 모델 id 하나 → 내부 호출 모델도 그 Claude 모델로 맞춘다 + if (patch.defaultModel && MODELS.some((m) => m.id === patch.defaultModel)) { s.defaultModel = patch.defaultModel; s.internalRef = 'anthropic:' + patch.defaultModel; } + // v1.5.16: 등급·내부 호출 모델은 "지금 사용 가능으로 설정된 모델" 중에서만 — 값이 바뀌는 항목만 검사한다(이미 저장된 값이 제공자를 끄면서 + // 사용 불가가 되어도 다른 등급만 고쳐 저장할 수 있게). providers 는 이 모듈을 불러 쓰므로 여기서는 호출 시점에 늦게 불러온다. + const assertUsable = (ref, what) => { const list = require('./providers').availableModels(null); if (!list.some((m) => m.ref === ref)) throw Object.assign(new Error(`${what}에 지정한 모델 ${ref} 은(는) 지금 사용 가능으로 설정된 모델이 아닙니다 — AI 연결 탭에서 제공자를 켜고 모델을 "사용" 으로 고른 뒤 다시 지정하세요`), { status: 400 }); }; + if (patch.tiers && typeof patch.tiers === 'object') { s.tiers = { ...DEFAULT_TIERS, ...(s.tiers || {}) }; for (const t of TIERS) if (typeof patch.tiers[t.id] === 'string' && /^[a-z0-9_-]+:.+/i.test(patch.tiers[t.id])) { const v = patch.tiers[t.id].trim(); if (v !== s.tiers[t.id]) assertUsable(v, `${t.label} 등급`); s.tiers[t.id] = v; } } + if (typeof patch.internalRef === 'string' && /^[a-z0-9_-]+:.+/i.test(patch.internalRef)) { const v = patch.internalRef.trim(); if (v !== s.internalRef) assertUsable(v, '내부 호출 모델'); s.internalRef = v; if (v.startsWith('anthropic:') && MODELS.some((m) => m.id === v.slice(10))) s.defaultModel = v.slice(10); } + // v1.5.16: 사용할 Claude 모델 (id 배열, 모르는 id 는 버림). 빈 배열 = 전체 사용 + if (Array.isArray(patch.claudeModels)) s.claudeModels = patch.claudeModels.map((x) => String(x)).filter((id) => MODELS.some((m) => m.id === id)); + if (patch.defaultTier && TIERS.some((t) => t.id === patch.defaultTier)) s.defaultTier = patch.defaultTier; + if (typeof patch.serviceApiKey === 'string' && patch.serviceApiKey !== '••••') s.serviceApiKey = patch.serviceApiKey.trim(); + if (typeof patch.backlogKey === 'string' && patch.backlogKey !== '••••') s.backlogKey = patch.backlogKey.trim(); + if (patch.theme !== undefined) s.theme = normalizeTheme(patch.theme); + if (patch.mailSystem && typeof patch.mailSystem === 'object') s.mailSystem = require('./mailsys').normalizeConfig(patch.mailSystem, s.mailSystem); // 검증 실패 시 예외(저장 안 함) + if (patch.auth) { + if (['server-login', 'oauth-token', 'api-key'].includes(patch.auth.mode)) s.auth.mode = patch.auth.mode; + // v1.3.1: Claude 키·토큰은 settings.json 이 아니라 암호화 보관소(lib/vault.js)에 저장한다 + if (typeof patch.auth.apiKey === 'string' && patch.auth.apiKey.trim() && patch.auth.apiKey !== '••••') { const k = patch.auth.apiKey.trim(); after.push(() => require('./vault').set({ kind: 'anthropic-api-key', secret: k })); s.auth.apiKey = ''; } + if (typeof patch.auth.oauthToken === 'string' && patch.auth.oauthToken.trim() && patch.auth.oauthToken !== '••••') { const k = patch.auth.oauthToken.trim(); after.push(() => require('./vault').set({ kind: 'anthropic-oauth', secret: k })); s.auth.oauthToken = ''; } + } + // v1.3.16: 숫자 값 검증. 예전에는 빈 칸('')이 +'' = 0 으로 저장되어 마진·환율이 0 이 될 수 있었다 → 빈 값은 "바꾸지 않음", + // 범위를 벗어나면 저장하지 않고 이유를 알린다(관리자 화면에 그대로 표시). + const num = (v, name, lo, hi) => { if (v === undefined || v === null || String(v).trim() === '') return undefined; const n = Number(v); if (!Number.isFinite(n) || n < lo || n > hi) throw Object.assign(new Error(`${name}은(는) ${lo}~${hi} 사이 숫자로 입력하세요`), { status: 400 }); return n; }; + if (patch.fx) { + if (['auto', 'manual'].includes(patch.fx.mode)) s.fx.mode = patch.fx.mode; + const mr = num(patch.fx.manualRate, '직접 입력 환율', 1, 100000); if (mr !== undefined) s.fx.manualRate = mr; + const sp = num(patch.fx.spreadPct, '스프레드 %', 0, 50); if (sp !== undefined) s.fx.spreadPct = sp; + if (s.fx.mode === 'manual' && !(s.fx.manualRate > 0)) throw Object.assign(new Error('직접 입력 방식은 1 USD 원화 가격을 입력해야 합니다'), { status: 400 }); + } + // v1.3.16 서비스 화면 기본 언어 { default, force } — force 이면 지금 시각을 forcedAt 으로 남겨, 그 전에 사용자가 고른 언어보다 앞서게 한다 + if (patch.uiLang && typeof patch.uiLang === 'object') { const d = String(patch.uiLang.default || 'ko').trim(); if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(d)) throw Object.assign(new Error('언어 코드가 올바르지 않습니다'), { status: 400 }); s.uiLang = { default: d, forcedAt: patch.uiLang.force ? new Date().toISOString() : ((s.uiLang && s.uiLang.forcedAt) || null) }; } + { const mg = num(patch.marginPct, '사용자 청구 마진 %', 0, 1000); if (mg !== undefined) s.marginPct = mg; } + { const me = num(patch.maxEdits, '수정 횟수 한도', 0, 10000); if (me !== undefined) s.maxEdits = Math.round(me); } + { const sc = num(patch.signupCredit, '신규 회원 기본 캐시', 0, 100000000); if (sc !== undefined) s.signupCredit = Math.round(sc); } + if (patch.signupApproval !== undefined) { if (!['immediate', 'approval'].includes(patch.signupApproval)) throw Object.assign(new Error('회원가입 승인 방식은 immediate 또는 approval 이어야 합니다'), { status: 400 }); s.signupApproval = patch.signupApproval; } // v1.5.27 + // v1.4.1 캐시 표시 단위 (원·환·동·골드·크레딧·D·ING 등 관리자가 정하는 글자). 표시만 바뀌고 1 단위 = 1원 기준은 그대로다 + if (patch.cashUnit !== undefined) { const u = String(patch.cashUnit || '').trim(); if (!u || u.length > 12 || /[<>"'&\\{}0-9]/.test(u)) throw Object.assign(new Error('캐시 표시 단위는 숫자·기호(< > " \' & { })를 뺀 1~12자로 정하세요 (예: 원, 크레딧, D)'), { status: 400 }); s.cashUnit = u; } + // v1.4.5 사용자 첫 화면 페이지 (빌더 오른쪽 미리보기에 사이트를 고르기 전 보이는 페이지) — 관리자가 만든 사이트 ID, 빈 값 = 기본 안내. + // 관리자 소유인지는 server.js 가 저장 전에 확인한다(settings 는 사이트 저장소를 모름) + if (patch.welcomeSiteId !== undefined) { const w = String(patch.welcomeSiteId || '').trim(); if (w && !/^[a-f0-9]{32}$/.test(w)) throw Object.assign(new Error('첫 화면 페이지 값이 올바르지 않습니다'), { status: 400 }); s.welcomeSiteId = w; } + if (typeof patch.letsEncryptEmail === 'string') s.letsEncryptEmail = patch.letsEncryptEmail.trim(); + // v1.4.0 도메인 설정 (요율·플랫폼 도메인·DNS 방식·사이트 IP·확장자·예약 이름·새 가입자 계정 종류) + if (patch.domains && typeof patch.domains === 'object') s.domains = require('./domains').normalizeConfig(patch.domains, s.domains); + // v1.3.17 비밀번호 규칙 (lib/passpolicy.js 가 검증, 기본은 기존 규칙 6자 이상) + if (patch.passwordPolicy && typeof patch.passwordPolicy === 'object') s.passwordPolicy = require('./passpolicy').normalize({ ...(s.passwordPolicy || {}), ...patch.passwordPolicy }); + // v1.3.1: 키 파일 경로가 오면 그 파일을 한 번 읽어 보관소에 암호화 저장한다(이후 평문 파일 없이 동작). 못 읽으면 저장 실패로 알린다. + if (patch.mail && typeof patch.mail === 'object' && typeof patch.mail.keyFile === 'string' && patch.mail.keyFile.trim()) { const f = patch.mail.keyFile.trim(); after.push(() => require('./vault').importFile('resend', f)); } + if (patch.mail && typeof patch.mail === 'object') { s.mail = { ...(s.mail || {}) }; for (const k of ['keyFile', 'defaultDomain', 'fromName', 'formBase']) if (typeof patch.mail[k] === 'string') s.mail[k] = patch.mail[k].trim(); if (typeof patch.mail.apiKey === 'string' && patch.mail.apiKey.trim() && patch.mail.apiKey !== '••••') { const k = patch.mail.apiKey.trim(); after.push(() => require('./vault').set({ kind: 'resend', secret: k })); s.mail.apiKey = ''; } } // v1.3.1: 메일 발송 키는 보관소에 + for (const fn of after) fn(); // 검증을 모두 통과 — 이제 보관소에 쓴다 (여기서 실패하면 설정 파일도 쓰지 않는다) + s.updatedAt = new Date().toISOString(); + fs.mkdirSync(DATA_ROOT, { recursive: true }); + fs.writeFileSync(FILE, JSON.stringify(s, null, 2), { mode: 0o600 }); + try { fs.chmodSync(FILE, 0o600); } catch (_) {} + cache = s; return s; +} +/** Claude 만 쓸 수 있는 자리(연결 테스트, 모델을 모르는 옛 기록의 비용 추정)에서 쓰는 Claude 모델 id — 내부 호출 모델이 Claude 면 그 모델 */ +function getModel() { const s = load(); const r = s.internalRef || ''; if (r.startsWith('anthropic:') && MODELS.some((m) => m.id === r.slice(10))) return r.slice(10); return s.defaultModel; } +/** v1.5.16 내부 호출 모델 참조(provider:model). 저장된 값이 없으면 Claude 기본 모델. 제공자가 꺼져 쓸 수 없을 때의 대체는 providers.resolveModel 이 한다 */ +function internalRef() { const s = load(); return s.internalRef || ('anthropic:' + s.defaultModel); } +/** v1.5.16 사용으로 켠 Claude 모델 목록 (빈 설정이면 전체) */ +function claudeModels() { const s = load(); const ids = Array.isArray(s.claudeModels) && s.claudeModels.length ? s.claudeModels : null; return ids ? MODELS.filter((m) => ids.includes(m.id)) : MODELS; } +// 등급별 안내 문구 (사용자에게 비용 체감을 미리 알리기 위한 설명, v1.1.0) +// v1.5.19 (요구 1): summary 는 등급의 특징(결과물 기준), fit 은 적합한 경우. 비용 차이는 표의 예상 비용 열이 보여 주므로 문장에서 뺐다 +const TIER_GUIDE = { + basic: { title: '기본', summary: 'HTML5, CSS3, 자바스크립트 구조의 정적 웹페이지 생성.', fit: '소개·안내 중심의 일반 사이트, 잦은 소규모 수정' }, + standard: { title: '중급', summary: '웹페이지의 시각적 완성도(UI/UX)와 심미성 우수.', fit: '디자인 완성도가 중요한 사이트, 페이지가 많거나 구성이 복잡한 사이트' }, + premium: { title: '고급', summary: '복잡한 UI/UX의 프런트엔드 아키텍쳐 구현.', fit: '여러 기능 모듈이 서로 연동되는 사이트, 세밀한 상호작용·품질이 필요한 경우' }, +}; +// 비용 추정에 쓰는 대표 토큰량 (실제 작업 기록의 중간값 근사). 생성은 파일을 많이 쓰고, 수정은 파일을 많이 읽는다. +const TYPICAL_USAGE = { create: { input: 20000, output: 30000, cacheCreate: 150000, cacheRead: 900000 }, edit: { input: 10000, output: 12000, cacheCreate: 90000, cacheRead: 350000 } }; +/** + * 등급별 예상 비용(USD). 원화 환산은 호출자(server)가 환율·마진을 적용한다. + * @param {(ref:string)=>{kind,model,price}} resolve 모델 참조를 실행 정보로 바꾸는 함수(providers.resolveModel) + */ +function tierGuide(resolve) { + const s = load(); const out = []; + for (const t of TIERS) { + const ref = (s.tiers && s.tiers[t.id]) || DEFAULT_TIERS[t.id]; let create = null, edit = null; + try { const mi = resolve(ref, null); const est = (u) => (mi.kind === 'claude-cli' ? estimateUsd(u, mi.model) : ((u.input + u.cacheCreate + u.cacheRead) * mi.price.input + u.output * mi.price.output) / 1e6); create = est(TYPICAL_USAGE.create); edit = est(TYPICAL_USAGE.edit); } catch (_) {} + out.push({ id: t.id, label: t.label, desc: t.desc, ...TIER_GUIDE[t.id], createUsd: create, editUsd: edit, isDefault: (s.defaultTier || 'standard') === t.id }); + } + return out; +} +// ---------- 화면 테마 (v1.2.0) ---------- +// 색상은 style.css 의 :root 변수로, 외형은 레이아웃 변수(좌측 폭·글자 크기·모서리·간격·패널 위치)로 반영한다. +const THEME_DEFAULTS = { + colors: { accent: '#2f6fed', accent2: '#1d4fd7', bg: '#f4f5f7', panel: '#ffffff', text: '#1f2933', muted: '#6b7684', line: '#e3e6ea', ok: '#1a8f5a', warn: '#c2410c', topBg: '#fbfcfe' }, + layout: { leftWidth: 460, fontSize: 14, radius: 10, density: 'normal', panelSide: 'left', headerStyle: 'light' }, +}; +const THEME_PRESETS = { + default: { label: '기본 (파랑)', colors: {} }, + green: { label: '초록', colors: { accent: '#15803d', accent2: '#166534', topBg: '#f6fbf7' } }, + purple: { label: '보라', colors: { accent: '#7c3aed', accent2: '#6d28d9', topBg: '#faf8ff' } }, + orange: { label: '주황', colors: { accent: '#ea580c', accent2: '#c2410c', topBg: '#fffaf5' } }, + slate: { label: '차분한 회색', colors: { accent: '#334155', accent2: '#1e293b', bg: '#eef0f3', topBg: '#f8fafc' } }, + dark: { label: '어두운 화면', colors: { accent: '#60a5fa', accent2: '#3b82f6', bg: '#0f172a', panel: '#111827', text: '#e5e7eb', muted: '#9ca3af', line: '#273244', ok: '#34d399', warn: '#fb923c', topBg: '#0b1220' }, layout: { headerStyle: 'dark' } }, +}; +const HEX = /^#[0-9a-f]{6}$/i; +/** 저장된 테마를 기본값과 합쳐 완전한 형태로 */ +/** 저장된(또는 주어진) 테마를 기본값과 합쳐 완전한 형태로. themeObj 를 주면 그 값을 쓴다(미리보기용, v1.3.2) */ +function getTheme(themeObj) { + const t = (themeObj !== undefined ? themeObj : load().theme) || {}; const c = { ...THEME_DEFAULTS.colors }; const l = { ...THEME_DEFAULTS.layout }; + for (const [k, v] of Object.entries(t.colors || {})) if (k in c && HEX.test(v)) c[k] = v; + for (const [k, v] of Object.entries(t.layout || {})) if (k in l) l[k] = v; + return { colors: c, layout: l, preset: t.preset || 'default' }; +} +/** 관리자 입력 검증: 색은 #rrggbb, 외형 값은 허용 범위로 자른다 */ +function normalizeTheme(input) { + if (!input || input.reset) return null; + const out = { colors: {}, layout: {}, preset: THEME_PRESETS[input.preset] ? input.preset : 'custom' }; + for (const [k, v] of Object.entries(input.colors || {})) if (k in THEME_DEFAULTS.colors && HEX.test(v)) out.colors[k] = v.toLowerCase(); + const L = input.layout || {}; const clamp = (v, a, b, d) => { const n = Number(v); return Number.isFinite(n) ? Math.min(b, Math.max(a, Math.round(n))) : d; }; + out.layout.leftWidth = clamp(L.leftWidth, 360, 720, 460); out.layout.fontSize = clamp(L.fontSize, 12, 17, 14); out.layout.radius = clamp(L.radius, 0, 18, 10); + out.layout.density = ['compact', 'normal', 'comfortable'].includes(L.density) ? L.density : 'normal'; + out.layout.panelSide = L.panelSide === 'right' ? 'right' : 'left'; out.layout.headerStyle = L.headerStyle === 'dark' ? 'dark' : (L.headerStyle === 'accent' ? 'accent' : 'light'); + return out; +} +/** 테마 → CSS (사용자 화면·로그인·사이트 관리자 페이지가 /theme.css 로 불러온다) */ +/** + * 테마 CSS. + * @param {object} [themeObj] 주면 저장하지 않은 그 테마로 만든다(관리자 화면 설정 미리보기). 없으면 저장된 테마. + */ +function themeCss(themeObj) { + const preview = themeObj !== undefined; const { colors: c, layout: l } = getTheme(preview ? normalizeTheme(themeObj) : undefined); + // 테마를 저장한 적이 없으면 변수만 내보낸다 → 기본 화면(style.css)이 그대로 유지된다 (미리보기는 항상 전체 CSS) + if (!preview && !load().theme) return `/* AI Webmaker 테마: 기본값 (관리자 콘솔 › 설정 › 화면 외형에서 변경) */\n:root { --left-w: ${l.leftWidth}px; --top-bg: ${c.topBg}; --top-text: ${c.text}; }\n`; // v1.6.40: 레일 폭(--rail-w)은 style.css 기본값을 쓴다 + // 패널 색이 어두우면(밝기 0.4 미만) style.css 에 밝은 색으로 고정된 배경들을 패널 색 계열로 바꾼다 + const lum = (hex) => { const n = parseInt(hex.slice(1), 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; }; + const darkPanel = lum(c.panel) < 0.4; const gap = { compact: 8, normal: 12, comfortable: 16 }[l.density]; const pad = { compact: 9, normal: 12, comfortable: 15 }[l.density]; + const head = l.headerStyle === 'dark' ? { bg: '#0f172a', text: '#e5e7eb' } : l.headerStyle === 'accent' ? { bg: c.accent, text: '#ffffff' } : { bg: c.topBg, text: c.text }; + return `/* AI Webmaker 테마 (관리자 콘솔 › 설정 › 화면 외형에서 변경) */ +:root { --accent: ${c.accent}; --accent-2: ${c.accent2}; --bg: ${c.bg}; --panel: ${c.panel}; --text: ${c.text}; --muted: ${c.muted}; --line: ${c.line}; --ok: ${c.ok}; --warn: ${c.warn}; --top-bg: ${head.bg}; --top-text: ${head.text}; --left-w: ${l.leftWidth}px; --fs: ${l.fontSize}px; --radius: ${l.radius}px; --gap: ${gap}px; --pad: ${pad}px; } +body { font-size: var(--fs); background: var(--bg); color: var(--text); } +.app { grid-template-columns: var(--rail-w, 232px) var(--left-w) 1fr; } /* v1.6.40: 레일 + 작업 칸 + 캔버스 세 칸 */ +${l.panelSide === 'right' ? '.app { grid-template-columns: 1fr var(--left-w) var(--rail-w, 232px); } .app > .rail { order: 3; border-right: 0; border-left: 1px solid var(--line); } .app > .left { order: 2; border-right: 0; border-left: 1px solid var(--line); } .app > .right { order: 1; }' : ''} +.rail { background: var(--top-bg); color: var(--top-text); } .rail .muted, .rail .rail-group, .rail .rail-label { color: ${l.headerStyle === 'light' ? 'var(--muted)' : 'rgba(255,255,255,.72)'}; } +.rail-nav button { color: ${l.headerStyle === 'light' ? 'var(--text)' : 'var(--top-text)'}; } .rail-nav button.active { background: color-mix(in srgb, var(--accent) 14%, var(--panel)); color: var(--accent-2); } +.card, .status-strip, .tier-guide, .module-row, .opt-card, .mode-opt, .dropzone { border-radius: var(--radius); background-color: var(--panel); border-color: var(--line); } +.card { padding: var(--pad); } .bottom-pane, .tabpane { gap: var(--gap); } +.left, .toolbar, .preview { background: var(--panel); } +.top-pane { background: var(--top-bg); color: var(--top-text); } .top-pane .muted, .top-pane .sub { color: ${l.headerStyle === 'light' ? 'var(--muted)' : 'rgba(255,255,255,.75)'}; } +input, textarea, select { background: var(--panel); color: var(--text); border-color: var(--line); border-radius: calc(var(--radius) - 2px); } +button, .btn { border-radius: calc(var(--radius) - 2px); background-color: var(--panel); color: var(--text); border-color: var(--line); } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } button.primary:hover { background: var(--accent-2); } +.main-tabs button.active { background: color-mix(in srgb, var(--accent) 14%, var(--panel)); color: var(--accent-2); border-color: color-mix(in srgb, var(--accent) 35%, var(--panel)); } +.credit-pill { border-color: color-mix(in srgb, var(--accent) 35%, var(--panel)); } .credit-pill b { color: var(--accent-2); } +.ai-main-input { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: color-mix(in srgb, var(--accent) 4%, var(--panel)); } +${darkPanel ? `.ghost, code, tfoot td, .toolbar, .preview, .editor-bar, .dropzone, .pending li, .basics, .chip, .opt-card, .page-row, .progress, .tier-guide, .update-card, .update-card #fInstruction, .status-strip, .credit-footer, .usage-group td, .wm-dialog, .help-tip, .main-tabs button, .fb-env-note, .mode-opt, .card { background-color: color-mix(in srgb, var(--panel) 92%, var(--text)); color: var(--text); } +.mode-opt:hover, .opt-card.on, .main-tabs button:hover { background-color: color-mix(in srgb, var(--accent) 22%, var(--panel)); } +.progress.running, .progress.done, .progress.error, .progress.cancelled, .progress.waiting-credit, .progress-deploy, .progress-warn { background-color: color-mix(in srgb, var(--panel) 85%, var(--accent)); color: var(--text); } .progress .pmsg, .progress #progressText { color: var(--text); } /* v1.3.2: 화면 설정 미리보기에서 발견 — 어두운 화면에서 진행 상자 글자가 안 보이던 문제 */ +th { background: color-mix(in srgb, var(--panel) 85%, var(--text)); color: var(--text); } +.tier-guide tr.on td, .usage-group td, tr.on td { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); color: var(--text); } .pill, .badge { filter: saturate(.85) brightness(.95); } a { color: color-mix(in srgb, var(--accent) 80%, #fff); }` : ''} +`; +} +/** 등급 → 모델 참조(provider:model). 등급이 없거나 모르면 기본 등급 */ +function tierRef(tier) { const s = load(); const t = TIERS.some((x) => x.id === tier) ? tier : (s.defaultTier || 'standard'); return (s.tiers && s.tiers[t]) || DEFAULT_TIERS[t]; } +function validTier(tier) { return TIERS.some((x) => x.id === tier) ? tier : (load().defaultTier || 'standard'); } +/** Claude 실행 환경에 넣을 인증 변수 */ +/** + * Claude 인증 값 (v1.3.1): 암호화 보관소 우선, 없으면 1.3.0 이전 settings.json 값(이전 전 호환). + * @param {'apiKey'|'oauthToken'} field + */ +function authSecret(field) { let v = ''; try { v = require('./vault').secretOf(field === 'apiKey' ? 'anthropic-api-key' : 'anthropic-oauth'); } catch (_) {} return v || (load().auth || {})[field] || ''; } +function authEnv() { + const a = load().auth; const env = {}; + if (a.mode === 'api-key' && authSecret('apiKey')) env.ANTHROPIC_API_KEY = authSecret('apiKey'); + else if (a.mode === 'oauth-token' && authSecret('oauthToken')) env.CLAUDE_CODE_OAUTH_TOKEN = authSecret('oauthToken'); + return env; +} +/** 보관소로 옮긴 뒤 settings.json 에서 키 값을 지울 때 쓰는 내부 함수 (vault.migrate 전용) */ +function replaceSecrets({ mail, auth }) { const s = load(); if (mail) s.mail = mail; if (auth) s.auth = auth; s.updatedAt = new Date().toISOString(); fs.writeFileSync(FILE, JSON.stringify(s, null, 2), { mode: 0o600 }); try { fs.chmodSync(FILE, 0o600); } catch (_) {} cache = s; } +/** 화면 표시용(비밀값 마스킹) */ +function publicView() { + const s = load(); + return { ...s, internalRef: internalRef(), claudeModels: Array.isArray(s.claudeModels) ? s.claudeModels : [], tiers: { ...DEFAULT_TIERS, ...(s.tiers || {}) }, tierCatalog: TIERS, serviceApiKey: s.serviceApiKey ? '••••' : '', serviceApiKeyHint: s.serviceApiKey ? s.serviceApiKey.slice(0, 6) + '…' : '', backlogKey: s.backlogKey ? '••••' : '', backlogKeyHint: s.backlogKey ? s.backlogKey.slice(0, 6) + '…' : '', mail: { ...(s.mail || {}), apiKey: s.mail && s.mail.apiKey ? '••••' : '' }, auth: { mode: s.auth.mode, apiKey: authSecret('apiKey') ? '••••' : '', oauthToken: authSecret('oauthToken') ? '••••' : '', apiKeyHint: authSecret('apiKey') ? authSecret('apiKey').slice(0, 10) + '…' + authSecret('apiKey').slice(-4) : '', oauthTokenHint: authSecret('oauthToken') ? authSecret('oauthToken').slice(0, 12) + '…' : '' }, models: MODELS, /* v1.4.0 (요구 15): AI 제공자 API 키는 암호문이라도 화면에 보내지 않는다 — 입력칸은 비어 있고, 새로 넣으면 바뀐다 */ providers: Object.fromEntries(Object.entries(s.providers || {}).map(([k, v]) => [k, { ...(v || {}), apiKey: v && v.apiKey ? '••••' : '' }])), domainsConfig: (() => { try { return require('./domains').config(); } catch (_) { return null; } })() }; // v1.4.0 도메인 설정(기본값 포함) +} +function serverLoginInfo() { + try { const j = JSON.parse(fs.readFileSync(path.join(process.env.HOME || '/home/master', '.claude.json'), 'utf8')); const o = j.oauthAccount || {}; return { email: o.emailAddress || null, org: o.organizationName || null, billing: o.billingType || null }; } catch (_) { return { email: null }; } +} +/** 사용량으로 USD 비용 추정 (실행 중 표시용). 캐시 생성은 입력 단가의 1.25배로 근사 */ +/** + * 토큰 사용량 → USD (가격표 MODELS). + * v1.4.5: 캐시 쓰기 단가를 고쳤다 — Claude Code 는 1시간 캐시를 써서 입력 단가의 2배다(5분 캐시는 1.25배). + * usage 에 종류별 토큰(cacheCreate1h·cacheCreate5m)이 있으면 그대로, 없으면(실시간 추정·옛 기록) 1시간 캐시로 본다. + * 이 계산은 CLI 가 알려 주는 비용과 일치한다(2026-10-10 실측: Haiku 0.005288 = 0.005288, Opus 작업 0.388024 ≈ 0.3880244). + * 1.25배로 계산하던 1.4.4 까지는 추정이 낮게 나와 보정 배율(costcal)이 이를 메우고 있었다. + * 모델 이름에 날짜가 붙은 경우(claude-haiku-4-5-20251001)도 앞부분으로 찾는다. + */ +function estimateUsd(usage, modelId) { + const id = String(modelId || '').replace(/^[a-z]+:/, ''); + const m = MODELS.find((x) => x.id === id) || MODELS.find((x) => id.startsWith(x.id)) || MODELS[0]; if (!usage) return 0; + const cc = usage.cacheCreate || 0; const c5 = Math.min(cc, usage.cacheCreate5m || 0); const c1 = cc - c5; + return ((usage.input || 0) * m.input + (usage.output || 0) * m.output + (usage.cacheRead || 0) * m.cacheRead + c1 * m.input * 2 + c5 * m.input * 1.25) / 1e6; +} +module.exports = { authSecret, replaceSecrets, THEME_DEFAULTS, THEME_PRESETS, getTheme, normalizeTheme, themeCss, MODELS, TIERS, DEFAULT_TIERS, TIER_GUIDE, TYPICAL_USAGE, tierGuide, load, save, getModel, internalRef, claudeModels, tierRef, validTier, authEnv, publicView, serverLoginInfo, estimateUsd }; diff --git a/modules/webmaker/lib/setupapi.js b/modules/webmaker/lib/setupapi.js new file mode 100644 index 0000000..8f3a6bc --- /dev/null +++ b/modules/webmaker/lib/setupapi.js @@ -0,0 +1,167 @@ +'use strict'; +// ============================================================================ +// 설정 파일·연동 상태 API (v1.5.7, 요구 2·7) +// +// 두 곳에서 같은 함수를 쓴다. +// - 초기 설정 페이지(setup-server.js): 설치 직후 운영자가 별도 포트(기본 3081)로 접속해 설정 파일을 채우는 전용 웹페이지. /api/setup/* +// - 관리자 콘솔(server.js): 로그인한 관리자가 › 시스템 설정 탭에서 같은 화면을 쓴다. /api/admin/config/* +// 화면(public/setup.js)은 어느 쪽에 붙어 있는지와 무관하게 아래 경로 모양만 안다. +// +// GET 설정 항목표(값·출처)와 연동·엔진 상태, 서버 정보, 설치 명령 안내 +// PUT 값 저장 { values: { '.': 값 } } → 바뀐 항목, 재시작 필요 여부 +// POST /test/ 연결 확인: source(Gitea) · mail(Arimail) · claude · codex · resend +// POST /provision// 연동 준비 작업: source/ensure-repo, source/register-key +// POST /restart 웹메이커 서비스 재시작 (sudo systemctl restart ) +// POST /migrate-env 지금 프로세스의 환경 변수를 설정 파일로 옮겨 적기(없는 항목만) +// v1.5.11 (요구 1·2) — 설치 점검표·관리자 계정·AI 엔진 인증 (처음 설치한 서버를 웹에서만 운영 가능한 상태로 만들기) +// GET /ready 설치 점검표 (lib/setupstate.js: 관리자 비밀번호·AI 인증·공개 주소 + 참고 항목) +// POST /admin/bootstrap 관리자 admin 을 1회용 무작위 비밀번호로 만들기 (v1.6.31, 응답에 password 한 번만) — 이미 있으면 그대로 +// POST /admin/create 새 관리자 { id, name, password } +// POST /admin/password 관리자 비밀번호 재설정 { id, password } — 설정 페이지에 들어온 사람(토큰·관리자)만 +// PUT /ai/claude Claude 인증 { mode: server-login|oauth-token|api-key, apiKey?, oauthToken? } (관리자 콘솔 › AI 연결 탭 › Claude 모델 연결과 같은 저장) +// PUT /ai/openai OpenAI { enabled, authMode: api-key|server-login, apiKey? } +// ============================================================================ +const os = require('os'); +const path = require('path'); +const { execFile } = require('child_process'); +const VERSION = require('../package.json').version; +const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s }); + +function run(cmd, args, timeout = 20000) { + return new Promise((resolve) => { execFile(cmd, args, { timeout, env: { ...process.env, LANG: 'C' } }, (err, stdout, stderr) => resolve({ ok: !err, code: err ? err.code : 0, stdout: String(stdout || '').trim(), stderr: String(stderr || '').trim() })); }); +} +/** systemd 유닛 상태 (sudo 없이 조회) */ +async function serviceState(unit) { + if (!unit) return { unit: null, state: 'unknown', detail: '설정 파일 service.systemdUnit 이 비어 있어 서비스 상태를 알 수 없습니다 (설치 스크립트가 넣습니다)' }; + const r = await run('systemctl', ['is-active', unit], 8000); + const state = r.stdout || (r.stderr ? 'unknown' : 'inactive'); + let since = null; try { const p = await run('systemctl', ['show', unit, '-p', 'ActiveEnterTimestamp', '--value'], 8000); since = p.stdout || null; } catch (_) {} + return { unit, state, active: state === 'active', since, detail: state === 'active' ? `${unit} 실행 중${since ? ` (${since})` : ''}` : `${unit}: ${state}` }; +} +/** 설치 스크립트 안내 명령 (현재 설정값으로 채운 예시) */ +function installCommands(config) { + const unit = config.get('service.systemdUnit') || 'webmaker'; const appDir = path.join(__dirname, '..'); + const domain = config.get('mail.domain') || 'example.com'; + const lan = config.lanIps()[0] || '192.168.0.10'; + return { + gitea: `${appDir}/deploy/install-gitea.sh --service ${unit} --bind ${lan}`, + arimail: `${appDir}/deploy/install-arimail.sh --host mail.${domain} --mailbox admin@${domain}`, + arimailRegister: `${appDir}/deploy/install-arimail.sh --register-only --url https://mail.${domain} --domain ${domain} --admin-login admin --admin-password '<비밀번호>'`, + services: `${appDir}/deploy/install-services.sh --check`, + cli: `node ${appDir}/scripts/config.js list`, + }; +} + +/** 전체 보기: 설정 항목표 + 상태 */ +async function view() { + const config = require('./config'); const connectors = require('./connectors'); + const unit = config.get('service.systemdUnit') || ''; + const [source, mail, service, setup] = await Promise.all([connectors.status('source'), connectors.status('mail'), serviceState(unit), serviceState('webmaker-setup')]); + // v1.5.11: 실행 파일뿐 아니라 인증(서버 로그인·OAuth 토큰·API 키)까지 본다 — 설치 직후 "실행 파일은 있는데 로그인이 없어" 작업이 실패하던 상태를 미리 보이게 + let claude = null; try { const c = require('./claude').claudeInfo(); const a = await require('./setupstate').claudeAuth(); claude = { ok: !!c.bin && a.ok, bin: c.bin, version: c.version, searched: c.searched, auth: a, detail: c.bin ? `claude ${c.version || ''} (${c.bin}) · ${a.detail}${a.ok ? '' : ` — ${a.hint || ''}`}` : `claude 실행 파일을 찾지 못했습니다 (탐색: ${(c.searched || []).join(', ')})` }; } catch (e) { claude = { ok: false, detail: e.message }; } + let codex = null; try { const c = require('./engine-codex').codexInfo(); const sb = c.sandbox || {}; codex = { ok: !!c.bin, bin: c.bin, version: c.version, loggedIn: c.loggedIn, mode: c.mode, sandbox: sb, detail: c.bin ? `${c.version || 'codex'} · ${c.loggedIn ? `로그인됨 (${c.mode})` : '로그인 안 됨 (codex login)'} · 샌드박스 ${sb.mode === 'none' ? '없음' : sb.mode || '?'}${sb.restricted ? ' — ' + sb.reason : ''}` : 'codex 실행 파일 없음 (선택 — OpenAI 서버 로그인을 쓸 때만)' }; } catch (e) { codex = { ok: false, detail: e.message }; } + let resend = null; try { const m = await require('./mailer').status(); resend = { ok: !!m.configured, detail: m.configured ? `메일 발송 키 있음 (${m.source})${m.verifiedDomains && m.verifiedDomains.length ? ` · 검증 도메인 ${m.verifiedDomains.join(', ')}` : ''}` : (m.error || '메일 발송 키 없음 — 관리자 콘솔 › 설정 › 메일 발송') }; } catch (e) { resend = { ok: false, detail: e.message }; } + // v1.5.11: 설치 점검표·관리자 계정·AI 인증 방식·접속 주소 — 설정 페이지 맨 위 카드들이 쓴다 + const ready = await require('./setupstate').check(true); + const admins = require('./auth').adminState(); + const s = require('./settings').load(); const aiAuth = { claude: { mode: (s.auth || {}).mode || 'server-login', hasApiKey: !!require('./settings').authSecret('apiKey'), hasOauthToken: !!require('./settings').authSecret('oauthToken') } }; + try { const p = require('./providers').listAdminProviders().find((x) => x.id === 'openai'); aiAuth.openai = p ? { enabled: p.enabled, authMode: p.authMode || 'api-key', hasKey: p.hasKey, apiKeyMasked: p.apiKeyMasked, codex: p.codex || null } : null; } catch (_) { aiAuth.openai = null; } + return { + version: VERSION, now: new Date().toISOString(), + server: { hostname: os.hostname(), ips: config.lanIps(), node: process.version, pid: process.pid, appDir: path.join(__dirname, '..'), dataRoot: process.env.DATA_ROOT || path.join(__dirname, '..', 'data'), configFile: config.FILE, user: os.userInfo().username, setupUrls: config.setupUrls(''), publicBaseUrl: config.get('service.publicBaseUrl') || '' }, + config: config.publicView(), connectors: connectors.list(), + status: { service, setup, source, mail, claude, codex, resend }, + ready, admins, aiAuth, bootstrap: { id: require('./auth').BOOTSTRAP_ID }, /* v1.6.31: 고정 비밀번호 없음 */ + install: installCommands(config), + }; +} +/** v1.5.11 관리자 계정 작업 (토큰 또는 관리자 세션으로 설정 페이지에 들어온 사람만 — 호출 전에 인증은 서버 쪽에서 끝났다) */ +function adminAction(action, body, by) { + const auth = require('./auth'); const setupstate = require('./setupstate'); const b = body || {}; + if (action === 'bootstrap') { const r = auth.bootstrapAdmin(by || 'setup'); setupstate.invalidate(); if (r.created) try { require('./audit').log({ by: by || 'setup', action: 'admin.bootstrap', targetType: 'user', target: auth.BOOTSTRAP_ID }); } catch (_) {} return { ok: true, password: r.created ? r.password : undefined, created: r.created, user: r.user, detail: r.created ? `기본 관리자 ${auth.BOOTSTRAP_ID} / ${auth.BOOTSTRAP_PASSWORD} 를 만들었습니다. 로그인 뒤 비밀번호를 바꿔야 합니다` : `${auth.BOOTSTRAP_ID} 계정이 이미 있습니다${r.user.mustChangePassword ? ' (비밀번호 기본값 — 바꾸거나 아래에서 재설정하세요)' : ''}` }; } + if (action === 'create') { const id = String(b.id || '').trim().toLowerCase(); if (!id) throw httpError('아이디를 입력하세요'); const u = auth.createUser({ id, password: String(b.password || ''), name: String(b.name || id), role: 'admin' }); setupstate.invalidate(); return { ok: true, user: u, detail: `관리자 ${u.id} 를 만들었습니다` }; } + if (action === 'password') { const id = String(b.id || '').trim().toLowerCase(); if (!id) throw httpError('아이디를 입력하세요'); const u0 = auth.getUser(id); if (!u0) throw httpError('사용자가 없습니다', 404); if (u0.role !== 'admin') throw httpError('관리자 계정만 여기서 재설정할 수 있습니다 (일반 회원은 관리자 콘솔 › 사용자)'); const u = auth.resetPassword(id, String(b.password || '')); setupstate.invalidate(); return { ok: true, user: u, detail: `${u.id} 의 비밀번호를 바꿨습니다. 이제 이 계정으로 로그인할 수 있습니다` }; } + throw httpError('지원하지 않는 작업입니다'); +} +/** v1.5.11 AI 엔진 인증 저장 — 관리자 콘솔 › AI 연결 탭(settings.auth / providers.openai, v1.5.16 에 설정 탭에서 옮김)과 같은 저장소 */ +async function aiAction(kind, body, by) { + const setupstate = require('./setupstate'); const b = body || {}; + if (kind === 'claude') { + const settings = require('./settings'); const mode = String(b.mode || ''); + if (!['server-login', 'oauth-token', 'api-key'].includes(mode)) throw httpError('인증 방식은 server-login / oauth-token / api-key 중 하나입니다'); + const patch = { auth: { mode } }; if (typeof b.apiKey === 'string' && b.apiKey.trim()) patch.auth.apiKey = b.apiKey.trim(); if (typeof b.oauthToken === 'string' && b.oauthToken.trim()) patch.auth.oauthToken = b.oauthToken.trim(); + if (mode === 'api-key' && !patch.auth.apiKey && !settings.authSecret('apiKey')) throw httpError('API 키를 입력하세요 (sk-ant-…)'); + if (mode === 'oauth-token' && !patch.auth.oauthToken && !settings.authSecret('oauthToken')) throw httpError('OAuth 토큰을 입력하세요 (서버에서 claude setup-token 으로 발급)'); + settings.save(patch); setupstate.invalidate(); + const a = await setupstate.claudeAuth(); + return { ok: a.ok, detail: a.ok ? `저장했습니다 — ${a.detail}` : `저장했지만 아직 쓸 수 없습니다: ${a.detail}${a.hint ? ' · ' + a.hint : ''}`, auth: a }; + } + if (kind === 'openai') { + const providers = require('./providers'); const patch = {}; if (b.enabled !== undefined) patch.enabled = !!b.enabled; if (['api-key', 'server-login'].includes(b.authMode)) patch.authMode = b.authMode; if (typeof b.apiKey === 'string' && b.apiKey.trim()) patch.apiKey = b.apiKey.trim(); + const p = providers.saveAdminProvider('openai', patch); setupstate.invalidate(); const st = setupstate.openaiState(); + return { ok: st.ok, detail: st.ok ? `저장했습니다 — ${st.detail}` : `저장했습니다 (${st.detail}${st.hint ? ' · ' + st.hint : ''})`, provider: { enabled: p.enabled, authMode: p.authMode, hasKey: p.hasKey } }; + } + throw httpError('지원하지 않는 엔진입니다'); +} +/** 저장 */ +function save(body, by) { + const config = require('./config'); + const values = body && body.values && typeof body.values === 'object' ? body.values : body; + const r = config.set(values, by); try { require('./setupstate').invalidate(); } catch (_) {} // v1.5.11 점검표(공개 주소 등) 다시 계산 + return { ok: true, changed: r.changed, restart: r.restart, note: r.changed.length ? (r.restart ? `저장했습니다 (${r.changed.length}개). 재시작해야 적용되는 항목이 있습니다 — "서비스 재시작" 을 누르세요.` : `저장했습니다 (${r.changed.length}개). 바로 적용됩니다.`) : '바뀐 값이 없습니다.' }; +} +/** 연결 확인 */ +async function test(kind) { + const connectors = require('./connectors'); + if (kind === 'source' || kind === 'mail') return connectors.test(kind); + const v = await view(); + if (kind === 'claude' || kind === 'codex' || kind === 'resend') return { ok: !!v.status[kind].ok, detail: v.status[kind].detail, status: v.status[kind] }; + if (kind === 'service') return { ok: !!v.status.service.active, detail: v.status.service.detail, status: v.status.service }; + throw httpError('알 수 없는 확인 대상입니다'); +} +/** 연동 준비 작업 (API 토큰이 있는 Gitea) */ +async function provision(kind, action) { + const connectors = require('./connectors'); const m = connectors.active(kind); + if (!m) throw httpError(`${kind} 연동 모듈이 꺼져 있습니다`); + if (kind === 'source' && action === 'ensure-repo' && m.ensureRepo) { const r = await m.ensureRepo(); return { ok: true, detail: r.created ? `저장소 ${r.repo} 를 만들었습니다` : `저장소 ${r.repo} 가 이미 있습니다`, ...r }; } + if (kind === 'source' && action === 'register-key' && m.registerKey) { const r = await m.registerKey(); return { ok: true, detail: r.registered ? `이 서버의 공개키를 등록했습니다 (${r.title})` : '공개키가 이미 등록되어 있습니다', ...r }; } + throw httpError('지원하지 않는 작업입니다'); +} +/** + * 서비스 재시작. 실행 계정에 `sudo -n systemctl restart <유닛>` 권한이 있어야 한다 + * (설치 스크립트가 /etc/sudoers.d/webmaker-setup 에 넣는다). 실행 중인 AI 작업이 있으면 끝날 때까지(최대 10분) 기다린다. + */ +async function restart() { + const config = require('./config'); const unit = config.get('service.systemdUnit'); + if (!unit) throw httpError('설정 파일 service.systemdUnit(서비스 이름)이 비어 있어 재시작할 수 없습니다. 서버에서 직접: sudo systemctl restart webmaker'); + // v1.5.13: codex(OpenAI 서버 로그인 엔진) 작업도 기다린다 — claude 만 보던 1.5.7 코드는 codex 작업 중에 재시작해 작업을 끊었다 + for (let i = 0; i < 120; i++) { const p = await run('pgrep', ['-u', os.userInfo().username, '-f', '[c]laude -p|[c]odex exec'], 5000); if (!p.ok) break; await new Promise((r) => setTimeout(r, 5000)); } + const r = await run('sudo', ['-n', 'systemctl', 'restart', unit], 60000); + if (!r.ok) throw httpError(`재시작 실패: ${r.stderr || r.stdout || `exit ${r.code}`}. 서버에서 직접 실행하세요: sudo systemctl restart ${unit} (sudo 권한: deploy/install.sh 가 /etc/sudoers.d/webmaker-setup 을 만듭니다)`, 500); + await new Promise((r2) => setTimeout(r2, 1500)); + const st = await serviceState(unit); + return { ok: st.active, detail: st.active ? `${unit} 를 다시 시작했습니다` : `${unit} 가 시작되지 않았습니다 (${st.state}) — journalctl -u ${unit} -n 50 을 확인하세요`, status: st }; +} + +/** + * 공통 라우터. base 아래 경로(sub)와 메서드로 분기한다. + * @param {object} o { method, sub(배열: base 뒤 경로 조각), body(객체|null), by } + * @returns {Promise<{status:number, body:object}>} + */ +async function handle({ method, sub, body, by }) { + const [a, b, c] = sub || []; + if (!a) { + if (method === 'GET') return { status: 200, body: await view() }; + if (method === 'PUT') return { status: 200, body: save(body || {}, by) }; + } + if (a === 'ready' && method === 'GET') return { status: 200, body: await require('./setupstate').check(sub.includes('force')) }; // v1.5.11 설치 점검표 + if (a === 'admin' && b && method === 'POST') return { status: 200, body: adminAction(b, body, by) }; // v1.5.11 관리자 계정 + if (a === 'ai' && b && method === 'PUT') return { status: 200, body: await aiAction(b, body, by) }; // v1.5.11 AI 엔진 인증 + if (a === 'test' && b && method === 'POST') return { status: 200, body: await test(b) }; + if (a === 'provision' && b && c && method === 'POST') return { status: 200, body: await provision(b, c) }; + if (a === 'restart' && method === 'POST') return { status: 200, body: await restart() }; + if (a === 'migrate-env' && method === 'POST') { const moved = require('./config').migrateFromEnv(`migrate:${by || 'setup'}`); return { status: 200, body: { ok: true, moved, detail: moved.length ? `${moved.length}개 항목을 환경 변수에서 옮겼습니다: ${moved.join(', ')}` : '옮길 항목이 없습니다 (이미 설정 파일에 있거나 환경 변수가 비어 있음)' } }; } + return { status: 404, body: { error: 'not found' } }; +} + +module.exports = { view, save, test, provision, restart, handle, serviceState, installCommands }; diff --git a/modules/webmaker/lib/setupauth.js b/modules/webmaker/lib/setupauth.js new file mode 100644 index 0000000..216a2c3 --- /dev/null +++ b/modules/webmaker/lib/setupauth.js @@ -0,0 +1,91 @@ +'use strict'; +// ============================================================================ +// 초기 설정 페이지 인증 (v1.5.11, 요구 2) +// +// 1.5.7 에서는 접속 토큰·세션 쿠키 처리가 setup-server.js(별도 포트) 안에만 있었다. 1.5.11 부터는 본 서비스(server.js)도 +// 같은 설정 페이지를 /setup 에서 "관리자 로그인 없이 토큰으로" 열어야 하므로(설치 직후 첫 화면이 /setup 으로 넘어온다 — 관리자 +// 비밀번호를 아직 모를 수 있다) 인증 부분을 이 모듈로 떼어 두 서버가 같이 쓴다. +// +// - 접속 토큰: data/setup.token (0600, 32자 base64url). 없으면 만든다. 설치 스크립트가 `node scripts/config.js setup-url` 로 읽어 +// http://<서버 IP>:8795/?token=… 를 안내한다. 운영자는 서버의 파일에서도 볼 수 있다(= 서버 접근 권한이 곧 설정 권한). +// - 세션: 쿠키 wm_setup = base64url("|<만료 ms>") + "." + HMAC(session.secret 에서 파생한 키). 12시간. +// who 는 'token' 또는 'admin:<아이디>'. 본 서비스에서는 관리자 세션 쿠키(wh_session, role=admin)도 같은 권한으로 본다. +// - 로그인 시도 제한: IP 당 15분 10회 (토큰·비밀번호 대입 방지). 공인망에 열리는 포트이므로 꼭 둔다. +// +// 쓰는 쪽: setup-server.js (standalone), server.js (/api/setup/*, /setup) +// ============================================================================ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data'); +const TOKEN_FILE = path.join(DATA_ROOT, 'setup.token'); +const COOKIE = 'wm_setup'; +const SESSION_HOURS = 12; + +/** 접속 토큰: 없으면 만든다 (scripts/config.js setup-token 과 같은 규칙) */ +function token() { + try { const t = fs.readFileSync(TOKEN_FILE, 'utf8').trim(); if (t.length >= 24) return t; } catch (_) {} + fs.mkdirSync(DATA_ROOT, { recursive: true }); + const t = crypto.randomBytes(24).toString('base64url'); + fs.writeFileSync(TOKEN_FILE, t + '\n', { mode: 0o600 }); try { fs.chmodSync(TOKEN_FILE, 0o600); } catch (_) {} + return t; +} +/** 세션 서명 키: data/session.secret(본 서비스 세션과 같은 파일)에 용도 문자열을 섞어 파생 — 설정 세션과 서비스 세션의 서명이 서로 호환되지 않게 */ +function signKey() { + let s = ''; try { s = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim(); } catch (_) {} + if (!s) { s = crypto.randomBytes(32).toString('hex'); try { fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(path.join(DATA_ROOT, 'session.secret'), s, { mode: 0o600 }); } catch (_) {} } + return crypto.createHash('sha256').update('setup-session:' + s).digest(); +} +/** 세션 값 만들기 (who: 'token' | 'admin:') */ +function makeSession(who) { + const exp = Date.now() + SESSION_HOURS * 3600 * 1000; const body = `${who}|${exp}`; + const sig = crypto.createHmac('sha256', signKey()).update(body).digest('base64url'); + return Buffer.from(body).toString('base64url') + '.' + sig; +} +function sessionCookie(who, secure) { return `${COOKIE}=${makeSession(who)}; Path=/; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${SESSION_HOURS * 3600}`; } +function clearCookie() { return `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`; } +/** 요청의 설정 세션 쿠키 검증 → { who } 또는 null */ +function readSession(req) { + const m = new RegExp(`(?:^|;\\s*)${COOKIE}=([^;]+)`).exec(req.headers.cookie || ''); if (!m) return null; + const [b64, sig] = m[1].split('.'); if (!b64 || !sig) return null; + const body = Buffer.from(b64, 'base64url').toString(); const want = crypto.createHmac('sha256', signKey()).update(body).digest('base64url'); + if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null; + const i = body.lastIndexOf('|'); if (i < 0) return null; const who = body.slice(0, i); const exp = Number(body.slice(i + 1)); + if (!(exp > Date.now())) return null; return { who }; +} + +// ---- 로그인 시도 제한 ---- +const attempts = new Map(); +function limited(ip) { const now = Date.now(); const a = (attempts.get(ip) || []).filter((t) => now - t < 900000); attempts.set(ip, a); return a.length >= 10; } +function hit(ip) { const a = attempts.get(ip) || []; a.push(Date.now()); attempts.set(ip, a); } + +/** + * 로그인 처리: { token } 또는 { id, password }(관리자 계정만). + * @returns {{status:number, body:object, cookie?:string}} + */ +function login(body, ip, secure) { + if (limited(ip)) return { status: 429, body: { error: '로그인 시도가 너무 많습니다. 15분 뒤 다시 시도하세요' } }; + const b = body || {}; let who = null; + if (typeof b.token === 'string' && b.token.trim()) { + // v1.6.31 (요구 6, 검토 S7): 설치 점검이 끝나(비밀번호를 바꾼 관리자가 있음) 운영 중인 서버에서는 접속 토큰으로 들어올 수 없다 — 관리자 로그인만. + // 설치 스크립트가 알려 준 토큰 주소는 "최초 설정" 전용이며, 그 뒤에는 토큰 파일이 남아 있어도 쓰이지 않는다. + try { const st = require('./auth').adminState(); if (st.usable) { hit(ip); return { status: 403, body: { error: '최초 설정이 끝난 서버입니다. 접속 토큰은 더 쓸 수 없으니 관리자 계정으로 로그인하세요' } }; } } catch (_) {} + const t = token(); const given = b.token.trim(); + if (given.length === t.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(t))) who = 'token'; + if (!who) { hit(ip); return { status: 401, body: { error: '접속 토큰이 맞지 않습니다 (서버의 data/setup.token 파일 내용)' } }; } + } else if (b.id) { + if (require('./ratelimit').limited(`setup-login:${String(b.id).toLowerCase()}`, 10, 900000)) return { status: 429, body: { error: '이 계정의 로그인 시도가 너무 많습니다. 15분 뒤 다시 시도하세요' } }; + try { + const auth = require('./auth'); const u = auth.login({ id: String(b.id), password: String(b.password || '') }); + if (u.role !== 'admin') { hit(ip); return { status: 403, body: { error: '관리자 계정만 들어올 수 있습니다' } }; } + who = `admin:${u.id}`; + } catch (e) { hit(ip); return { status: 401, body: { error: e.message || '아이디 또는 비밀번호가 맞지 않습니다' } }; } + } else { hit(ip); return { status: 400, body: { error: '접속 토큰 또는 관리자 아이디·비밀번호를 보내세요' } }; } + return { status: 200, body: { ok: true, who }, cookie: sessionCookie(who, secure) }; +} + +/** v1.6.31 설정 세션용 CSRF 토큰 (who 에 묶인 HMAC) */ +function csrfOf(who) { return crypto.createHmac('sha256', signKey()).update('csrf|' + String(who)).digest('base64url'); } +function csrfOk(who, tok) { if (!who || !tok) return false; const a = Buffer.from(csrfOf(who)); const b = Buffer.from(String(tok)); return a.length === b.length && crypto.timingSafeEqual(a, b); } +module.exports = { csrfOf, csrfOk, TOKEN_FILE, COOKIE, token, makeSession, sessionCookie, clearCookie, readSession, login, limited, hit }; diff --git a/modules/webmaker/lib/setupstate.js b/modules/webmaker/lib/setupstate.js new file mode 100644 index 0000000..f6d4578 --- /dev/null +++ b/modules/webmaker/lib/setupstate.js @@ -0,0 +1,105 @@ +'use strict'; +// ============================================================================ +// 설치 점검표 — "운영할 수 있는 상태인가" (v1.5.11, 요구 2) +// +// 처음 설치한 서버는 설정값(관리자 비밀번호, AI 엔진 인증, 공개 주소 …)이 없어 운영할 수 없다. 이 모듈이 그 상태를 판단해 +// - server.js 가 첫 화면(/, /login)을 설정 페이지(/setup)로 보낼지 정하고 (required 가 true 인 동안만), +// - 설정 페이지 맨 위의 "설치 점검표" 카드가 항목별 상태·해결 방법을 보여 준다. +// 점검표가 모두 끝나면(required=false) 포워딩은 멈추고 정상 로그인 화면이 나온다. 운영자가 포워딩 자체를 끄려면 설정 service.setupRedirect=off. +// +// 필수 항목 (하나라도 안 되면 required) +// admin : 쓸 수 있는(비밀번호 변경이 강제되지 않은) 관리자 계정이 있나 — v1.6.31: 최초 관리자는 설정 페이지에서 운영자가 만든다(고정 비밀번호 없음) +// ai : 쓸 수 있는 AI 엔진이 하나라도 있나 — Claude Code(실행 파일 + 서버 로그인/OAuth 토큰/API 키) 또는 OpenAI(API 키·codex 로그인) +// publicBaseUrl : 서비스 공개 주소가 설치값(기본값 http://192.168.20.66)이 아닌 "이 서버의 값" 으로 정해졌나 +// 참고 항목 (운영은 되지만 안내) +// resend : 메일 발송 키, source : 소스 보관(Gitea) 원격, mail : 메일 서버(Arimail) 연동 +// +// 판단에는 claude auth status 실행(1초 안팎)이 들어가므로 결과를 15초 동안 캐시한다. 설정·계정을 바꾼 뒤에는 invalidate() 로 비운다. +// ============================================================================ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFile } = require('child_process'); + +const CACHE_MS = 15000; +let cache = null; let cacheAt = 0; let pending = null; + +/** 자식 프로세스용 환경: 상위 Claude Code 세션 변수를 뺀다 (lib/claude.js cleanEnv 와 같은 이유) */ +function childEnv() { const env = {}; for (const [k, v] of Object.entries(process.env)) { if (k === 'CLAUDECODE' || k.startsWith('CLAUDE_')) continue; env[k] = v; } if (!env.HOME) env.HOME = os.homedir(); return env; } +function run(cmd, args, timeout = 15000) { return new Promise((resolve) => { execFile(cmd, args, { timeout, env: childEnv() }, (err, stdout, stderr) => resolve({ ok: !err, stdout: String(stdout || '').trim(), stderr: String(stderr || '').trim() })); }); } + +/** + * Claude Code 인증 상태. + * - 관리자 설정(settings.auth.mode)이 api-key / oauth-token 이면 보관소에 그 값이 있는지만 본다 (실행 때 환경 변수로 넘김). + * - server-login 이면 `claude auth status`(JSON: loggedIn, authMethod)를 실행해 본다. 명령이 없는 옛 버전이면 ~/.claude/.credentials.json 존재로 대신 판단. + * @returns {Promise<{ok:boolean, mode:string, method?:string, detail:string, hint?:string}>} + */ +async function claudeAuth() { + const settings = require('./settings'); const claude = require('./claude'); + const info = claude.claudeInfo(); + if (!info.bin) return { ok: false, mode: 'none', detail: `claude 실행 파일이 없습니다 (탐색: ${(info.searched || []).join(', ')})`, hint: '서비스 실행 계정으로 설치: curl -fsSL https://claude.ai/install.sh | bash (또는 npm i -g @anthropic-ai/claude-code). 설치 뒤 설정 ai.claudeBin 을 비워 두면 자동 탐색' }; + const a = settings.load().auth || { mode: 'server-login' }; + const env = settings.authEnv(); + if (a.mode === 'api-key') return env.ANTHROPIC_API_KEY ? { ok: true, mode: 'api-key', detail: 'Anthropic API 키 (보관소)' } : { ok: false, mode: 'api-key', detail: 'API 키 방식이지만 저장된 키가 없습니다', hint: '아래 "AI 엔진 인증" 에서 API 키를 넣거나 서버 로그인으로 바꾸세요' }; + if (a.mode === 'oauth-token') return env.CLAUDE_CODE_OAUTH_TOKEN ? { ok: true, mode: 'oauth-token', detail: 'Claude OAuth 토큰 (보관소)' } : { ok: false, mode: 'oauth-token', detail: 'OAuth 토큰 방식이지만 저장된 토큰이 없습니다', hint: '서버에서 claude setup-token 으로 만든 토큰을 아래 "AI 엔진 인증" 에 넣으세요' }; + // 서버 로그인: claude auth status → { loggedIn, authMethod } + const r = await run(info.bin, ['auth', 'status']); + if (r.ok) { try { const j = JSON.parse(r.stdout); if (j && j.loggedIn) return { ok: true, mode: 'server-login', method: j.authMethod || '', detail: `서버 로그인됨 (${j.authMethod || 'claude'})` }; return { ok: false, mode: 'server-login', detail: '서버에 claude 로그인이 없습니다', hint: `서비스 실행 계정(${os.userInfo().username})으로 서버에서 한 번 실행: claude (브라우저 로그인) — 또는 아래에서 OAuth 토큰·API 키 방식으로 바꾸세요` }; } catch (_) {} } + const credFile = path.join(os.homedir(), '.claude', '.credentials.json'); + if (fs.existsSync(credFile)) return { ok: true, mode: 'server-login', detail: `서버 로그인 자격 증명 있음 (${credFile})` }; + return { ok: false, mode: 'server-login', detail: `서버 로그인 자격 증명이 없습니다 (${credFile} 없음)`, hint: `서비스 실행 계정(${os.userInfo().username})으로 서버에서 한 번 실행: claude (브라우저 로그인) — 또는 아래에서 OAuth 토큰·API 키 방식으로 바꾸세요` }; +} +/** OpenAI 엔진(API 키 또는 codex 서버 로그인) 상태 */ +function openaiState() { + try { + const providers = require('./providers'); const p = providers.listAdminProviders().find((x) => x.id === 'openai'); + if (!p || !p.enabled) return { ok: false, detail: 'OpenAI 제공자 꺼짐 (선택)' }; + if (p.authMode === 'server-login') return p.codex && p.codex.loggedIn ? { ok: true, detail: `codex 서버 로그인 (${p.codex.mode || ''})` } : { ok: false, detail: 'OpenAI 서버 로그인 방식이지만 codex 로그인이 없습니다', hint: '서버에서 codex login --device-auth' }; + return p.hasKey ? { ok: true, detail: `OpenAI API 키 ${p.apiKeyMasked || ''}` } : { ok: false, detail: 'OpenAI 제공자가 켜져 있지만 API 키가 없습니다' }; + } catch (e) { return { ok: false, detail: e.message }; } +} + +/** + * 점검표 계산. 결과는 15초 캐시. + * @param {boolean} [force] 캐시 무시 + * @returns {Promise<{required:boolean, redirect:boolean, items:object[], warnings:object[], at:string}>} + */ +async function check(force) { + if (!force && cache && Date.now() - cacheAt < CACHE_MS) return cache; + if (pending) { if (!force) return pending; await pending.catch(() => {}); } // force: 진행 중인 계산(옛 상태)을 기다렸다가 새로 계산 + pending = (async () => { + const config = require('./config'); const auth = require('./auth'); + const items = []; const warnings = []; + // 1) 관리자 계정 + const st = auth.adminState(); + if (!st.admins.length) items.push({ key: 'admin', label: '관리자 계정', ok: false, detail: '관리자 계정이 없습니다', hint: '아래 "관리자 계정" 에서 아이디·비밀번호를 정해 최초 관리자를 만드세요 (v1.6.31: 고정 비밀번호 admin/admin 은 없습니다)' }); + else if (!st.usable) items.push({ key: 'admin', label: '관리자 계정', ok: false, detail: `관리자 ${st.admins.map((u) => u.id).join(', ')} 의 비밀번호가 기본값(admin)입니다`, hint: '로그인 화면에서 admin / admin 으로 들어가 새 비밀번호를 정하거나, 아래 "관리자 계정" 에서 비밀번호를 재설정하세요' }); + else items.push({ key: 'admin', label: '관리자 계정', ok: true, detail: `관리자 ${st.admins.filter((u) => !u.mustChangePassword).map((u) => u.id).join(', ')}${st.defaultOpen ? ` (기본 비밀번호 그대로인 계정: ${st.admins.filter((u) => u.mustChangePassword).map((u) => u.id).join(', ')} — 바꾸거나 지우세요)` : ''}` }); + // 2) AI 엔진 + const cl = await claudeAuth(); const oa = openaiState(); + const aiOk = cl.ok || oa.ok; + items.push({ key: 'ai', label: 'AI 엔진 인증', ok: aiOk, detail: `Claude: ${cl.detail} · OpenAI: ${oa.detail}`, hint: aiOk ? '' : (cl.hint || oa.hint || '아래 "AI 엔진 인증" 에서 Claude 또는 OpenAI 인증을 넣으세요'), claude: cl, openai: oa }); + // 3) 공개 주소 + const pb = config.effective('service.publicBaseUrl'); + items.push({ key: 'publicBaseUrl', label: '서비스 공개 주소', ok: pb.source !== 'default', detail: pb.source === 'default' ? `설치값(${pb.value})이 그대로입니다 — 이 서버의 주소가 아닙니다` : `${pb.value} (${pb.source === 'file' ? '설정 파일' : '환경 변수'})`, hint: pb.source === 'default' ? '아래 "서비스" 묶음의 서비스 공개 주소에 이 서버의 주소(https://도메인 또는 http://IP)를 넣고 저장 → 서비스 재시작' : '' }); + // 참고 항목 + try { const m = await require('./mailer').status(); warnings.push({ key: 'resend', label: '메일 발송 (Resend)', ok: !!m.configured, detail: m.configured ? `키 있음 (${m.source})` : '메일 발송 키 없음 — 문의 메일·회원 메일이 나가지 않습니다 (관리자 콘솔 › 설정 › 메일 발송)' }); } catch (e) { warnings.push({ key: 'resend', label: '메일 발송 (Resend)', ok: false, detail: e.message }); } + warnings.push({ key: 'source', label: '소스 보관 (Gitea)', ok: !!config.get('source.remote'), detail: config.get('source.remote') ? config.get('source.remote') : '원격 보관 없음 — 사이트 버전은 서버 안에만 남습니다 (deploy/install-gitea.sh)' }); + // v1.5.19 (요구 2): 기본 배포 서버의 vhost 구성 능력 — sudo·nginx·/etc/nginx 쓰기. 서비스 유닛의 NoNewPrivileges·ProtectSystem 때문에 안 되는 경우를 설치 때 미리 보인다 + try { const deploy = require('./deploy'); const dl = deploy.list(); const t = dl.defaultId ? deploy.get(dl.defaultId) : null; if (t && t.manageVhost !== false) { const cap = t.type === 'local' || t.hasKey ? await deploy.vhostCapability(t) : { ok: false, hint: 'SSH 키 없음' }; warnings.push({ key: 'vhost', label: `배포 서버 도메인 연결 (${t.name})`, ok: cap.ok, detail: cap.ok ? 'nginx 설정 쓰기 가능 (sudo · nginx · /etc/nginx)' : `불가 — ${cap.hint || 'sudo 권한 없음'} (사이트 도메인 연결·SSL 발급이 실패합니다)` }); } } catch (e) { warnings.push({ key: 'vhost', label: '배포 서버 도메인 연결', ok: false, detail: e.message }); } + // v1.5.17 (요구 1): 플랫폼 도메인 와일드카드 인증서 — 서버 설치 때 미리 받아 둔 것(이름 무관)도 인지해 "서브 도메인에 자동 적용" 으로 보인다 + try { const w = await require('./ssl').detect(); const c = w.cert; warnings.push({ key: 'wildcard', label: `와일드카드 인증서${w.platformDomain ? ` (*.${w.platformDomain})` : ''}`, ok: !!(c && c.hasCert), detail: c && c.hasCert ? `있음 — 이름 ${c.certName}${c.external ? ' (서버에 미리 발급된 인증서)' : ''}, 만료 ${c.expires}${c.daysLeft != null ? ` (${c.daysLeft}일)` : ''} → 서브 도메인 사이트에 자동 적용` : (w.reason ? `확인 못 함 — ${w.reason}` : `없음 — 서브 도메인 사이트는 HTTP 로 열립니다 (관리자 콘솔 › 설정 › SSL 에서 발급하거나, 서버에서 certbot 으로 *.${w.platformDomain} 인증서를 받아 두면 자동 적용)`) }); } catch (e) { warnings.push({ key: 'wildcard', label: '와일드카드 인증서', ok: false, detail: e.message }); } + warnings.push({ key: 'mail', label: '메일 서버 (Arimail)', ok: (config.get('mail.provider') || 'none') !== 'none', detail: (config.get('mail.provider') || 'none') !== 'none' ? `${config.get('mail.provider')} ${config.get('mail.url') || ''}` : '연동 없음 — 받는 메일·웹메일은 쓸 수 없습니다 (deploy/install-arimail.sh)' }); + const required = items.some((i) => !i.ok); + const redirect = required && String(config.get('service.setupRedirect') || 'auto') !== 'off'; + cache = { required, redirect, items, warnings, at: new Date().toISOString() }; cacheAt = Date.now(); + return cache; + })().finally(() => { pending = null; }); + return pending; +} +/** 설정·계정이 바뀐 뒤 캐시 비우기 */ +function invalidate() { cache = null; cacheAt = 0; } +/** 마지막 계산 결과 (없으면 null) — 동기 경로에서 참고용 */ +function last() { return cache; } + +module.exports = { check, invalidate, last, claudeAuth, openaiState }; diff --git a/modules/webmaker/lib/sitemsg.js b/modules/webmaker/lib/sitemsg.js new file mode 100644 index 0000000..68d5018 --- /dev/null +++ b/modules/webmaker/lib/sitemsg.js @@ -0,0 +1,92 @@ +'use strict'; +// ============================================================================ +// 생성 사이트 시스템 문구 (v1.3.16) +// +// 생성 사이트에 서비스가 넣는 모든 글자 — 모듈 위젯(회원·게시판·문의·예약·갤러리·지도·후기·FAQ·설문) 화면 문구, +// 폼 라벨, 오류 메시지, 동의문, 쿠키·법적 고지, 사이트 소유자에게 가는 알림 메일 — 를 코드에 직접 쓰지 않고 +// locales/site/ko.json 의 id 로만 쓴다. 이 모듈이 id → 글자를 언어별로 돌려준다. +// +// 언어별 번역은 세 겹으로 찾는다 (뒤에 있을수록 우선): +// 1) locales/site/<언어>.json — 소스에 함께 저장하는 씨앗 번역 (영어는 전부, 다른 언어는 일부) +// 2) data/site-i18n/_runtime/<언어>.json — 다국어 사이트가 그 언어를 처음 쓸 때 AI 가 채운 번역 캐시(서비스 전체 공용) +// 3) data/site-i18n/_runtime/<언어>.override.json — 관리자가 고친 번역 (관리자 콘솔 › 언어 › 사이트 문구) +// 없으면 한국어 원문을 쓴다. {이름} 자리 표시는 값으로 채운다. +// ============================================================================ +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data'); +const RUNTIME_DIR = path.join(DATA_ROOT, 'site-i18n', '_runtime'); +const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (_) { return null; } }; + +let SOURCE = null; // { id: { text, group } } +function source() { if (!SOURCE) SOURCE = (readJson(path.join(ROOT, 'locales', 'site', 'ko.json')) || {}).messages || {}; return SOURCE; } +const cache = new Map(); // 언어 → { id: 번역 } (파일을 쓸 때 지운다) +/** 언어의 번역표 (씨앗 → AI 캐시 → 관리자 수정 순으로 덮어씀) */ +function table(lang) { + if (!lang || lang === 'ko') return {}; + if (cache.has(lang)) return cache.get(lang); + const safe = String(lang).replace(/[^\w-]/g, ''); + const t = { ...((readJson(path.join(ROOT, 'locales', 'site', `${safe}.json`)) || {}).messages || {}), ...(readJson(path.join(RUNTIME_DIR, `${safe}.json`)) || {}), ...(readJson(path.join(RUNTIME_DIR, `${safe}.override.json`)) || {}) }; + cache.set(lang, t); return t; +} +/** {이름} 자리 채우기 */ +const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m)); +/** 한국어 원문 */ +function text(id, vars) { const m = source()[id]; return fill(m ? m.text : id, vars); } +/** 언어별 문구 (번역이 없으면 한국어 원문) */ +function tr(lang, id, vars) { const t = table(lang)[id]; return t ? fill(t, vars) : text(id, vars); } +/** 번역 함수 하나로 묶기: const t = sitemsg.forLang('en'); t('board.empty') */ +const forLang = (lang) => (id, vars) => tr(lang, id, vars); +/** + * 오류 객체: 메시지는 한국어 원문, msgId·msgVars 를 함께 달아 API 가 요청 언어로 바꿔 보낼 수 있게 한다. + * @example throw sitemsg.err('err.noPost', 404) + */ +function err(id, status = 400, vars) { return Object.assign(new Error(text(id, vars)), { status, msgId: id, msgVars: vars }); } +/** API 오류 응답용: 오류 객체 → 요청 언어 메시지 */ +function errorText(e, lang) { return e && e.msgId ? tr(lang, e.msgId, e.msgVars) : (e && e.message) || ''; } +/** 위젯 스크립트에 넣을 표 (id → 그 언어 글자, 번역 없으면 원문) */ +function clientTable(lang, ids) { const src = source(); const t = table(lang); const out = {}; for (const id of ids || Object.keys(src)) out[id] = t[id] || (src[id] ? src[id].text : id); return out; } +/** 아직 번역이 없는 id (AI 번역 대상) */ +function missing(lang) { if (!lang || lang === 'ko') return []; const t = table(lang); return Object.keys(source()).filter((id) => !t[id]); } +/** AI 번역 결과 저장 (서비스 전체 공용 캐시) */ +function saveRuntime(lang, add) { + const safe = String(lang).replace(/[^\w-]/g, ''); fs.mkdirSync(RUNTIME_DIR, { recursive: true }); + const f = path.join(RUNTIME_DIR, `${safe}.json`); const cur = readJson(f) || {}; + for (const [k, v] of Object.entries(add || {})) if (source()[k] && typeof v === 'string' && v.trim()) cur[k] = v.trim(); + fs.writeFileSync(f, JSON.stringify(cur, null, 1)); cache.delete(lang); return Object.keys(cur).length; +} +/** 관리자 수정 번역 저장 (빈 문자열이면 그 항목 수정 취소) */ +function saveOverride(lang, patch) { + const safe = String(lang).replace(/[^\w-]/g, ''); fs.mkdirSync(RUNTIME_DIR, { recursive: true }); + const f = path.join(RUNTIME_DIR, `${safe}.override.json`); const cur = readJson(f) || {}; + for (const [k, v] of Object.entries(patch || {})) { if (!source()[k]) continue; if (typeof v === 'string' && v.trim()) cur[k] = v.trim(); else delete cur[k]; } + fs.writeFileSync(f, JSON.stringify(cur, null, 1)); cache.delete(lang); return cur; +} +/** + * AI 로 빠진 문구 채우기. translateFn(texts[], to, from) → string[] (lib/multilang 과 같은 모양). + * 자리 표시({name})가 빠지거나 바뀐 번역은 버린다(원문으로 표시됨). + * @returns {number} 새로 채운 개수 + */ +async function ensure(lang, translateFn) { + const ids = missing(lang); if (!ids.length) return 0; const src = source(); const add = {}; + for (let i = 0; i < ids.length; i += 100) { + const batch = ids.slice(i, i + 100); const tr0 = await translateFn(batch.map((id) => src[id].text), lang, 'ko'); + batch.forEach((id, k) => { const v = tr0[k]; const ph = (s) => (String(s).match(/\{\w+\}/g) || []).sort().join(','); if (typeof v === 'string' && v.trim() && ph(v) === ph(src[id].text)) add[id] = v; }); + } + saveRuntime(lang, add); return Object.keys(add).length; +} +/** 관리자 화면용 목록: id·그룹·원문·번역·출처 */ +function listFor(lang) { + const src = source(); const safe = String(lang).replace(/[^\w-]/g, ''); + const seed = (readJson(path.join(ROOT, 'locales', 'site', `${safe}.json`)) || {}).messages || {}; const ai = readJson(path.join(RUNTIME_DIR, `${safe}.json`)) || {}; const ov = readJson(path.join(RUNTIME_DIR, `${safe}.override.json`)) || {}; + return Object.entries(src).map(([id, m]) => ({ id, group: m.group, text: m.text, translation: ov[id] || ai[id] || seed[id] || '', origin: ov[id] ? 'admin' : ai[id] ? 'ai' : seed[id] ? 'seed' : '' })); +} +/** 위젯 템플릿의 {{t:id}} 자리를 그 언어 글자로, {{msgs:id1,id2}} 를 JSON 표(HTML 속성용)로 바꾼다 */ +function renderTemplate(html, lang) { + return html.replace(/\{\{t:([\w.]+)\}\}/g, (m, id) => tr(lang, id).replace(/"/g, '"')) + .replace(/\{\{msgs:([\w.,]+)\}\}/g, (m, ids) => JSON.stringify(clientTable(lang, ids.split(','))).replace(/&/g, '&').replace(/'/g, ''')); +} + +module.exports = { source, text, tr, forLang, err, errorText, clientTable, missing, ensure, saveRuntime, saveOverride, listFor, renderTemplate, table }; diff --git a/modules/webmaker/lib/ssl.js b/modules/webmaker/lib/ssl.js new file mode 100644 index 0000000..618e5f8 --- /dev/null +++ b/modules/webmaker/lib/ssl.js @@ -0,0 +1,157 @@ +'use strict'; +// ============================================================================ +// 플랫폼 도메인 와일드카드 인증서 관리 (v1.4.14, 요구 9) +// +// 배경: 서브 도메인 계정의 사이트(mycompany.ibit.kr …)는 모두 플랫폼 도메인(ibit.kr) 아래에 있다. 사이트마다 인증서를 따로 +// 받으면 Let's Encrypt 의 "등록 도메인당 주간 발급 한도" 에 걸리고, 인증서 수도 끝없이 늘어난다. 그래서 플랫폼 도메인에는 +// 와일드카드 인증서(*.ibit.kr) 하나를 발급해 그 아래 모든 사이트의 HTTPS 를 함께 처리한다. +// 고객이 가진 도메인(example.com)은 사이트별 인증서(wh-<사이트ID>, webroot 방식)를 그대로 쓴다 — 두 종류는 분리해 관리한다. +// +// 와일드카드는 DNS-01 인증만 가능하다(_acme-challenge.<도메인> TXT 레코드). TXT 를 넣는 방법(dnsMode): +// openprovider : 플랫폼 도메인 DNS 가 Openprovider 에 있을 때(설정 › 도메인 서비스 › DNS 방식 '자동') — 훅이 API 로 TXT 를 넣고 지운다. 갱신도 자동. +// manual : 그 밖(ibit.kr 은 hosting.co.kr DNS) — 훅이 필요한 TXT 값을 대기 파일에 적고 공용 DNS 에 보일 때까지 기다린다. +// 운영자는 관리자 콘솔 › 설정 › SSL 에 뜨는 TXT 를 DNS 관리 화면에 넣는다. 갱신(약 60일마다)도 같은 절차가 필요하다. +// +// 동작: 배포 서버(type=local, 서비스 서버 자신)에서 certbot 을 --manual + 훅(scripts/acme-dns-hook.js)으로 실행한다. +// 인증서 이름 wh-wild-<플랫폼 도메인>, SAN 은 *.<플랫폼 도메인> 하나. 발급은 오래 걸릴 수 있어(수동 DNS 대기 최대 WAIT_MIN 분) 비동기로 +// 돌리고 상태(진행 중·대기 TXT·마지막 결과)를 data/ssl-wildcard.json 과 /var/tmp/webmaker-acme/ 에서 읽어 화면에 보인다. +// 원격(ssh) 배포 서버는 지원하지 않는다 — 훅이 그 서버에서 실행되어 이 서비스의 DNS 접속 정보를 쓸 수 없기 때문. +// +// v1.5.17 (요구 1): 서버를 설치할 때 이미 *.<플랫폼 도메인> 인증서를 받아 둔 경우(이름이 wh-wild-… 가 아님)도 인지한다. +// deploy.findWildcardCert 가 SAN 으로 찾고(detect), 서비스 기동 때 autoApply 가 HTTP 로 배포돼 있던 서브 도메인 사이트의 nginx 설정을 +// 그 인증서로 다시 써 재배포 없이 HTTPS 가 된다. 설치 점검표(lib/setupstate)와 scripts/ssl-wildcard.js 도 같은 detect 를 쓴다. +// ============================================================================ +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const deploy = require('./deploy'); + +const ROOT = path.resolve(__dirname, '..'); +const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data'); +const STATE_FILE = path.join(DATA_ROOT, 'ssl-wildcard.json'); +/** 훅(root 로 실행)과 서비스(일반 사용자)가 함께 쓰는 대기 디렉터리 — 훅이 TXT 챌린지를 적고, 서비스가 읽어 화면에 보인다 */ +const PENDING_DIR = process.env.WM_ACME_DIR || '/var/tmp/webmaker-acme'; +const HOOK = path.join(ROOT, 'scripts', 'acme-dns-hook.js'); +const WAIT_MIN = Math.max(5, parseInt(process.env.WM_ACME_WAIT_MIN || '40', 10)); // 수동 DNS 에서 TXT 를 기다리는 최대 시간(분) +const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s }); + +let job = null; // 진행 중인 발급 { startedAt, targetId, domain, mode, child } + +function loadState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) || {}; } catch (_) { return {}; } } +function saveState(d) { try { fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(d, null, 1), { mode: 0o600 }); } catch (_) {} } + +/** 플랫폼 도메인 DNS 방식 → 챌린지 방식 */ +// v1.5.36 (요구 5): 플랫폼 도메인의 DNS 공급자 id(openprovider·porkbun·route53·gcloud) — 훅이 그 공급자로 TXT 를 넣는다. 없으면 'manual' +function dnsMode() { try { const z = require('./domains').platformZone(); return z ? z.id : 'manual'; } catch (_) { return 'manual'; } } +/** 와일드카드 발급 대상 배포 서버: 지정 → 기본 배포 서버 */ +function targetFor(targetId) { const dl = deploy.list(); const tid = targetId || dl.defaultId; return tid ? deploy.get(tid) : null; } + +/** + * v1.5.17 와일드카드 인증서 감지 — 플랫폼 도메인과 기본(또는 지정) 배포 서버에서 *.<도메인> 인증서를 찾는다. 이름은 무엇이든 된다. + * @returns {Promise<{platformDomain:string, target:object|null, cert:object|null, reason:string}>} cert: deploy.findWildcardCert 결과(hasCert·certName·external·만료) + */ +async function detect(targetId) { + const pd = deploy.platformDomain(); const t = targetFor(targetId); + if (!pd) return { platformDomain: '', target: null, cert: null, reason: '플랫폼 도메인이 없습니다' }; + if (!t) return { platformDomain: pd, target: null, cert: null, reason: '배포 서버가 없습니다' }; + if (t.allowSudo !== true) return { platformDomain: pd, target: t, cert: null, reason: `배포 서버 "${t.name}" 에서 sudo 사용이 허용되지 않아 인증서 디렉터리를 읽을 수 없습니다` }; + try { return { platformDomain: pd, target: t, cert: await deploy.findWildcardCert(t, pd), reason: '' }; } catch (e) { return { platformDomain: pd, target: t, cert: null, reason: e.message }; } +} +/** + * v1.5.17 기동 때 자동 적용: 와일드카드 인증서가 있는데(설치 때 미리 받아 둔 것 포함) 서브 도메인 주소가 HTTP 로 배포돼 있는 사이트의 vhost 를 다시 쓴다. + * 판단 근거는 마지막 성공 배포 기록의 domainUrls — 플랫폼 서브 도메인이 http:// 로 남아 있으면 그 사이트. 결과는 상태 파일 autoApply 에 남겨 관리자 콘솔 › 설정 › SSL 에 보인다. + * @returns {Promise<{checked:boolean, certName?:string, reconfigured:number, sites:string[], reason?:string}>} + */ +async function autoApply(targetId) { + const d = await detect(targetId); + if (!d.cert || !d.cert.hasCert) return { checked: !!d.cert, reconfigured: 0, sites: [], reason: d.reason || '와일드카드 인증서 없음' }; + const store = require('./store'); const pd = d.platformDomain; const sites = []; + for (const s0 of store.list()) { + const site = store.load(s0.id); if (!site) continue; + const subs = (site.domains || []).filter((x) => deploy.isPlatformSub(x, pd)); if (!subs.length) continue; + const dep = [...(site.deployments || [])].reverse().find((x) => x.ok && x.kind !== 'unpublish'); if (!dep || dep.targetId !== d.target.id) continue; + if (!subs.some((x) => (dep.domainUrls || []).includes(`http://${x}/`))) continue; // 이미 HTTPS 로 배포된 사이트는 건너뜀 + try { const r = await deploy.configureVhost(site.id, d.target.id, site.domains); if (r.ok) { sites.push(site.id); dep.domainUrls = (dep.domainUrls || []).map((u) => (subs.some((x) => u === `http://${x}/`) ? u.replace('http://', 'https://') : u)); dep.sslDetail = (dep.sslDetail ? dep.sslDetail + ' / ' : '') + `v1.5.17 기동 때 와일드카드 인증서(${d.cert.certName}) 자동 적용`; store.save(site); } } catch (_) {} + } + const st = loadState(); st.autoApply = { at: new Date().toISOString(), certName: d.cert.certName, external: d.cert.external, reconfigured: sites.length, sites }; saveState(st); + return { checked: true, certName: d.cert.certName, external: d.cert.external, reconfigured: sites.length, sites }; +} +/** 훅이 남긴 대기 챌린지 (수동 DNS 모드) — { domain, name, value, at } 또는 null */ +function pendingChallenge(domain) { + try { + const f = path.join(PENDING_DIR, `${domain}.json`); const j = JSON.parse(fs.readFileSync(f, 'utf8')); + if (Date.now() - Date.parse(j.at) > (WAIT_MIN + 5) * 60000) return null; // 끝난 지 오래된 파일은 무시 + return j; + } catch (_) { return null; } +} + +/** + * 현재 상태 (관리자 콘솔 › 설정 › SSL). + * @returns {{ platformDomain, dnsMode, target, supported, reason, cert, pending, running, last, waitMinutes }} + */ +async function status(targetId) { + const pd = deploy.platformDomain(); const t = targetFor(targetId); const mode = dnsMode(); const st = loadState(); + const out = { platformDomain: pd, dnsMode: mode, target: t ? { id: t.id, name: t.name, type: t.type } : null, supported: false, reason: '', certName: pd ? deploy.wildcardCertName(pd) : null, cert: null, pending: pd ? pendingChallenge(pd) : null, running: !!job, job: job ? { startedAt: job.startedAt, domain: job.domain, mode: job.mode } : null, last: st.last || null, waitMinutes: WAIT_MIN, email: !!(require('./settings').load().letsEncryptEmail) }; + if (!pd) { out.reason = '플랫폼 도메인이 없습니다 — 관리자 콘솔 › 설정 › 도메인 서비스 에 플랫폼 도메인(예: ibit.kr)을 넣으세요'; return out; } + if (!t) { out.reason = '배포 서버가 없습니다 (관리자 콘솔 › 배포 서버)'; return out; } + if (t.type !== 'local') { out.reason = `와일드카드 인증서는 서비스 서버가 배포 서버인 경우(종류 local)에만 발급합니다. "${t.name}" 은(는) 원격(ssh) 서버라 DNS 인증 훅을 실행할 수 없습니다 — 그 서버의 서브 도메인은 사이트별 인증서로 발급됩니다`; } + else if (t.allowSudo !== true) out.reason = '배포 서버 설정에서 sudo 사용 허용이 꺼져 있어 certbot 을 실행할 수 없습니다'; + else out.supported = true; + // v1.5.17: 서비스가 발급한 이름뿐 아니라 서버에 미리 받아 둔 *.<도메인> 인증서도 찾는다 — 있으면 그 이름이 certName 이 되고 external 로 표시 + try { out.cert = await deploy.findWildcardCert(t, pd); out.certName = out.cert.certName; out.external = !!out.cert.external; } catch (e) { out.cert = { hasCert: false, error: e.message }; } + out.autoApply = st.autoApply || null; + return out; +} + +/** + * 와일드카드 인증서 발급·갱신 시작 (비동기). 이미 진행 중이면 409. + * certbot 은 --keep-until-expiring 이라 만료가 멀면 그대로 두고 성공으로 끝난다(갱신 강제는 force=true). + */ +async function issue(targetId, { force = false } = {}) { + if (job) throw httpError('와일드카드 인증서 발급이 이미 진행 중입니다', 409); + const s = await status(targetId); if (!s.supported) throw httpError(s.reason || '발급할 수 없습니다', 400); + // v1.5.17: 다른 절차로 받아 둔 와일드카드 인증서가 이미 유효하면 새로 발급하지 않는다(서브 도메인에 자동 적용 중). 갱신은 그 인증서를 받은 방법(certbot renew 타이머)대로. + if (!force && s.cert && s.cert.hasCert && s.external && !(s.cert.daysLeft != null && s.cert.daysLeft < 20)) throw httpError(`서버에 이미 와일드카드 인증서가 있습니다 (이름 ${s.cert.certName}, ${(s.cert.sans || []).join(', ')}, 만료 ${s.cert.expires}) — 서브 도메인 사이트에 자동 적용됩니다. 이 서비스 이름(${deploy.wildcardCertName(s.platformDomain)})으로 따로 발급하려면 강제 갱신(force)을 쓰세요`, 409); + s.certName = deploy.wildcardCertName(s.platformDomain); // 발급은 늘 서비스 이름으로 + const t = targetFor(targetId); const pd = s.platformDomain; const mode = s.dnsMode; + const email = require('./settings').load().letsEncryptEmail || ''; + try { fs.mkdirSync(PENDING_DIR, { recursive: true, mode: 0o1777 }); fs.chmodSync(PENDING_DIR, 0o1777); } catch (_) {} + try { fs.unlinkSync(path.join(PENDING_DIR, `${pd}.json`)); } catch (_) {} + const node = process.execPath; + // 훅에 넘길 환경: DATA_ROOT(접속 정보·설정), 방식, 대기 시간. sudo 는 환경을 지우므로 env 로 명시한다 + const envArgs = [`DATA_ROOT=${DATA_ROOT}`, `WM_ACME_MODE=${mode}`, `WM_ACME_DIR=${PENDING_DIR}`, `WM_ACME_WAIT_MIN=${WAIT_MIN}`, `WM_ACME_ZONE=${pd}`, `PATH=${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}`]; + const args = ['-n', 'env', ...envArgs, 'certbot', 'certonly', '--manual', '--preferred-challenges', 'dns', '--manual-auth-hook', `${node} ${HOOK} auth`, '--manual-cleanup-hook', `${node} ${HOOK} cleanup`, '--non-interactive', '--agree-tos', ...(email ? ['-m', email] : ['--register-unsafely-without-email']), '--cert-name', s.certName, '--expand', force ? '--force-renewal' : '--keep-until-expiring', '-d', `*.${pd}`, '--deploy-hook', 'systemctl reload nginx || nginx -s reload']; + const startedAt = new Date().toISOString(); + const child = spawn('sudo', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + job = { startedAt, targetId: t.id, domain: pd, mode, child }; + let out = '', err = ''; + child.stdout.on('data', (d) => { out += d; if (out.length > 20000) out = out.slice(-20000); }); + child.stderr.on('data', (d) => { err += d; if (err.length > 20000) err = err.slice(-20000); }); + const timer = setTimeout(() => { try { child.kill('SIGTERM'); } catch (_) {} }, (WAIT_MIN + 10) * 60000); + child.on('close', async (code) => { + clearTimeout(timer); job = null; + const all = `${out}\n${err}`; const ok = code === 0 && !/error|failed|problem/i.test(err.split('\n').filter((l) => !/^Saving debug log/.test(l)).join('\n')) || /Successfully received certificate|Certificate not yet due for renewal|not yet due/.test(all); + const summary = ok ? (/not yet due/.test(all) ? '기존 인증서가 아직 유효해 그대로 둠' : '발급·갱신 완료') : (all.match(/(?:Error|Problem|Detail|Timeout|too many|rate|unauthorized|Hook)[^\n]{0,300}/gi) || [all.trim().split('\n').slice(-3).join(' ')]).slice(0, 3).join(' / ').slice(0, 600); + const st = loadState(); st.last = { at: new Date().toISOString(), ok, detail: summary, domain: pd, mode, code }; saveState(st); + try { fs.unlinkSync(path.join(PENDING_DIR, `${pd}.json`)); } catch (_) {} + // 발급이 끝났으면 이 플랫폼의 서브 도메인을 쓰는 사이트들의 nginx 설정을 와일드카드 인증서로 다시 쓴다 + if (ok) { try { await reconfigureSites(t.id, pd); } catch (_) {} } + }); + return { started: true, startedAt, mode, certName: s.certName, waitMinutes: WAIT_MIN }; +} +/** 발급 뒤: 플랫폼 서브 도메인이 연결된 사이트들의 vhost 를 다시 구성 (HTTP → 와일드카드 HTTPS) */ +async function reconfigureSites(targetId, pd) { + const store = require('./store'); let n = 0; + for (const s0 of store.list()) { + const site = store.load(s0.id); if (!site || !(site.domains || []).some((d) => deploy.isPlatformSub(d, pd))) continue; + const dep = (site.deployments || []).filter((d) => d.ok).slice(-1)[0]; if (!dep || dep.targetId !== targetId) continue; + try { await deploy.configureVhost(site.id, targetId, site.domains); n += 1; } catch (_) {} + } + const st = loadState(); if (st.last) { st.last.reconfigured = n; saveState(st); } + return n; +} +/** 진행 중 발급 취소 */ +function cancel() { if (!job) return false; try { job.child.kill('SIGTERM'); } catch (_) {} return true; } + +function lastState() { return loadState(); } // v1.6.31 (요구 20) 운영 복구 목록용 — 마지막 발급 결과 +module.exports = { lastState, status, issue, cancel, detect, autoApply, pendingChallenge, dnsMode, PENDING_DIR, WAIT_MIN }; diff --git a/modules/webmaker/lib/stats.js b/modules/webmaker/lib/stats.js new file mode 100644 index 0000000..49df373 --- /dev/null +++ b/modules/webmaker/lib/stats.js @@ -0,0 +1,244 @@ +'use strict'; +// ============================================================================ +// 사이트 접속 통계 모듈 (v1.0.2) +// +// 두 가지 경로로 통계를 모은다. +// 1) 서버 로그(측정): 우리가 관리하는 nginx 에 `wmstats` 로그 형식을 추가해 요청마다 +// 수신 바이트($request_length)·송신 바이트($bytes_sent)·상태·경로·리퍼러·UA 를 기록하고, +// 수집기가 주기적으로 새로 쌓인 부분만 읽어 사이트별·일자별로 집계한다. +// - 서비스 서버 자체(로컬 nginx): /var/log/nginx/webmaker-sites.log 한 파일, URI 의 /<32hex>/ 로 사이트 식별 +// - 원격 배포 서버(SSH, sudo 허용): 사이트 vhost 마다 /var/log/nginx/wh-.access.log +// 2) 브라우저 비컨(추정): 모든 HTML 에 삽입되는 /wm-stats.js 가 페이지를 열 때 한 번 +// POST /api/stats/beacon 으로 경로·리퍼러·화면·전송량(Performance API transferSize)을 보고한다. +// 로그를 읽을 수 없는 서버(가져온 기존 사이트의 원본 서버 등)에서도 방문 통계를 얻기 위한 보조 경로. +// +// 저장: data/stats/.json +// { days: { 'YYYY-MM-DD': { req, pv, bytesIn, bytesOut, bots, v: {해시: 1}, vOver, +// pages: {경로: 조회수}, refs: {출처: 수}, hosts: {호스트: 수}, +// b: { pv, v: {해시: 1}, vOver, bytesOut, screens: {구간: 수}, langs: {언어: 수} } } }, +// updatedAt } +// v(방문자 집합)는 "일자+IP+UA" 의 해시만 저장하고(원본 IP 는 저장하지 않음) 하루 MAX_VISITOR_KEYS 개까지만 보관, +// 넘치면 vOver 에 개수만 더한다. 방문자 수 = Object.keys(v).length + vOver. +// ============================================================================ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data'); +const STATS_DIR = path.join(DATA_ROOT, 'stats'); +const OFFSETS_FILE = path.join(STATS_DIR, '_offsets.json'); // 로그 파일별 읽은 위치 { key: { inode, offset } } +fs.mkdirSync(STATS_DIR, { recursive: true }); + +const MAX_VISITOR_KEYS = 5000; // 하루에 보관하는 방문자 해시 개수 상한 (그 이상은 vOver 로 개수만) +const MAX_DAYS = 400; // 보관 일수 (누적치는 totals 에 따로 합산해 두므로 오래된 일자는 버려도 됨) +const TOP_KEYS = 60; // 페이지/유입 경로 집계에 보관하는 키 개수 상한 +const SITE_ID_RE = /^\/([a-f0-9]{32})(?:\/|$)/; +// 검색엔진·크롤러·모니터링 봇 UA. 봇 요청은 bots 로만 세고 방문자·페이지뷰에서 뺀다. +const BOT_RE = /bot|crawl|spider|slurp|facebookexternalhit|preview|monitor|uptime|headless|curl\/|wget\/|python-requests|go-http-client|java\/|libwww|okhttp|scrapy|ahrefs|semrush|mj12|dotbot|petalbot|yandex|baidu|bingpreview|lighthouse|pingdom/i; +// 페이지로 간주하는 요청: HTML 문서(확장자 없음 포함). 이미지·CSS·JS 는 요청(req)·바이트에만 반영. +const ASSET_EXT_RE = /\.(css|js|mjs|json|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf|eot|map|mp4|webm|mp3|pdf|zip|txt|xml)$/i; + +/** 서울 기준 날짜 문자열 'YYYY-MM-DD' (통계 일자는 모두 KST) */ +function kstDate(ts) { const d = new Date(ts || Date.now()); return new Date(d.getTime() + 9 * 3600 * 1000).toISOString().slice(0, 10); } +/** 방문자 식별 해시: 일자 + 서버 비밀 + IP + UA. 원본 IP 는 저장하지 않는다. */ +let SALT = null; +function salt() { if (SALT) return SALT; try { SALT = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim().slice(0, 32); } catch (_) { SALT = 'wm'; } return SALT; } +function visitorHash(day, ip, ua) { return crypto.createHash('sha1').update(`${day}|${salt()}|${ip || ''}|${(ua || '').slice(0, 200)}`).digest('hex').slice(0, 16); } + +// ---------- 저장소 ---------- +function fileOf(siteId) { return path.join(STATS_DIR, `${siteId}.json`); } +function load(siteId) { try { return JSON.parse(fs.readFileSync(fileOf(siteId), 'utf8')); } catch (_) { return { days: {}, totals: { req: 0, pv: 0, bytesIn: 0, bytesOut: 0, bots: 0, bpv: 0, bBytesOut: 0 }, updatedAt: null }; } } +function save(siteId, d) { + // 오래된 일자는 totals 에 합산한 뒤 버린다 (파일 크기 상한) + const keys = Object.keys(d.days).sort(); + while (keys.length > MAX_DAYS) { const k = keys.shift(); const x = d.days[k]; d.totals.req += x.req || 0; d.totals.pv += x.pv || 0; d.totals.bytesIn += x.bytesIn || 0; d.totals.bytesOut += x.bytesOut || 0; d.totals.bots += x.bots || 0; d.totals.bpv += (x.b && x.b.pv) || 0; d.totals.bBytesOut += (x.b && x.b.bytesOut) || 0; d.totals.visitors = (d.totals.visitors || 0) + visitors(x); d.totals.bVisitors = (d.totals.bVisitors || 0) + bVisitors(x); delete d.days[k]; } // v1.3.17 (B31): 방문자 수도 누적에 남긴다 (일별 방문자 합 — 전체 기간 지표와 같은 기준) + d.updatedAt = new Date().toISOString(); + const tmp = fileOf(siteId) + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d)); fs.renameSync(tmp, fileOf(siteId)); +} +function remove(siteId) { try { fs.unlinkSync(fileOf(siteId)); } catch (_) {} } +/** 일자 레코드(없으면 생성) */ +function dayRec(d, day) { + if (!d.days[day]) d.days[day] = { req: 0, pv: 0, bytesIn: 0, bytesOut: 0, bots: 0, v: {}, vOver: 0, pages: {}, refs: {}, hosts: {}, b: { pv: 0, v: {}, vOver: 0, bytesOut: 0, screens: {}, langs: {} } }; + return d.days[day]; +} +/** 집합에 방문자 해시를 넣는다(상한 초과 시 개수만) */ +function addVisitor(rec, hash) { if (rec.v[hash]) return; if (Object.keys(rec.v).length >= MAX_VISITOR_KEYS) { rec.vOver += 1; return; } rec.v[hash] = 1; } +/** 상위 N 개만 보관하는 카운터 증가 */ +function bump(map, key, n = 1) { + if (!key) return; key = String(key).slice(0, 200); + if (map[key] === undefined && Object.keys(map).length >= TOP_KEYS) { const min = Object.entries(map).sort((a, b) => a[1] - b[1])[0]; if (min && min[1] <= n) delete map[min[0]]; else return; } + map[key] = (map[key] || 0) + n; +} +/** 리퍼러를 "출처 호스트" 로 정규화. 같은 사이트 안의 이동은 (직접/내부) 로 묶는다. */ +function refHost(ref, selfHosts) { if (!ref || ref === '-') return '(직접 접속)'; try { const h = new URL(ref).hostname.toLowerCase(); if (selfHosts && selfHosts.has(h)) return '(사이트 내 이동)'; return h; } catch (_) { return '(기타)'; } } +/** 사이트 안 경로로 정규화: //about.html → /about.html, 쿼리 제거 */ +function pagePath(uri, siteId) { let p = String(uri || '/').split('?')[0]; if (siteId && p.startsWith('/' + siteId)) p = p.slice(siteId.length + 1) || '/'; return p || '/'; } + +// ---------- 기록 ---------- +// 짧은 시간에 여러 건이 들어와도 사이트당 한 번만 파일을 쓰도록 지연 저장한다. +const dirty = new Map(); // siteId -> data +let flushTimer = null; +function markDirty(siteId, d) { dirty.set(siteId, d); if (!flushTimer) flushTimer = setTimeout(flush, 3000); } +function flush() { flushTimer = null; for (const [id, d] of dirty) { try { save(id, d); } catch (_) {} } dirty.clear(); } +function current(siteId) { return dirty.get(siteId) || load(siteId); } + +/** + * 서버 로그 한 줄 반영. + * @param {string} siteId + * @param {object} e { ts(ms), ip, host, method, uri, status, reqLen, bytesSent, referer, ua } + */ +function recordLog(siteId, e) { + const d = current(siteId); const day = kstDate(e.ts); const rec = dayRec(d, day); + rec.req += 1; rec.bytesIn += e.reqLen || 0; rec.bytesOut += e.bytesSent || 0; + bump(rec.hosts, (e.host || '').toLowerCase()); + const bot = BOT_RE.test(e.ua || ''); + if (bot) { rec.bots += 1; markDirty(siteId, d); return; } + const p = pagePath(e.uri, siteId); + const isPage = e.method === 'GET' && (e.status === 200 || e.status === 304) && !ASSET_EXT_RE.test(p); + if (isPage) { rec.pv += 1; addVisitor(rec, visitorHash(day, e.ip, e.ua)); bump(rec.pages, p); bump(rec.refs, refHost(e.referer, new Set([(e.host || '').toLowerCase()]))); } + markDirty(siteId, d); +} +/** + * 브라우저 비컨 반영. 로그가 없는 서버의 보조 통계. + * @param {object} b { ip, ua, path, ref, host, transfer(바이트), lang, w, h } + */ +function recordBeacon(siteId, b) { + if (BOT_RE.test(b.ua || '')) return; + const d = current(siteId); const day = kstDate(); const rec = dayRec(d, day); + rec.b.pv += 1; rec.b.bytesOut += Math.max(0, Math.min(50 * 1024 * 1024, Number(b.transfer) || 0)); + const bh = visitorHash(day, b.ip, b.ua); if (!rec.b.v[bh]) { if (Object.keys(rec.b.v).length >= MAX_VISITOR_KEYS) rec.b.vOver += 1; else rec.b.v[bh] = 1; } + bump(rec.hosts, (b.host || '').toLowerCase()); + const w = Number(b.w) || 0; bump(rec.b.screens, w ? (w < 600 ? '모바일' : (w < 1024 ? '태블릿' : 'PC')) : '알 수 없음'); + bump(rec.b.langs, String(b.lang || '').slice(0, 10) || '-'); + // 로그를 못 읽는 사이트는 비컨 페이지뷰를 pages/refs 에도 반영 (서버 로그가 있으면 중복이지만 pv 자체는 분리 보관) + bump(rec.pages, pagePath(b.path, siteId)); bump(rec.refs, refHost(b.ref, new Set([(b.host || '').toLowerCase()]))); + markDirty(siteId, d); +} + +// ---------- nginx 로그 파싱 ---------- +/** + * `wmstats` 로그 형식(deploy/nginx-wmstats.conf) 한 줄을 파싱한다. + * $msec|$remote_addr|$host|$request_method|$request_uri|$status|$request_length|$bytes_sent|$http_referer|$http_user_agent + * UA 안에 '|' 가 있을 수 있으므로 앞 9개만 나누고 나머지는 UA 로 합친다. + */ +function parseLine(line) { + const f = line.split('|'); if (f.length < 10) return null; + const ts = Math.round(parseFloat(f[0]) * 1000); if (!Number.isFinite(ts)) return null; + return { ts, ip: f[1], host: f[2], method: f[3], uri: f[4], status: parseInt(f[5], 10) || 0, reqLen: parseInt(f[6], 10) || 0, bytesSent: parseInt(f[7], 10) || 0, referer: f[8], ua: f.slice(9).join('|') }; +} +function loadOffsets() { try { return JSON.parse(fs.readFileSync(OFFSETS_FILE, 'utf8')); } catch (_) { return {}; } } +function saveOffsets(o) { const tmp = OFFSETS_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o)); fs.renameSync(tmp, OFFSETS_FILE); } +/** + * 로그 텍스트 덩어리를 집계한다. siteOf(entry) 가 사이트 ID 를 돌려주지 않으면 건너뛴다. + * @returns {number} 반영한 줄 수 + */ +function ingestText(text, siteOf) { + let n = 0; + for (const line of text.split('\n')) { if (!line) continue; const e = parseLine(line); if (!e) continue; const id = siteOf(e); if (!id) continue; recordLog(id, e); n += 1; } + flush(); + return n; +} +/** + * 로컬 로그 파일에서 지난번 이후 새로 쌓인 부분만 읽어 반영한다. + * 파일이 교체(로테이션)되어 inode 가 바뀌거나 크기가 줄었으면 처음부터 다시 읽는다. + * 로테이션 직후에는 이전 파일(.1)의 꼬리를 놓칠 수 있는데, 5분 주기 수집에서 그 손실은 작다고 보고 단순화했다. + */ +function collectLocalFile(logPath, siteOf, key) { + let st; try { st = fs.statSync(logPath); } catch (_) { return { ok: false, lines: 0, detail: '로그 파일 없음' }; } + const offs = loadOffsets(); const k = key || logPath; const prev = offs[k] || { inode: null, offset: 0 }; + let start = prev.offset; if (prev.inode !== st.ino || st.size < prev.offset) start = 0; + if (st.size === start) { offs[k] = { inode: st.ino, offset: start }; saveOffsets(offs); return { ok: true, lines: 0 }; } + const fd = fs.openSync(logPath, 'r'); const len = st.size - start; const buf = Buffer.alloc(Math.min(len, 64 * 1024 * 1024)); + const read = fs.readSync(fd, buf, 0, buf.length, start); fs.closeSync(fd); + let text = buf.slice(0, read).toString('utf8'); let consumed = read; + // 마지막 줄이 아직 덜 쓰였을 수 있으므로 개행까지만 처리 + const nl = text.lastIndexOf('\n'); if (nl < 0) return { ok: true, lines: 0 }; if (nl < text.length - 1) { consumed = Buffer.byteLength(text.slice(0, nl + 1)); text = text.slice(0, nl + 1); } + const lines = ingestText(text, siteOf); + offs[k] = { inode: st.ino, offset: start + consumed }; saveOffsets(offs); + return { ok: true, lines }; +} +/** 원격 로그용: 외부에서 받아온 텍스트와 새 오프셋을 기록 */ +function offsetOf(key) { return loadOffsets()[key] || { inode: null, offset: 0 }; } +function setOffset(key, inode, offset) { const o = loadOffsets(); o[key] = { inode, offset }; saveOffsets(o); } + +// ---------- 저장 공간 ---------- +/** 디렉터리 총 바이트 (심볼릭 링크 제외) */ +function dirSize(dir, skip = new Set()) { + let total = 0; + try { for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { if (skip.has(ent.name)) continue; const p = path.join(dir, ent.name); try { if (ent.isDirectory()) total += dirSize(p, skip); else if (ent.isFile()) total += fs.statSync(p).size; } catch (_) {} } } catch (_) {} + return total; +} +function fileSize(p) { try { return fs.statSync(p).size; } catch (_) { return 0; } } + +// ---------- 요약 ---------- +const sum = (recs, f) => recs.reduce((a, r) => a + (f(r) || 0), 0); +const visitors = (r) => (r ? Object.keys(r.v || {}).length + (r.vOver || 0) : 0); +const bVisitors = (r) => (r && r.b ? Object.keys(r.b.v || {}).length + (r.b.vOver || 0) : 0); +/** + * 사용자 화면용 요약. + * @param {string} siteId + * @param {object} paths { siteDir, siteJson, inquiriesJson, gitDir } + * @returns 오늘/어제/7일/30일/이번 달/누적 방문자·페이지뷰·요청·바이트, 일별 시계열(최근 30일), 상위 페이지/유입, 기기, 저장 공간 + */ +function summary(siteId, paths = {}) { + const d = load(siteId); const days = d.days; const today = kstDate(); const month = today.slice(0, 7); + const dayKeys = Object.keys(days).sort(); + const lastN = (n) => { const out = []; for (let i = n - 1; i >= 0; i--) { const k = kstDate(Date.now() - i * 86400000); out.push([k, days[k]]); } return out; }; + const agg = (recs) => { + const list = recs.filter(Boolean); + return { visitors: sum(list, visitors), pv: sum(list, (r) => r.pv), req: sum(list, (r) => r.req), bots: sum(list, (r) => r.bots), bytesIn: sum(list, (r) => r.bytesIn), bytesOut: sum(list, (r) => r.bytesOut), beaconPv: sum(list, (r) => r.b && r.b.pv), beaconVisitors: sum(list, bVisitors), beaconBytesOut: sum(list, (r) => r.b && r.b.bytesOut) }; + }; + const all = agg(dayKeys.map((k) => days[k])); // 보관 중인 일자 + totals(버린 일자) + all.req += d.totals.req; all.pv += d.totals.pv; all.bytesIn += d.totals.bytesIn; all.bytesOut += d.totals.bytesOut; all.bots += d.totals.bots; all.beaconPv += d.totals.bpv; all.beaconBytesOut += d.totals.bBytesOut; all.visitors += d.totals.visitors || 0; all.beaconVisitors += d.totals.bVisitors || 0; + const todayRec = days[today]; const yKey = kstDate(Date.now() - 86400000); + const series = lastN(30).map(([k, r]) => ({ day: k, visitors: visitors(r), pv: r ? r.pv : 0, bytesIn: r ? r.bytesIn : 0, bytesOut: r ? r.bytesOut : 0, beaconPv: r && r.b ? r.b.pv : 0, beaconVisitors: bVisitors(r), beaconBytesOut: r && r.b ? r.b.bytesOut : 0 })); + const merge = (f) => { const m = {}; for (const k of dayKeys.slice(-30)) for (const [key, n] of Object.entries(f(days[k]) || {})) m[key] = (m[key] || 0) + n; return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([key, n]) => ({ key, n })); }; + const hasLog = all.req > 0; const hasBeacon = all.beaconPv > 0; + // 저장 공간: 사이트 파일(첨부 별도) 과 "DB"(사이트 정보·문의·통계·버전 저장소) + const siteBytes = paths.siteDir ? dirSize(paths.siteDir, new Set(['.git', '.claude', '.refs', 'uploads'])) : 0; + const uploadBytes = paths.siteDir ? dirSize(path.join(paths.siteDir, 'uploads')) : 0; + const db = { site: fileSize(paths.siteJson), inquiries: fileSize(paths.inquiriesJson), stats: fileSize(fileOf(siteId)), git: paths.gitDir ? dirSize(paths.gitDir) : 0 }; + db.total = db.site + db.inquiries + db.stats + db.git; + // v1.7.2 (요구 2): 메일 데이터 — 사이트 메일함 파일(data/mail/). 메일 시스템이 꺼져 있으면 null(화면에서 숨김). Arimail 서버 쪽 메일함 용량은 API 가 주지 않아 포함하지 않는다 + const mailBytes = paths.mailEnabled && paths.mailDir ? dirSize(paths.mailDir) : null; + // v1.7.2 (요구 2): 달력 기준 월간 합계 — 직전 2개월 + 이번 달(누적 중). 일자 키는 KST 날짜라 월 경계도 KST + const months = []; + for (let i = 2; i >= 0; i--) { const dt = new Date(today + 'T00:00:00Z'); dt.setUTCMonth(dt.getUTCMonth() - i, 1); const key = dt.toISOString().slice(0, 7); const recs = dayKeys.filter((k) => k.startsWith(key)).map((k) => days[k]); months.push({ month: key, days: recs.length, current: key === month, ...agg(recs) }); } + return { + updatedAt: d.updatedAt, sources: { log: hasLog, beacon: hasBeacon }, + today: agg([todayRec]), yesterday: agg([days[yKey]]), last7: agg(lastN(7).map((x) => x[1])), last30: agg(lastN(30).map((x) => x[1])), month: agg(dayKeys.filter((k) => k.startsWith(month)).map((k) => days[k])), total: all, + series, series7: series.slice(-7), months, + topPages: merge((r) => r.pages), topRefs: merge((r) => r.refs), devices: merge((r) => r.b && r.b.screens), hosts: merge((r) => r.hosts), + disk: { site: siteBytes, uploads: uploadBytes, total: siteBytes + uploadBytes, web: siteBytes + uploadBytes, mail: mailBytes, limit: null /* 허용량은 아직 없음 */ }, db, + }; +} + +// ---------- 비컨 삽입 ---------- +const BEACON_MARK = 'data-wm-stats'; +/** + * 사이트의 모든 HTML 에 통계 비컨 `; + let n = 0; + const walk = (dir) => { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.name.startsWith('.') || ent.name === 'node_modules' || ent.name === 'uploads') continue; + const p = path.join(dir, ent.name); + if (ent.isDirectory()) { walk(p); continue; } + if (!/\.html?$/i.test(ent.name)) continue; + let html; try { html = fs.readFileSync(p, 'utf8'); } catch (_) { continue; } + if (html.includes(BEACON_MARK)) { if (!html.includes(tag)) { html = html.replace(/]*data-wm-stats[^>]*><\/script>\s*/i, tag + '\n'); fs.writeFileSync(p, html); } continue; } + const i = html.search(/<\/body\s*>/i); + html = i >= 0 ? html.slice(0, i) + tag + '\n' + html.slice(i) : html + '\n' + tag + '\n'; + try { fs.writeFileSync(p, html); n += 1; } catch (_) {} + } + }; + try { walk(siteDir); } catch (_) {} + return n; +} + +module.exports = { recordLog, recordBeacon, parseLine, ingestText, collectLocalFile, offsetOf, setOffset, summary, ensureBeacon, remove, flush, kstDate, SITE_ID_RE, STATS_DIR, dirSize }; diff --git a/modules/webmaker/lib/store.js b/modules/webmaker/lib/store.js new file mode 100644 index 0000000..16f26e9 --- /dev/null +++ b/modules/webmaker/lib/store.js @@ -0,0 +1,73 @@ +'use strict'; +// 사이트 메타데이터/명령 이력/토큰 사용량을 data/sites/.json 에 저장하는 단순 JSON 저장소. +const fs = require('fs'); +const path = require('path'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data', 'sites'); +fs.mkdirSync(DATA_DIR, { recursive: true }); + +const ID_RE = /^[a-f0-9]{32}$/; + +function fileOf(id) { return path.join(DATA_DIR, `${id}.json`); } + +function emptyTotals() { + return { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0, total: 0, costUsd: 0, chargeKrw: 0, commands: 0 }; +} + +function recomputeTotals(site) { + const t = emptyTotals(); + for (const c of site.commands) { + if (!c.usage) continue; + t.input += c.usage.input; t.output += c.usage.output; + t.cacheCreate += c.usage.cacheCreate; t.cacheRead += c.usage.cacheRead; + t.thinking += c.usage.thinking || 0; t.total += c.usage.total; + t.costUsd += c.costUsd || 0; t.chargeKrw += c.chargeKrw || 0; t.commands += 1; + } + site.totals = t; + return site; +} + +// v1.6.31 (요구 10): 원자적 쓰기(임시 파일 + fsync + rename) + 직전 정상본 .bak 보존. 읽기 실패는 손상으로 기록해 관리자 운영 복구 화면에 보인다(빈 값으로 숨기지 않음) +const js = require('./jsonstore'); +function save(site) { + recomputeTotals(site); + site.updatedAt = new Date().toISOString(); + js.writeJsonAtomic(fileOf(site.id), JSON.stringify(site, null, 2), { mode: 0o600, keepBackup: true }); + return site; +} +/** 사이트 기록 읽기. 없으면 null. 깨졌으면 .bak 으로 복구를 시도하고 손상 목록에 남긴다(복구도 안 되면 null — 화면에는 "손상" 으로 보인다) */ +function load(id) { + if (!ID_RE.test(id)) return null; + return js.readJson(fileOf(id), null, {}); +} +/** 손상된 사이트 파일 목록 (운영 복구 화면) */ +function health() { return js.health().filter((h) => h.file.startsWith(DATA_DIR)); } + +function list(ownerId) { + return fs.readdirSync(DATA_DIR) + .filter((f) => f.endsWith('.json')) + .map((f) => load(f.slice(0, -5))) + .filter(Boolean) + .filter((s) => !ownerId || s.ownerId === ownerId) + .sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')) + // v1.2.0: 상단 사이트 선택 상자가 "생성 중 / 완성 / 가져옴·복사" 로 묶어 보여 주도록 mode·plan 단계도 함께 준다 + .map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, status: s.status, totals: s.totals, commands: s.commands.length, mode: s.mode || null, stage: (s.plan && s.plan.stage) || null })); +} + +function create(fields) { + const site = { + id: fields.id, + createdAt: new Date().toISOString(), + ownerId: fields.ownerId || null, ownerName: fields.ownerName || '', + title: fields.title, purpose: fields.purpose, content: fields.content, + design: fields.design, extra: fields.extra, + git: { seq: 0, changes: [] }, + sessionId: fields.sessionId, + status: 'idle', + commands: [], + totals: emptyTotals(), + }; + return save(site); +} + +module.exports = { health, ID_RE, create, save, load, list, DATA_DIR }; diff --git a/modules/webmaker/lib/templates.js b/modules/webmaker/lib/templates.js new file mode 100644 index 0000000..19001f4 --- /dev/null +++ b/modules/webmaker/lib/templates.js @@ -0,0 +1,159 @@ +'use strict'; +// ============================================================================ +// 사이트 템플릿 (v1.3.10) +// +// 관리자가 빌더로 만든 사이트를 "템플릿" 으로 등록하고, 사용자에게 사용을 허용한 템플릿으로 새 사이트를 시작하게 한다. +// - 등록 = 그 순간의 사이트 파일을 data/templates/<템플릿ID>/files/ 로 복사(스냅숏). 이후 원본 사이트를 고쳐도 템플릿은 +// 그대로이며, "다시 담기" 를 눌러야 바뀐다. 사이트 고유 값(통계 스크립트, 검색·AI 최적화 관리 구역, 웹메일 진입 파일)은 뺀다. +// - 디렉터리: 기본은 업종 구조(brief.js INDUSTRIES 의 대분류/세부 업종, 'ind:대분류' · 'ind:대분류/세부'), 필요하면 +// 관리자가 별도 디렉터리를 만든다('dir:', 상위 디렉터리 지정 가능). +// - 해시태그: tags 배열(# 없이 소문자로 저장). 검색어의 #태그는 모두 포함한 템플릿만, 나머지 낱말은 제목·설명·태그에서 찾는다. +// - 사용자 사용 허용(enabled)은 관리자가 정한다. 사용자 목록·미리보기·사용은 enabled 인 것만. +// - 사용 = 스냅숏을 새 사이트 폴더로 복사하고, 파일 안의 원본 사이트 ID(모듈 위젯 data-site 등)를 새 사이트 ID 로 바꾼다. +// +// 저장: data/templates/index.json { dirs: [{id, name, parent, createdAt}], items: [{id, title, description, tags, dir, sourceSiteId, +// enabled, files, bytes, createdAt, updatedAt, snapshotAt, by}] } +// ============================================================================ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data'); +const ROOT = path.join(DATA_ROOT, 'templates'); +const INDEX = path.join(ROOT, 'index.json'); +const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s }); +const clip = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n); +const TEXT_EXT = /\.(html?|css|js|mjs|json|txt|xml|svg|md)$/i; +const SKIP = new Set(['.git', 'node_modules', 'webmail', 'uploads/.tmp']); + +function load() { try { const d = JSON.parse(fs.readFileSync(INDEX, 'utf8')); d.dirs = d.dirs || []; d.items = d.items || []; return d; } catch (_) { return { dirs: [], items: [] }; } } +function save(d) { fs.mkdirSync(ROOT, { recursive: true }); const tmp = INDEX + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d, null, 1)); fs.renameSync(tmp, INDEX); } +const filesDir = (id) => path.join(ROOT, id, 'files'); + +/** 해시태그 정리: "#카페, 미니멀 #강남" → ['카페','미니멀','강남'] */ +function normTags(v) { const arr = Array.isArray(v) ? v : String(v || '').split(/[,\s]+/); return [...new Set(arr.map((t) => String(t).replace(/^#+/, '').trim().toLowerCase()).filter((t) => t && t.length <= 30))].slice(0, 20); } + +// ---------- 디렉터리 ---------- +/** + * 디렉터리 트리: 업종(대분류 → 세부 업종) + 관리자가 만든 디렉터리. 각 노드에 템플릿 수(count, 하위 포함)를 붙인다. + * @param {Array} industries brief.INDUSTRIES ({major, minors}) + * @param {boolean} enabledOnly 사용자 화면이면 허용된 템플릿만 센다 + */ +function tree(industries, enabledOnly) { + const d = load(); const items = d.items.filter((t) => !enabledOnly || t.enabled); + const cnt = (pred) => items.filter(pred).length; + const ind = industries.map((i) => ({ id: `ind:${i.major}`, name: i.major, kind: 'industry', count: cnt((t) => t.dir === `ind:${i.major}` || t.dir.startsWith(`ind:${i.major}/`)), children: (i.minors || []).map((m) => ({ id: `ind:${i.major}/${m}`, name: m, kind: 'industry', count: cnt((t) => t.dir === `ind:${i.major}/${m}`) })) })); + const custom = (parent) => d.dirs.filter((x) => (x.parent || null) === parent).map((x) => { const kids = custom(x.id); const ids = new Set([x.id, ...flat(kids).map((k) => k.id.slice(4))]); return { id: `dir:${x.id}`, name: x.name, kind: 'custom', count: cnt((t) => t.dir.startsWith('dir:') && ids.has(t.dir.slice(4))), children: kids }; }); + const flat = (nodes) => nodes.flatMap((n) => [n, ...flat(n.children || [])]); + return { industries: ind, custom: custom(null), total: items.length }; +} +/** 디렉터리 ID 확인 (업종 경로가 실제 목록에 있는지, 별도 디렉터리가 있는지) */ +function validDir(dir, industries) { + const v = String(dir || ''); if (!v) return 'dir:' ; + if (v.startsWith('ind:')) { const [maj, min] = v.slice(4).split('/'); const i = industries.find((x) => x.major === maj); if (i && (!min || (i.minors || []).includes(min))) return v; throw httpError('업종 디렉터리를 찾을 수 없습니다'); } + if (v.startsWith('dir:')) { if (load().dirs.some((x) => x.id === v.slice(4))) return v; throw httpError('디렉터리를 찾을 수 없습니다'); } + throw httpError('디렉터리를 고르세요'); +} +function addDir(name, parent) { + const d = load(); const n = clip(name, 40); if (!n) throw httpError('디렉터리 이름을 입력하세요'); + const p = parent ? String(parent).replace(/^dir:/, '') : null; if (p && !d.dirs.some((x) => x.id === p)) throw httpError('상위 디렉터리가 없습니다'); + if (d.dirs.some((x) => x.name === n && (x.parent || null) === p)) throw httpError('같은 이름의 디렉터리가 이미 있습니다', 409); + const x = { id: crypto.randomBytes(5).toString('hex'), name: n, parent: p, createdAt: new Date().toISOString() }; d.dirs.push(x); save(d); return x; +} +function renameDir(id, name) { const d = load(); const x = d.dirs.find((y) => y.id === String(id).replace(/^dir:/, '')); if (!x) throw httpError('디렉터리가 없습니다', 404); const n = clip(name, 40); if (!n) throw httpError('디렉터리 이름을 입력하세요'); x.name = n; save(d); return x; } +/** 비어 있는(템플릿·하위 디렉터리가 없는) 디렉터리만 지운다 */ +function removeDir(id) { const d = load(); const k = String(id).replace(/^dir:/, ''); if (!d.dirs.some((x) => x.id === k)) throw httpError('디렉터리가 없습니다', 404); if (d.dirs.some((x) => x.parent === k)) throw httpError('하위 디렉터리가 있어 지울 수 없습니다', 409); if (d.items.some((t) => t.dir === `dir:${k}`)) throw httpError('템플릿이 들어 있어 지울 수 없습니다. 템플릿을 다른 디렉터리로 옮기세요', 409); d.dirs = d.dirs.filter((x) => x.id !== k); save(d); return true; } + +// ---------- 스냅숏 복사 ---------- +/** + * 사이트 폴더 → 템플릿 폴더. 사이트 고유 값은 뺀다: + * - 접속 통계 스크립트( diff --git a/modules/webmaker/modules/board/widget.html b/modules/webmaker/modules/board/widget.html new file mode 100644 index 0000000..a9b14ad --- /dev/null +++ b/modules/webmaker/modules/board/widget.html @@ -0,0 +1,30 @@ + +
    +

    +
    +
    +
    + + +

    {{t:board.write}}

    + + + +
    + +
    +

    + +
    + diff --git a/modules/webmaker/modules/booking/widget.html b/modules/webmaker/modules/booking/widget.html new file mode 100644 index 0000000..92e83c9 --- /dev/null +++ b/modules/webmaker/modules/booking/widget.html @@ -0,0 +1,21 @@ + +
    +

    +
    + +
    + +
    + + +

    +
    +
    + diff --git a/modules/webmaker/modules/calendar/widget.html b/modules/webmaker/modules/calendar/widget.html new file mode 100644 index 0000000..ac202c4 --- /dev/null +++ b/modules/webmaker/modules/calendar/widget.html @@ -0,0 +1,18 @@ + +
    +

    +
    +
    +
    + diff --git a/modules/webmaker/modules/faq/widget.html b/modules/webmaker/modules/faq/widget.html new file mode 100644 index 0000000..20f9e10 --- /dev/null +++ b/modules/webmaker/modules/faq/widget.html @@ -0,0 +1,14 @@ + +
    +

    +
    +
    +
    + diff --git a/modules/webmaker/modules/gallery/widget.html b/modules/webmaker/modules/gallery/widget.html new file mode 100644 index 0000000..337bd48 --- /dev/null +++ b/modules/webmaker/modules/gallery/widget.html @@ -0,0 +1,15 @@ + + + diff --git a/modules/webmaker/modules/inquiry/widget.html b/modules/webmaker/modules/inquiry/widget.html new file mode 100644 index 0000000..9e26190 --- /dev/null +++ b/modules/webmaker/modules/inquiry/widget.html @@ -0,0 +1,31 @@ + +
    +

    +
    +
    + + +
    +
    + + +
    + + + + + + +

    +
    +
    + diff --git a/modules/webmaker/modules/library/widget.html b/modules/webmaker/modules/library/widget.html new file mode 100644 index 0000000..25ee447 --- /dev/null +++ b/modules/webmaker/modules/library/widget.html @@ -0,0 +1,17 @@ + +
    +

    + +
    +
    +

    +
    + diff --git a/modules/webmaker/modules/map/widget.html b/modules/webmaker/modules/map/widget.html new file mode 100644 index 0000000..bdaabf1 --- /dev/null +++ b/modules/webmaker/modules/map/widget.html @@ -0,0 +1,13 @@ + +
    +
    +

    +
    + diff --git a/modules/webmaker/modules/members/widget.html b/modules/webmaker/modules/members/widget.html new file mode 100644 index 0000000..8aa3552 --- /dev/null +++ b/modules/webmaker/modules/members/widget.html @@ -0,0 +1,30 @@ + +
    +
    + + +
    + diff --git a/modules/webmaker/modules/reviews/widget.html b/modules/webmaker/modules/reviews/widget.html new file mode 100644 index 0000000..046b39b --- /dev/null +++ b/modules/webmaker/modules/reviews/widget.html @@ -0,0 +1,21 @@ + +
    +

    +
    +
    +

    {{t:reviews.write}}

    +
    + + +

    +
    +
    + diff --git a/modules/webmaker/modules/survey/widget.html b/modules/webmaker/modules/survey/widget.html new file mode 100644 index 0000000..90d6b47 --- /dev/null +++ b/modules/webmaker/modules/survey/widget.html @@ -0,0 +1,16 @@ + +
    +

    +

    +

    +
    + diff --git a/modules/webmaker/package.json b/modules/webmaker/package.json new file mode 100644 index 0000000..6893be9 --- /dev/null +++ b/modules/webmaker/package.json @@ -0,0 +1,15 @@ +{ + "name": "webhosting-ai-agent", + "version": "1.7.3", + "private": true, + "description": "AI 기반 자동 웹페이지 생성·호스팅 에이전트 (웹메이커)", + "main": "server.js", + "scripts": { + "start": "node server.js", + "i18n": "node scripts/i18n-extract.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/modules/webmaker/public/404.html b/modules/webmaker/public/404.html new file mode 100644 index 0000000..b92167c --- /dev/null +++ b/modules/webmaker/public/404.html @@ -0,0 +1 @@ +페이지를 찾을 수 없습니다

    페이지를 찾을 수 없습니다

    요청하신 페이지가 존재하지 않거나 이동되었습니다.

    diff --git a/modules/webmaker/public/admin.css b/modules/webmaker/public/admin.css new file mode 100644 index 0000000..3211e18 --- /dev/null +++ b/modules/webmaker/public/admin.css @@ -0,0 +1,122 @@ +/* 관리자 콘솔: 일반 사이트(밝은 배경·파란 포인트)와 대비되는 다크 테마(짙은 남색 배경·호박색 포인트) */ +:root { --bg: #0b1220; --panel: #121a2b; --panel2: #18223a; --line: #263353; --text: #e6ebf5; --muted: #8b98b8; --accent: #f5a524; --accent2: #ffc457; --ok: #34d399; --warn: #fb923c; --bad: #f87171; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +* { box-sizing: border-box; } +body { margin: 0; padding: 16px; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; font-size: 13px; color: var(--text); background: var(--bg); } +.adm { max-width: 1480px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; } +.adm-hdr { display: flex; align-items: center; gap: 14px; padding: 10px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; } +.brand { display: flex; align-items: center; gap: 10px; } .brand h1 { margin: 0; font-size: 17px; letter-spacing: .3px; } .logo { width: 30px; height: 30px; border-radius: 8px; background: var(--accent); color: #0b1220; display: grid; place-items: center; font-weight: 700; } +.spacer { flex: 1; } .muted { color: var(--muted); font-size: 12px; } +.tabs { display: flex; gap: 4px; } .tabs button { padding: 7px 14px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--muted); font: inherit; cursor: pointer; } .tabs button.active { background: var(--panel2); color: var(--accent2); border-color: var(--line); } .tabs button:hover { color: var(--text); } +button, .btn { font: inherit; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); cursor: pointer; text-decoration: none; display: inline-block; } +button:hover, .btn:hover { border-color: var(--accent); } button:disabled { opacity: .45; cursor: not-allowed; } +.primary { background: var(--accent); border-color: var(--accent); color: #0b1220; font-weight: 700; } .primary:hover { background: var(--accent2); } +.ghost.danger { color: var(--bad); border-color: #7f1d1d; } .ghost.warn { color: var(--warn); } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; } +h2 { font-size: 14px; margin: 0 0 10px; color: var(--accent2); } h3 { font-size: 13px; margin: 12px 0 6px; } +.hidden { display: none !important; } +.kpi { display: flex; gap: 8px; flex-wrap: wrap; } .kpi .tot { min-width: 140px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; display: flex; flex-direction: column; } .kpi .k { font-size: 11px; color: var(--muted); } .kpi .v { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; } +.table-wrap { overflow-x: auto; } table.adm-t { width: 100%; border-collapse: collapse; font-size: 12px; } table.adm-t th, table.adm-t td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; vertical-align: top; } table.adm-t th { color: var(--muted); font-weight: 600; } +table.adm-t td.wrap { white-space: normal; max-width: 560px; } table.adm-t td.num { text-align: right; font-variant-numeric: tabular-nums; } +tr.clickable { cursor: pointer; } tr.clickable:hover td { background: var(--panel2); } +.st-error, .st-interrupted { color: var(--bad); font-weight: 600; } .st-cancelled { color: var(--warn); } .st-running { color: var(--accent2); } .st-done { color: var(--ok); } .st-idle { color: var(--muted); } +.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } @media (max-width: 1000px) { .grid { grid-template-columns: 1fr; } } +.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; } +.field-label { font-size: 12px; color: var(--muted); margin: 10px 0 4px; } +.log { margin: 0; background: #060b16; color: #cbd5e1; border: 1px solid var(--line); border-radius: 8px; padding: 10px; height: 480px; overflow: auto; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; } +.log .info { color: #93c5fd; } .log .tool { color: #a7f3d0; } .log .assistant { color: #fde68a; } .log .warn { color: #fca5a5; } .log .debug { color: #f9a8d4; } .log .t { color: #64748b; } +.json { max-height: 260px; overflow: auto; background: #060b16; color: #cbd5e1; border: 1px solid var(--line); border-radius: 8px; padding: 8px; font-family: var(--mono); font-size: 11px; white-space: pre-wrap; } +input, select { font: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: #0f172a; color: var(--text); width: 100%; margin: 4px 0 8px; } input:focus, select:focus { outline: 2px solid #3b4a75; border-color: var(--accent); } +.radio { display: block; margin: 8px 0 4px; } .radio input { width: auto; margin: 0 6px 0 0; } +.inline { display: flex; align-items: center; gap: 8px; margin: 4px 0 8px 22px; } .inline input { margin: 0; } +code { font-family: var(--mono); background: #0f172a; padding: 1px 5px; border-radius: 4px; } +.fx-line { margin: 8px 0; font-size: 12px; color: var(--muted); } +.charge-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; margin-top: 8px; } .charge-form input { margin: 0; } +.pos { color: var(--ok); } .neg { color: var(--bad); font-weight: 600; } + +.two { display: grid; grid-template-columns: 2fr 1fr; gap: 8px; } +textarea { font: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: #0f172a; color: var(--text); width: 100%; margin: 4px 0 8px; font-family: var(--mono); font-size: 11px; } +.deploy-box { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; margin-bottom: 12px; background: var(--panel2); } +.star { color: var(--accent); } + +.deploy-result { border-radius: 10px; padding: 10px 12px; margin: 6px 0 10px; font-size: 12px; border: 1px solid var(--line); background: #0f172a; } +.deploy-result.ok { border-color: #065f46; background: #052e2b; } .deploy-result.fail { border-color: #7f1d1d; background: #2a1215; } .deploy-result.busy { border-color: var(--accent); } +.deploy-result h4 { margin: 0 0 6px; font-size: 13px; } +.deploy-result ul { margin: 6px 0 0; padding-left: 18px; } .deploy-result li { margin: 3px 0; } +.deploy-result a { color: var(--accent2); } + +.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; white-space: nowrap; } +.badge.deploying { background: #dbeafe; color: #1e40af; } +.deploy-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0; padding: 8px 12px; border-radius: 8px; font-size: 13px; } +.deploy-status.running { background: #1e3a8a; color: #dbeafe; } .deploy-status.ok { background: #064e3b; color: #d1fae5; } .deploy-status.fail { background: #7f1d1d; color: #fee2e2; } +.deploy-status a { color: inherit; text-decoration: underline; } +.deploy-status.running .ds-icon { width: 14px; height: 14px; border-radius: 50%; border: 2px solid #93c5fd; border-top-color: #fff; animation: spin .9s linear infinite; display: inline-block; } +@keyframes spin { to { transform: rotate(360deg); } } +.badge.pending { background: #fef3c7; color: #92400e; } .badge.deployed { background: #d1fae5; color: #065f46; } .badge.working { background: #e0e7ff; color: #3730a3; } .badge.empty { background: #1f2937; color: var(--muted); } +h2 .tabs button { padding: 4px 10px; font-size: 12px; } + +/* 표가 화면 밖으로 나가지 않도록: flex/grid 자식의 최소 너비 해제, 표는 컨테이너 안에서 가로 스크롤 */ +.adm > * , .card, .grid > * { min-width: 0; max-width: 100%; } +.table-wrap { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; } +table.adm-t td:not(.num):not(.wrap) { white-space: normal; } +table.adm-t td.act { white-space: nowrap; } table.adm-t td.act button, table.adm-t td.act .btn { padding: 4px 8px; font-size: 11px; margin: 1px 0; } + +.dlg { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: grid; place-items: center; z-index: 50; } +.dlg-box { width: 520px; max-width: calc(100vw - 32px); background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; } + +.ledger-scroll { max-height: 360px; overflow-y: auto; } .ledger-scroll thead th { position: sticky; top: 0; background: var(--panel, #111827); } +.badge.planned { background: #1e3a8a; color: #dbeafe; } +.badge-n { display: inline-block; min-width: 16px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font-size: 10px; margin-left: 4px; } + +/* v1.2.0: Webmaker 화면 외형 편집기 */ +.th-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px 10px; margin: 8px 0; } +.th-grid label { font-size: 12px; color: var(--muted); } +.th-preview { display: grid; gap: 0; height: 150px; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin: 6px 0 10px; background: var(--tp-bg); font-size: var(--tp-fs); color: var(--tp-text); } +.th-preview .tp-left { background: var(--tp-panel); display: flex; flex-direction: column; gap: var(--tp-pad); border-right: 1px solid var(--tp-line); } +.th-preview .tp-top { background: var(--tp-top); color: var(--tp-top-text); padding: 6px 8px; font-weight: 700; display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 12px; white-space: nowrap; } +.th-preview .tp-pill { white-space: nowrap; font-weight: 600; font-size: 11px; border: 1px solid var(--tp-accent); border-radius: 999px; padding: 1px 6px; } +.th-preview .tp-card { margin: 0 8px; padding: var(--tp-pad); border: 1px solid var(--tp-line); border-radius: var(--tp-radius); font-size: 12px; } +.th-preview .tp-muted { color: var(--tp-muted); } +.th-preview .tp-btn { margin: 0 8px; background: var(--tp-accent); border-color: var(--tp-accent); color: #fff; border-radius: var(--tp-radius); padding: 4px 10px; align-self: flex-start; font-size: 12px; } +.th-preview .tp-right { display: grid; place-items: center; color: var(--tp-muted); font-size: 12px; } + +/* v1.3.2 로그 보기 */ +.lg-filters { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; } +.lg-kinds { display: flex; flex-wrap: wrap; gap: 6px; } .lg-kind { padding: 5px 10px; border-radius: 999px; font-size: 12px; opacity: .55; } .lg-kind.on { opacity: 1; border-color: var(--accent); } +.lg-kind.error.on { color: #fca5a5; } .lg-kind.warn.on { color: #fcd34d; } .lg-kind.deploy.on, .lg-kind.done.on { color: #6ee7b7; } +.st-warn { color: #fcd34d; } +.lg-wrap { max-height: 64vh; overflow: auto; } +/* v1.3.2 메일 시스템 탭 */ +.ms-status { padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; font-size: 13px; line-height: 1.6; } .ms-status b { color: var(--accent2); } +.ms-pill { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; border: 1px solid var(--line); } .ms-pill.ready, .ms-pill.send-only { color: #6ee7b7; border-color: #065f46; } .ms-pill.setting-up { color: #fcd34d; border-color: #92400e; } .ms-pill.off { color: var(--muted); } +/* v1.3.2 화면 설정 탭: 왼쪽 조정, 오른쪽 넓은 미리보기 */ +.th-layout { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 16px; align-items: start; } +.th-stage { min-width: 0; } /* 1280px iframe 가 칸을 넓히지 않게 (그래야 축소 비율이 맞음) */ +@media (max-width: 1100px) { .th-layout { grid-template-columns: 1fr; } } +.th-controls { position: sticky; top: 8px; } +.th-views { gap: 4px; margin-bottom: 8px; } .th-views button.active { border-color: var(--accent); color: var(--accent2); } +.th-frames.one .th-frame-wrap { width: 100%; } .th-frames.all { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.th-frame-wrap .th-vp { position: relative; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: #fff; width: 100%; } +.th-frame-wrap[data-view="mobile"] .th-vp { max-width: 390px; } +.th-frame-wrap iframe { border: 0; transform-origin: 0 0; display: block; } + +/* v1.3.2: 탭이 9개로 늘어 한 줄에 다 안 들어가면 단어가 잘리지 않게 줄 단위로 넘김 */ +.adm-hdr { flex-wrap: wrap; } .tabs { flex-wrap: wrap; } .tabs button { white-space: nowrap; padding: 7px 10px; } .brand { white-space: nowrap; } + +/* v1.3.5 버전 변경 알림 띠 */ +.ver-bar { margin: 10px 0; padding: 8px 12px; border: 1px solid #92400e; border-radius: 10px; color: #fcd34d; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +/* v1.3.10: 관리자 콘솔 다국어 — 번역문이 길어도 칸 안에서 줄바꿈 */ +.adm, .card, .kpi .tot { min-width: 0; max-width: 100%; } .tabs button { white-space: normal; } td, th, label, p { overflow-wrap: anywhere; } .table-wrap { max-width: 100%; overflow-x: auto; } + +/* v1.3.10 템플릿 트리 */ +.tp-tree { border: 1px solid var(--line); border-radius: 8px; padding: 4px; max-height: 360px; overflow: auto; } .tp-node { display: flex; align-items: center; gap: 4px; } .tp-node.on { background: var(--panel2); border-radius: 6px; } .tp-node .link { background: transparent; border: 0; color: var(--text); text-align: left; padding: 3px 4px; flex: 1; } .tp-node .ghost { padding: 1px 6px; font-size: 11px; } +/* v1.3.16 저장하지 않은 변경 표시 (data-dirty-watch 입력칸) */ +input.dirty, select.dirty { outline: 2px solid #f59e0b; outline-offset: 1px; } +input[type=radio].dirty { outline-offset: 2px; } +.muted.ok { color: #059669; } + +/* v1.4.0 회원 목록의 계정 종류 (선택 + 변경 버튼 한 줄) */ +.acct-type { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; vertical-align: middle; } +.acct-type select { width: auto; margin: 0; padding: 3px 6px; font-size: 12px; } +/* v1.6.40 선불 충전 상품 폼: 칸이 많아 3열 → 자동 채움 */ +.charge-form.pp-form { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); align-items: center; } .charge-form.pp-form select { margin: 0; } .charge-form.pp-form button { white-space: nowrap; } diff --git a/modules/webmaker/public/admin.html b/modules/webmaker/public/admin.html new file mode 100644 index 0000000..002cb11 --- /dev/null +++ b/modules/webmaker/public/admin.html @@ -0,0 +1,572 @@ + + + + + +관리자 콘솔 — AI Webmaker + + + +
    +
    +

    관리자 콘솔

    + +
    + + + +
    + + +
    + +
    +

    모든 사이트 + + +

    + +
    배포 상태마지막 변경사이트소유자도메인배포 위치방식단계작업 상태명령오류토큰비용(USD)작업
    +

    배포 위치: 사이트마다 배포할 서버를 지정합니다(★ 는 기본 배포 서버). 사용자 화면에는 서버 이름이 보이지 않으며, 사용자의 "최종 확정(발행)"과 재배포는 여기서 지정한 서버로 갑니다.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + diff --git a/modules/webmaker/public/admin.js b/modules/webmaker/public/admin.js new file mode 100644 index 0000000..78978db --- /dev/null +++ b/modules/webmaker/public/admin.js @@ -0,0 +1,1245 @@ +'use strict'; +const $ = (id) => document.getElementById(id); +const fmt = (n) => Math.round(n || 0).toLocaleString(uiLocale()); +const usd = (n) => '$' + (n || 0).toFixed(3); +let cashUnit = '원'; // v1.4.1 캐시 표시 단위 (설정을 불러오면 바뀜) +const krw = (n) => { const v = Math.round(n || 0).toLocaleString(uiLocale()); return cashUnit === '원' ? TF('{1}원', v) : `${v}${/^[가-힣]+$/.test(cashUnit) ? '' : ' '}${cashUnit}`; }; // v1.3.10 통화 단위 번역, v1.4.1 관리자 지정 단위 +const dt = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''); +const KIND = { create: '생성', update: '수정', 'plan-analyze': '이해 확인', 'plan-structure': '구조 제안', 'plan-design': '디자인 3안', 'plan-verify': '검증', 'site-analyze': '사이트 분석', 'seo-draft': '검색·AI 최적화 초안' }; +const MODE = { ai: 'AI에게 맡기기', manual: '내가 고르기', quick: '기본', import: '사이트 관리(가져옴)', copy: '사이트 복사' }; +const TIER = { basic: '기본', standard: '중급', premium: '고급' }; +const STATUS = { running: '실행 중', deploying: '배포 중', 'waiting-credit': '캐시 대기', done: '완료', error: '오류', cancelled: '중단', interrupted: '끊김', idle: '대기' }; +const VER = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원' }; +let overview = null, es = null, me = null, curSite = null, billingData = null, curBillUser = null, settingsData = null, siteFilter = 'all'; +const DS = { pending: '배포 대기중', deployed: '발행 완료', working: '작업 중', deploying: '배포 중', empty: '완성 전', transferred: '전송 완료', 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중(HTTP)', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패', failed: '배포 실패' }; // v1.6.31 (요구 14) 단계 구분 +const CHG = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원', deploy: '배포본' }; + +/** + * 관리자 API 호출. + * v1.3.0 수정: 1.1.x~1.2.0 은 본문이 FormData(파일 업로드)여도 Content-Type 을 application/json 으로 고정해 + * 브라우저가 multipart 경계(boundary)를 붙이지 못했고, 서버가 파일을 JSON 으로 읽으려다 "JSON 파싱 실패" 를 냈다 + * (언어 파일·ZIP 업로드가 모두 실패한 원인). FormData 일 때는 헤더를 비워 브라우저가 multipart 헤더를 만들게 한다. + */ +/** + * v1.6.31 (요구 4): 서비스 API 는 세션 쿠키만으로는 쓸 수 없고, 출처가 이 화면(Referer)이거나 X-WM-CSRF 헤더가 있어야 한다. + * 토큰은 /api/auth/csrf 가 "출처가 빌더·관리자 화면인 요청" 에만 준다 — 같은 주소에서 열린 생성 사이트 페이지는 받을 수 없다. + */ +let _csrf = null; let _csrfP = null; +async function csrf() { if (_csrf) return _csrf; if (!_csrfP) _csrfP = fetch('/api/auth/csrf', { headers: { 'X-UI-Lang': (window.I18N && window.I18N.lang) || 'ko' } }).then((r) => r.ok ? r.json() : {}).then((b) => { _csrf = (b && b.csrf) || ''; return _csrf; }).catch(() => ''); return _csrfP; } +async function api(path, opt = {}) { + const headers = opt.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; + if (!/^\/api\/auth\/csrf/.test(path)) { const tok = await csrf(); if (tok) headers['X-WM-CSRF'] = tok; } + const r = await fetch(path, { headers, ...opt }); + const b = await r.json().catch(() => ({})); + if (r.status === 401) { location.href = '/login'; throw new Error('로그인이 필요합니다'); } + if (r.status === 403) { document.body.innerHTML = '

    관리자만 접근할 수 있습니다.

    '; throw new Error('403'); } + if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`); + return b; +} +function td(tr, text, cls) { const c = document.createElement('td'); if (text instanceof Node) c.appendChild(text); else c.textContent = text == null ? '' : text; if (cls) c.className = cls; tr.appendChild(c); return c; } +function mkBtn(label, cls, fn) { const b = document.createElement('button'); b.type = 'button'; b.className = cls || 'ghost'; b.textContent = label; b.onclick = (e) => { e.stopPropagation(); fn(); }; return b; } +function showTab(name) { document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('hidden', t.id !== 'tab-' + name)); document.querySelectorAll('.tabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === name)); if (es && name !== 'detail') { es.close(); es = null; } } +/** + * 탭별 데이터 불러오기 (v1.3.2). 탭을 열 때와 "새로고침" 때 같은 함수를 쓴다. + * sites 는 loadOverview 가 그리므로 별도 함수가 없다. detail 은 열려 있는 사이트 상세를 다시 연다. + */ +const TAB_LOADERS = { templates: () => loadTemplates(), search: () => loadSearchRegs(), feedback: () => loadFeedback(), i18n: () => loadI18n(), errors: () => loadErrors(), billing: () => loadBilling(), settings: () => loadSettings(), recovery: () => loadRecovery(), audit: () => loadAudit() /* v1.6.31 */, ai: () => loadSettings() /* v1.5.16 AI 연결 탭: 인증·사용할 Claude 모델·제공자 — 설정과 같은 응답을 쓴다 */, deploy: () => loadTargets(), mail: () => loadMailSys(), theme: () => loadTheme(), detail: () => (curSite ? openSite(curSite.id || curSite) : null) }; +let activeAdminTab = 'sites'; +/** 탭 열기: 화면 전환 + 데이터 불러오기 + 주소의 #탭 기록(브라우저 새로고침에도 같은 탭 유지) */ +function openTab(name) { activeAdminTab = name; showTab(name); try { history.replaceState(null, '', name === 'sites' ? location.pathname : '#' + name); } catch (_) {} const f = TAB_LOADERS[name]; if (f) Promise.resolve(f()).catch(() => {}); } +document.querySelectorAll('.tabs button').forEach((b) => (b.onclick = () => openTab(b.dataset.tab))); +/** + * 새로고침 (v1.3.2 수정): 1.3.1 까지는 사이트 목록(개요)만 다시 불러와, 다른 탭을 보고 있으면 아무 변화가 없어 + * "작동하지 않는" 것처럼 보였다. 이제 다른 화면으로 이동하지 않고 개요(위쪽 숫자·배지)와 지금 보고 있는 탭의 + * 데이터를 모두 다시 불러오며, 끝나면 갱신 시각을 표시한다. 스크롤 위치는 그대로 둔다. + */ +$('btnReload').onclick = async () => { + const b = $('btnReload'); const y = window.scrollY; b.disabled = true; b.textContent = '갱신 중…'; + try { await Promise.all([loadOverview(), Promise.resolve(TAB_LOADERS[activeAdminTab] ? TAB_LOADERS[activeAdminTab]() : null)]); $('reloadInfo').textContent = `갱신 ${new Date().toLocaleTimeString(uiLocale())}`; } + catch (e) { $('reloadInfo').textContent = '갱신 실패: ' + e.message; } + finally { b.disabled = false; b.textContent = '새로고침'; window.scrollTo(0, y); } +}; +$('btnLogout').onclick = async () => { await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login'; }; +$('dBack').onclick = () => openTab('sites'); + +// ---------- 사이트 관리 ---------- +async function loadOverview() { + // v1.5.27 (요구 6): 가입 승인 대기 수를 회원 · 청구 탭 배지에 + overview = await api('/api/admin/overview'); + { const n = overview.pendingApprovals || 0; const b = $('apBadge'); if (b) { b.textContent = n ? String(n) : ''; b.classList.toggle('hidden', !n); } } + const kpi = $('kpi'); kpi.innerHTML = ''; + const tot = overview.sites.reduce((a, s) => { a.tokens += (s.totals && s.totals.total) || 0; a.cost += (s.totals && s.totals.costUsd) || 0; a.errors += s.errors + s.toolErrors; a.running += s.status === 'running' ? 1 : 0; return a; }, { tokens: 0, cost: 0, errors: 0, running: 0 }); + for (const [k, v] of [['회원', overview.users.length], ['사이트', overview.sites.length], ['실행 중', tot.running], ['총 토큰', fmt(tot.tokens)], ['총 비용', usd(tot.cost)], ['오류 건수', tot.errors], ['버전', overview.version || '-']]) { const d = document.createElement('div'); d.className = 'tot'; d.innerHTML = `${k}${v}`; kpi.appendChild(d); } + if (overview.version) $('ver').textContent = `v${overview.version}`; + if (overview.targets) targets = overview.targets; + { const fb = overview.feedback || {}; const b = $('fbBadge'); b.textContent = fb.new || ''; b.classList.toggle('hidden', !fb.new); const d = document.createElement('div'); d.className = 'tot'; d.innerHTML = `사용자 문의${fb.new || 0} 새 글 / ${fb.total || 0}건`; kpi.appendChild(d); const l = document.createElement('div'); l.className = 'tot'; l.innerHTML = `활성 언어${(overview.languages || 0) + 1}개`; kpi.appendChild(l); } + // v1.3.5: "문의 메일 발송 정상(도메인)" 칸 삭제 — 메일은 사이트 단위 메일 시스템·외부 연동 접속 정보에서 관리하며 여러 도메인을 지원한다 + { const st = overview.stats || {}; const l = st.local || {}; $('statsCollectLine').textContent = l.at ? `접속 로그 수집: ${l.ok ? `${dt(l.at)} 기준 정상 (${l.lines || 0}줄)` : `${dt(l.at)} 실패 — ${l.detail || ''}`}` : '접속 로그 수집: 아직 실행되지 않음'; } + const pendingCount = overview.sites.filter((x) => x.deployState && x.deployState.state === 'pending').length; + { const d = document.createElement('div'); d.className = 'tot'; d.innerHTML = `배포 대기중${pendingCount}`; kpi.insertBefore(d, kpi.children[2]); } + renderSites(); +} +function renderSites() { + const list = overview.sites.filter((x) => siteFilter === 'all' || (x.deployState && x.deployState.state === siteFilter)); + const order = { working: 0, pending: 1, deployed: 2, empty: 3 }; + list.sort((a, b) => (order[(a.deployState || {}).state] ?? 9) - (order[(b.deployState || {}).state] ?? 9) || ((b.deployState || {}).lastChangeAt || '').localeCompare((a.deployState || {}).lastChangeAt || '')); + $('siteCount').textContent = `${list.length}개${siteFilter !== 'all' ? ` (전체 ${overview.sites.length})` : ''}`; + const tb = $('sitesTable').querySelector('tbody'); tb.innerHTML = ''; + for (const s of list) { + const ds = s.deployState || { state: 'empty' }; + const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => openSite(s.id); + const b = document.createElement('span'); b.className = 'badge ' + ds.state; b.textContent = DS[ds.state] || ds.state; b.title = ds.state === 'pending' ? (ds.neverDeployed ? '아직 한 번도 배포되지 않았습니다' : `마지막 배포 ${dt(ds.lastDeployAt)} (${ds.lastDeployTarget || ''}) 이후 변경됨`) : (ds.lastDeployAt ? `마지막 배포 ${dt(ds.lastDeployAt)} → ${ds.lastDeployTarget || ''}` : ''); td(tr, b); + td(tr, ds.lastChangeAt ? `${dt(ds.lastChangeAt)} · ${T(CHG[ds.lastChangeKind] || (ds.neverDeployed ? '신규 생성' : ''))}` : '-'); // v1.3.10 조각 번역 + td(tr, s.title); td(tr, `${s.ownerName || ''} (${s.ownerId || '-'})`); td(tr, (s.domains || []).join(', ') || (s.source ? '원본: ' + s.source : '-')); + { const c = document.createElement('td'); if (s.source) c.textContent = '원본 서버'; else { const sel = document.createElement('select'); sel.style.margin = '0'; sel.style.minWidth = '120px'; sel.title = '이 사이트의 배포 서버'; sel.appendChild(new Option(`기본 서버 (${(targets.targets.find((t) => t.isDefault) || {}).name || '없음'})`, '')); for (const t of targets.targets || []) sel.appendChild(new Option(`${t.name}${t.isDefault ? ' ★' : ''}`, t.id)); sel.value = s.deployTargetId && (targets.targets || []).some((t) => t.id === s.deployTargetId) ? s.deployTargetId : ''; sel.onclick = (e) => e.stopPropagation(); sel.onchange = async (e) => { e.stopPropagation(); try { await api(`/api/admin/sites/${s.id}/target`, { method: 'POST', body: JSON.stringify({ targetId: sel.value || null }) }); s.deployTargetId = sel.value || null; } catch (err) { alert(err.message); } }; c.appendChild(sel); } tr.appendChild(c); } + td(tr, MODE[s.mode] || s.mode); td(tr, s.stage || '-'); td(tr, STATUS[s.status] || s.status, 'st-' + s.status); + td(tr, s.commands, 'num'); td(tr, s.errors + s.toolErrors, (s.errors + s.toolErrors) ? 'st-error num' : 'num'); td(tr, fmt(s.totals && s.totals.total), 'num'); td(tr, usd(s.totals && s.totals.costUsd), 'num'); + const act = document.createElement('td'); act.className = 'act'; const open = document.createElement('a'); open.className = 'btn ghost'; open.href = s.url; open.target = '_blank'; open.rel = 'noopener'; open.textContent = '열기'; open.onclick = (e) => e.stopPropagation(); act.appendChild(open); + if (ds.state === 'pending') act.appendChild(mkBtn(ds.neverDeployed ? '첫 배포' : '재배포', 'primary', () => quickDeploy(s))); + if (s.status === 'running') act.appendChild(mkBtn('중단', 'ghost warn', () => cancelSite(s.id))); + act.appendChild(mkBtn('삭제', 'ghost danger', () => deleteSite(s.id, s.title))); tr.appendChild(act); tb.appendChild(tr); + } + if (!list.length) { const tr = document.createElement('tr'); td(tr, siteFilter === 'pending' ? '배포 대기중인 사이트가 없습니다' : '사이트가 없습니다').colSpan = 14; tb.appendChild(tr); } +} +document.querySelectorAll('[data-filter]').forEach((b) => (b.onclick = (e) => { e.stopPropagation(); siteFilter = b.dataset.filter; document.querySelectorAll('[data-filter]').forEach((x) => x.classList.toggle('active', x === b)); renderSites(); })); +async function quickDeploy(s) { + if (s.source) { if (!confirm(`가져온 기존 사이트입니다. 수정 내용을 원본 서버(${s.source})에 반영할까요?`)) return; return runDeploy(s, { id: 'source', name: '원본 서버 ' + s.source, manageVhost: false, enableSsl: false }, null, null); } + if (!targets.targets.length) { try { targets = await api('/api/admin/deploy-targets'); } catch (_) {} } + if (!targets.targets.length) return alert('배포 서버가 없습니다. 배포 서버 탭에서 먼저 등록하세요.'); + const first = s.deployState && s.deployState.neverDeployed; + if (!first) { + // 재배포: 정보 입력 없이 지정 서버(없으면 기본)로 바로 진행 + // v1.3.17 (B24): 목록의 배포 서버 선택(s.deployTargetId)이 최신 지정 — 예전 배포 기록(s.deploy.targetId)보다 우선 ('' = 기본 서버) + const tid = s.deployTargetId || targets.defaultId; // 서버와 같은 규칙: 사이트 지정 서버 → 없으면 기본 서버 const t = targets.targets.find((x) => x.id === tid) || targets.targets.find((x) => x.isDefault); + return runDeploy(s, t, null, null); + } + // 첫 배포: 정보 입력 대화상자 + const sel = $('ddTarget'); sel.innerHTML = ''; for (const t of targets.targets) sel.appendChild(new Option(`${t.name}${t.isDefault ? ' ★' : ''}${t.type === 'ssh' && !t.hasKey ? ' (키 없음)' : ''}`, t.id)); sel.value = (s.deploy && s.deploy.targetId) || targets.defaultId || targets.targets[0].id; + $('ddTitle').textContent = '첫 배포 정보 입력'; $('ddSite').textContent = `${s.title} (${s.ownerName || ''} ${s.ownerId || ''})`; $('ddDomains').value = (s.domains || []).join(', '); $('ddSsl').checked = true; $('ddMsg').textContent = ''; + $('deployDialog').classList.remove('hidden'); + $('ddCancel').onclick = () => $('deployDialog').classList.add('hidden'); + $('ddGo').onclick = async () => { const t = targets.targets.find((x) => x.id === sel.value); if (!t) return; $('ddGo').disabled = true; $('ddMsg').textContent = '배포 중…'; try { await runDeploy(s, t, $('ddDomains').value, $('ddSsl').checked); $('deployDialog').classList.add('hidden'); } finally { $('ddGo').disabled = false; } }; +} +function deployStatus(kind, text, urls) { + const box = $('deployStatus'); box.classList.remove('hidden'); box.className = 'deploy-status ' + kind; box.innerHTML = ''; + const sp = document.createElement('span'); sp.className = 'ds-icon'; sp.textContent = kind === 'running' ? '' : (kind === 'ok' ? '✓' : '✗'); box.appendChild(sp); + const tx = document.createElement('span'); tx.textContent = text; box.appendChild(tx); + for (const u of urls || []) { const a = document.createElement('a'); a.href = u.url || u; a.target = '_blank'; a.rel = 'noopener'; a.textContent = u.url || u; a.title = u.note || ''; box.appendChild(a); if (u.note) box.appendChild(document.createTextNode(`(${u.ok === false ? '접속 불가: ' : ''}${u.note}) `)); } + if (kind !== 'running') { const x = document.createElement('button'); x.className = 'ghost small-btn'; x.textContent = '닫기'; x.onclick = () => box.classList.add('hidden'); box.appendChild(x); } +} +async function runDeploy(s, t, domainsText, ssl) { + const started = Date.now(); const tick = setInterval(() => deployStatus('running', `배포 진행 중: ${s.title} → ${t.name} · 경과 ${Math.round((Date.now() - started) / 1000)}초`), 1000); + deployStatus('running', `배포 진행 중: ${s.title} → ${t.name}`); + try { + if (domainsText !== null) { const r = await api(`/api/admin/sites/${s.id}/domains`, { method: 'POST', body: JSON.stringify({ domains: domainsText }) }); s.domains = r.domains; } + // v1.3.17 (B25): 첫 배포 대화상자의 SSL 체크는 이 사이트에만 적용한다(배포 요청에 ssl 로 보냄). 예전에는 서버 공용 설정을 꺼서 다른 사이트에도 영향 + if ((s.domains || []).length && (t.manageVhost !== false || t.enableSsl !== false) && t.allowSudo !== true) { if (!confirm(`도메인 연결·SSL 에는 배포 서버 "${t.name}" 에서 sudo 실행이 필요합니다. 허용할까요? (허용하지 않으면 파일만 배포)`)) { /* 파일만 */ } else { await api(`/api/admin/deploy-targets/${t.id}`, { method: 'PUT', body: JSON.stringify({ ...t, allowSudo: true }) }); } } + const r = await api(`/api/admin/sites/${s.id}/deploy`, { method: 'POST', body: JSON.stringify({ targetId: t.id, ...(ssl === true || ssl === false ? { ssl } : {}) }) }); + clearInterval(tick); deployStatus('ok', `${r.redeploy ? '재배포' : '배포'} 완료: ${s.title} → ${t.name} (${Math.round((Date.now() - started) / 1000)}초) — ${r.detail} `, (r.access && r.access.items) || (r.domainUrls || []).map((u) => ({ url: u }))); + } catch (e) { clearInterval(tick); deployStatus('fail', `배포 실패: ${s.title} → ${t.name} — ${e.message}`); $('ddMsg').textContent = '실패: ' + e.message; throw e; } + finally { clearInterval(tick); loadOverview(); } +} +async function cancelSite(id) { try { await api(`/api/admin/sites/${id}/cancel`, { method: 'POST', body: '{}' }); setTimeout(loadOverview, 1500); } catch (e) { alert(e.message); } } +async function deleteSite(id, title) { if (!confirm(`"${title}" 사이트와 파일을 모두 삭제할까요? 되돌릴 수 없습니다.`)) return; try { await api(`/api/admin/sites/${id}`, { method: 'DELETE' }); showTab('sites'); loadOverview(); } catch (e) { alert(e.message); } } +function logTo(pre, l) { const t = new Date(l.ts).toLocaleTimeString(uiLocale(), { hour12: false }); const s1 = document.createElement('span'); s1.className = 't'; s1.textContent = `[${t}] `; const s2 = document.createElement('span'); s2.className = l.level || 'info'; s2.textContent = `${l.level === 'debug' ? '[상세] ' : ''}${l.text}`; pre.appendChild(s1); pre.appendChild(s2); pre.appendChild(document.createTextNode('\n')); pre.scrollTop = pre.scrollHeight; } +async function openSite(id) { + const s = await api(`/api/admin/sites/${id}`); curSite = s; + showTab('detail'); activeAdminTab = 'detail'; $('dTitle').textContent = `${s.title} — ${s.ownerName || ''} (${s.ownerId || '-'}) · ${MODE[s.mode] || s.mode}${s.plan ? ' · ' + s.plan.stage : ''}${s.deployState ? ' · ' + (DS[s.deployState.state] || s.deployState.state) : ''} · 등급 ${TIER[s.tier] || s.tier || '-'} (${s.modelRef || '-'})${s.creditWait ? ' · 캐시 충전 대기 중' : ''}`; $('dOpen').href = s.url; $('dReport').href = `/report/${id}`; + if (s.stats) { const st = s.stats; const useLog = st.sources.log; const v = (a) => (useLog ? a.visitors : a.beaconVisitors); const pv = (a) => (useLog ? a.pv : a.beaconPv); $('dTitle').textContent += ` · 방문자 오늘 ${v(st.today)} / 이달 ${v(st.month)} / 누적 ${v(st.total)} (페이지뷰 ${pv(st.total)})${useLog ? ` · 송신 ${(st.total.bytesOut / 1048576).toFixed(1)}MB` : ''} · 디스크 ${(st.disk.total / 1048576).toFixed(1)}MB`; } + $('dCancel').classList.toggle('hidden', !s.running); $('dCancel').onclick = () => cancelSite(id); $('dDelete').onclick = () => deleteSite(id, s.title); + const tb = $('cmdTable').querySelector('tbody'); tb.innerHTML = ''; const pre = $('dLog'); pre.textContent = ''; + const showCmd = (c) => { pre.textContent = ''; $('dLogHint').textContent = `명령 #${c.no} ${KIND[c.kind] || c.kind} (${(c.log || []).length}줄)`; for (const l of c.log || []) logTo(pre, l); if (c.error) logTo(pre, { ts: Date.parse(c.finishedAt || c.startedAt) || Date.now(), level: 'warn', text: '오류: ' + c.error }); if (c.prompt) logTo(pre, { ts: Date.parse(c.startedAt) || Date.now(), level: 'debug', text: '프롬프트:\n' + c.prompt.slice(0, 3000) }); }; + for (const c of [...s.commands].reverse()) { const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => showCmd(c); td(tr, c.no); td(tr, KIND[c.kind] || c.kind); td(tr, c.model || ''); td(tr, STATUS[c.status] || c.status, 'st-' + c.status); td(tr, dt(c.startedAt)); td(tr, c.usage ? fmt(c.usage.total) + (c.usageEstimated ? '*' : '') : '-', 'num'); td(tr, c.usage && !c.usageEstimated ? usd(c.costUsd) : '-', 'num'); td(tr, c.error || '', 'wrap'); tb.appendChild(tr); } + const vt = $('verTable').querySelector('tbody'); vt.innerHTML = ''; + for (const v of [...(s.versions || [])].reverse()) { const tr = document.createElement('tr'); td(tr, v.no); td(tr, VER[v.kind] || v.kind); td(tr, dt(v.at)); td(tr, v.message, 'wrap'); vt.appendChild(tr); } + $('dPlan').textContent = s.plan ? JSON.stringify({ stage: s.plan.stage, warnings: s.plan.warnings, refsFetched: s.plan.refsFetched, verification: s.plan.verification && s.plan.verification.rules && s.plan.verification.rules.summary, brief: s.plan.brief }, null, 1) : JSON.stringify({ title: s.title, purpose: s.purpose, content: s.content, design: s.design, extra: s.extra }, null, 1); + await renderSiteDeploy(s); + if (s.commands.length) showCmd(s.commands[s.commands.length - 1]); + if (es) es.close(); + es = new EventSource(`/api/admin/sites/${id}/events`); + es.onmessage = (m) => { const ev = JSON.parse(m.data); if (ev.type === 'log') { $('dLogHint').textContent = '실시간'; logTo(pre, ev); } else if (ev.type === 'done') openSite(id); else if (ev.type === 'status') $('dCancel').classList.toggle('hidden', ev.status !== 'running'); }; +} +// ---------- 로그 보기 (v1.3.2) ---------- +/** + * GET /api/admin/logs 로 종류·기간·회사(회원)·사이트·검색어 조건에 맞는 로그를 받는다. + * 종류 버튼은 여러 개를 함께 켤 수 있고(기본: 오류·경고·중단), 버튼의 숫자는 다른 조건만 적용한 건수다. + * 행을 누르면 해당 사이트 상세(명령 이력·상세 로그)로 간다. + */ +const LOG_CLS = { error: 'st-error', warn: 'st-warn', cancelled: 'st-idle', deploy: 'st-done', done: 'st-done', debug: '' }; +let lgTypes = ['error', 'warn', 'cancelled']; let lgTimer = null; +const kstDate = (offsetDays) => new Date(Date.now() + 9 * 3600000 - offsetDays * 86400000).toISOString().slice(0, 10); +async function loadErrors() { + const range = $('lgRange').value; const custom = range === 'custom'; + $('lgFrom').classList.toggle('hidden', !custom); $('lgTo').classList.toggle('hidden', !custom); + const p = new URLSearchParams({ types: lgTypes.join(','), owner: $('lgOwner').value, site: $('lgSite').value, q: $('lgQ').value.trim() }); + if (custom) { if ($('lgFrom').value) p.set('from', $('lgFrom').value); if ($('lgTo').value) p.set('to', $('lgTo').value); } + else if (range !== 'all') p.set('from', kstDate(Number(range) - 1)); + const r = await api('/api/admin/logs?' + p.toString()); + // 종류 버튼 + const kb = $('lgKinds'); kb.innerHTML = ''; + for (const [k, label] of Object.entries(r.types)) { const b = document.createElement('button'); b.type = 'button'; b.className = 'lg-kind ' + k + (lgTypes.includes(k) ? ' on' : ''); b.textContent = `${T(label)} ${r.counts[k] || 0}`; b.onclick = () => { lgTypes = lgTypes.includes(k) ? lgTypes.filter((x) => x !== k) : [...lgTypes, k]; if (!lgTypes.length) lgTypes = [k]; loadErrors(); }; kb.appendChild(b); } + // 회사·사이트 선택 목록 (선택값 유지) + const fill = (sel, items, first, fmt) => { const v = sel.value; sel.innerHTML = ''; sel.appendChild(new Option(first, '')); for (const it of items) sel.appendChild(new Option(fmt(it), it.id)); sel.value = v; }; + fill($('lgOwner'), r.owners, '모든 회사(회원)', (o) => `${o.name || o.id} (${o.id})`); + fill($('lgSite'), r.sites.filter((x) => !$('lgOwner').value || x.ownerId === $('lgOwner').value), '모든 사이트', (x) => x.title || x.id); + $('lgSummary').textContent = `— ${r.total}건${r.total > r.items.length ? ` (최근 ${r.items.length}건 표시)` : ''}`; + const tb = $('errTable').querySelector('tbody'); tb.innerHTML = ''; + for (const e of r.items) { + const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => openSite(e.siteId); + td(tr, dt(e.at)); td(tr, r.types[e.type] || e.type, LOG_CLS[e.type] || ''); td(tr, `${e.ownerName || ''} (${e.ownerId || '-'})`, 'wrap'); td(tr, e.siteTitle || '-', 'wrap'); td(tr, e.no ? `#${e.no} ${KIND[e.kind] || e.kind}` : (e.kind === 'unpublish' ? '발행 취소' : '배포')); td(tr, e.text, 'wrap'); + tb.appendChild(tr); + } + if (!r.items.length) { const tr = document.createElement('tr'); td(tr, '조건에 맞는 로그가 없습니다').colSpan = 6; tb.appendChild(tr); } + $('lgMore').textContent = r.total > r.items.length ? '조건을 좁히면(기간·회사·사이트·검색어) 나머지 기록도 볼 수 있습니다.' : ''; +} +for (const id of ['lgRange', 'lgFrom', 'lgTo', 'lgOwner', 'lgSite']) $(id).onchange = () => { if (id === 'lgOwner') $('lgSite').value = ''; loadErrors(); }; +$('lgQ').oninput = () => { clearTimeout(lgTimer); lgTimer = setTimeout(loadErrors, 300); }; +$('lgReset').onclick = () => { lgTypes = ['error', 'warn', 'cancelled']; $('lgRange').value = '7'; $('lgOwner').value = ''; $('lgSite').value = ''; $('lgQ').value = ''; $('lgFrom').value = ''; $('lgTo').value = ''; loadErrors(); }; + +// ---------- 배포 서버 ---------- +let targets = { defaultId: null, targets: [] }; +async function loadTargets() { + targets = await api('/api/admin/deploy-targets'); const tb = $('targetTable').querySelector('tbody'); tb.innerHTML = ''; + for (const t of targets.targets) { + const tr = document.createElement('tr'); td(tr, t.isDefault ? '★' : '', 'star'); td(tr, t.name); td(tr, t.type === 'local' ? '로컬' : 'SSH'); td(tr, t.type === 'local' ? '(이 서버)' : `${t.user}@${t.host}:${t.port}`); td(tr, t.baseDir); td(tr, t.publicBaseUrl || '-'); + td(tr, t.type === 'local' ? '-' : (t.hasKey ? '있음' : '없음'), t.type !== 'local' && !t.hasKey ? 'st-error' : ''); td(tr, t.allowSudo === true ? '허용' : '미허용', t.allowSudo === true ? 'st-done' : 'st-cancelled'); td(tr, t.enableSsl !== false ? '자동' : '끔'); td(tr, t.lastTest ? `${t.lastTest.ok ? '성공' : '실패'} · ${dt(t.lastTest.at)}` : '-', t.lastTest ? (t.lastTest.ok ? 'st-done' : 'st-error') : ''); + const act = document.createElement('td'); act.className = 'act'; + if (t.type !== 'local' || t.allowSudo === true) act.appendChild(mkBtn('오류 서버 점검', 'ghost', async () => { if (t.allowSudo !== true) return alert('sudo 사용 허용이 필요합니다 (수정에서 켜 주세요)'); try { const r = await api(`/api/admin/deploy-targets/${t.id}/default-server`, { method: 'POST', body: '{}' }); alert((r.ok ? '정상: ' : '주의: ') + r.detail); } catch (e) { alert(e.message); } })); + act.appendChild(mkBtn('연결 테스트', 'ghost', async () => { try { const r = await api(`/api/admin/deploy-targets/${t.id}/test`, { method: 'POST', body: '{}' }); alert((r.ok ? '성공: ' : '실패: ') + r.detail); loadTargets(); } catch (e) { alert(e.message); } })); + // v1.5.13 (요구 3): 생성·조회한 공개키는 별도 창(showKeyDialog)에 한 줄로 보이고 "공개키 복사" 버튼으로 복사한다 + if (t.type === 'ssh') act.appendChild(mkBtn(t.hasKey ? '키 재생성' : '키 생성', 'ghost', async () => { if (t.hasKey && !confirm('기존 키를 새 키로 바꿉니다. 배포 서버에 새 공개키를 다시 등록해야 합니다. 계속할까요?')) return; try { const r = await api(`/api/admin/deploy-targets/${t.id}/generate-key`, { method: 'POST', body: '{}' }); showKeyDialog(r.name, r.publicKey, true); loadTargets(); } catch (e) { alert(e.message); } })); + if (t.publicKey) act.appendChild(mkBtn('공개키 보기', 'ghost', () => showKeyDialog(t.name, t.publicKey, false))); + if (!t.isDefault) act.appendChild(mkBtn('기본으로', 'ghost', async () => { await api(`/api/admin/deploy-targets/${t.id}/default`, { method: 'POST', body: '{}' }); loadTargets(); })); + act.appendChild(mkBtn('수정', 'ghost', () => fillTargetForm(t))); + act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`"${t.name}" 배포 서버를 삭제할까요? (이미 배포된 사이트 파일은 남습니다)`)) return; await api(`/api/admin/deploy-targets/${t.id}`, { method: 'DELETE' }); loadTargets(); })); + tr.appendChild(act); tb.appendChild(tr); + } + if (!targets.targets.length) { const tr = document.createElement('tr'); td(tr, '등록된 배포 서버가 없습니다. "배포 서버 추가" 를 누르세요.').colSpan = 11; tb.appendChild(tr); } +} +/** + * v1.5.13 (요구 3): 공개키 창. 키 한 줄을 읽기 전용 textarea 에 넣고 "공개키 복사" 로 클립보드에 복사한다. + * 클립보드 API 는 https 또는 localhost 에서만 쓸 수 있으므로(사내 http://IP 접속), 안 되면 textarea 선택 + execCommand('copy') 로, 그것도 안 되면 prompt 로 보여 준다. + * @param {string} name 배포 서버 이름 @param {string} publicKey 공개키 한 줄 @param {boolean} fresh 방금 생성했는지(안내 문구) + */ +function showKeyDialog(name, publicKey, fresh) { + const key = String(publicKey || '').trim(); + $('tKeyTitle').textContent = `${fresh ? '새로 만든 ' : ''}공개키 — ${name}`; + $('tKeyNote').innerHTML = (fresh ? '키를 새로 만들었습니다. 이 공개키를 등록해야 배포 서버에 접속할 수 있습니다. ' : '') + '배포 서버의 ~/.ssh/authorized_keys 에 아래 한 줄을 그대로 추가하세요 (접속 사용자의 홈). 줄바꿈 없이 한 줄입니다.'; + $('tKeyText').value = key; $('tKeyMsg').textContent = ''; + const dlg = $('tKeyDialog'); if (typeof dlg.showModal === 'function') { if (!dlg.open) dlg.showModal(); } else dlg.setAttribute('open', ''); + setTimeout(() => { $('tKeyText').focus(); $('tKeyText').select(); }, 50); +} +async function copyKey() { + const ta = $('tKeyText'); const key = ta.value; const done = () => { $('tKeyMsg').textContent = '복사했습니다 — 배포 서버의 ~/.ssh/authorized_keys 에 붙여넣으세요'; $('tKeyCopy').textContent = '복사됨'; setTimeout(() => ($('tKeyCopy').textContent = '공개키 복사'), 1500); }; + try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(key); return done(); } } catch (_) {} + try { ta.focus(); ta.select(); ta.setSelectionRange(0, key.length); if (document.execCommand('copy')) return done(); } catch (_) {} + $('tKeyMsg').textContent = '자동 복사가 되지 않는 환경입니다(https 가 아닌 주소). 키가 선택되어 있으니 Ctrl+C 로 복사하세요.'; +} +$('tKeyCopy').onclick = copyKey; $('tKeyClose').onclick = () => $('tKeyDialog').close(); +/** v1.5.13 (요구 4): 추가·수정 폼을 대화상자로 연다. t 가 있으면 수정, 없으면 빈 폼(추가) */ +function fillTargetForm(t) { + $('tFormTitle').textContent = t ? `배포 서버 수정: ${t.name}` : '배포 서버 추가'; $('tId').value = t ? t.id : ''; + document.querySelector(`input[name=tType][value=${t ? t.type : 'ssh'}]`).checked = true; + $('tName').value = t ? t.name : ''; $('tHost').value = t ? (t.host || '') : ''; $('tPort').value = t ? (t.port || 22) : ''; $('tUser').value = t ? (t.user || '') : ''; $('tBaseDir').value = t ? t.baseDir : ''; $('tPublicUrl').value = t ? t.publicBaseUrl : ''; $('tNote').value = t ? (t.note || '') : ''; $('tVhost').checked = t ? t.manageVhost !== false : true; $('tSsl').checked = t ? t.enableSsl !== false : true; $('tSudo').checked = t ? t.allowSudo === true : false; $('tKey').value = ''; $('tMsg').textContent = ''; + toggleTypeFields(); + const dlg = $('tDialog'); if (typeof dlg.showModal === 'function') { if (!dlg.open) dlg.showModal(); } else dlg.setAttribute('open', ''); + setTimeout(() => $('tName').focus(), 50); +} +function closeTargetForm() { const dlg = $('tDialog'); if (dlg.open) dlg.close(); } +function toggleTypeFields() { const local = document.querySelector('input[name=tType]:checked').value === 'local'; for (const id of ['tHost', 'tPort', 'tUser', 'tKey']) $(id).disabled = local; } +document.querySelectorAll('input[name=tType]').forEach((r) => (r.onchange = toggleTypeFields)); +$('tNew').onclick = () => fillTargetForm(null); +$('tCancel').onclick = closeTargetForm; +$('tSave').onclick = async () => { + const id = $('tId').value; const body = { type: document.querySelector('input[name=tType]:checked').value, name: $('tName').value, host: $('tHost').value, port: $('tPort').value || 22, user: $('tUser').value, baseDir: $('tBaseDir').value, publicBaseUrl: $('tPublicUrl').value, note: $('tNote').value, manageVhost: $('tVhost').checked, enableSsl: $('tSsl').checked, allowSudo: $('tSudo').checked, privateKey: $('tKey').value }; + try { const r = await api(id ? `/api/admin/deploy-targets/${id}` : '/api/admin/deploy-targets', { method: id ? 'PUT' : 'POST', body: JSON.stringify(body) }); $('tListMsg').textContent = `저장됨 (${r.name})${r.type === 'ssh' && !r.hasKey ? ' — 행의 "키 생성" 으로 키를 만들거나 수정 창에서 개인키를 붙여넣은 뒤 연결 테스트를 하세요' : ''}`; closeTargetForm(); loadTargets(); } + catch (e) { $('tMsg').textContent = '저장 실패: ' + e.message; } +}; +async function renderSiteDeploy(s) { + if (!targets.targets.length) { try { targets = await api('/api/admin/deploy-targets'); } catch (_) {} } + const sel = $('dTarget'); sel.innerHTML = ''; sel.appendChild(new Option('(기본 배포 서버)', '')); + for (const t of targets.targets) sel.appendChild(new Option(`${t.name}${t.isDefault ? ' ★' : ''}`, t.id)); + sel.value = s.deployTargetId || ''; $('dDomains').value = (s.domains || []).join(', '); + $('dDomains').onchange = async () => { try { const r = await api(`/api/admin/sites/${s.id}/domains`, { method: 'POST', body: JSON.stringify({ domains: $('dDomains').value }) }); $('dDomains').value = r.domains.join(', '); } catch (e) { alert(e.message); } }; + sel.onchange = async () => { try { await api(`/api/admin/sites/${s.id}/target`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); } catch (e) { alert(e.message); } }; + const box = $('dDeployResult'); + const showResult = (cls, title, lines, access) => { + box.className = 'deploy-result ' + cls; box.innerHTML = ''; const h = document.createElement('h4'); h.textContent = title; box.appendChild(h); + for (const l of lines) { const p = document.createElement('div'); p.textContent = l; box.appendChild(p); } + if (access && access.items && access.items.length) { const ul = document.createElement('ul'); for (const it of access.items) { const li = document.createElement('li'); const a = document.createElement('a'); a.href = it.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = it.url; li.appendChild(a); li.appendChild(document.createTextNode(` — ${it.ok ? '✓ ' : '✗ '}${it.note}`)); li.className = it.ok ? 'st-done' : 'st-error'; ul.appendChild(li); } box.appendChild(ul); } + }; + async function ensureSudo() { + const tid = sel.value || targets.defaultId; const t = targets.targets.find((x) => x.id === tid); if (!t) return true; + const needs = (t.manageVhost !== false || t.enableSsl !== false) && (s.domains || []).length > 0; + if (!needs || t.allowSudo === true) return true; + if (!confirm(`도메인 연결(nginx 설정)과 SSL 인증서 발급에는 배포 서버 "${t.name}" 에서 sudo 명령 실행이 필요합니다.\n이 서버에서 sudo 사용을 허용할까요? (허용하지 않으면 파일만 배포되고 도메인/SSL 은 설정되지 않습니다)`)) return false; + try { await api(`/api/admin/deploy-targets/${t.id}`, { method: 'PUT', body: JSON.stringify({ ...t, allowSudo: true }) }); targets = await api('/api/admin/deploy-targets'); } catch (e) { alert('설정 저장 실패: ' + e.message); return false; } + return true; + } + $('dDeploy').onclick = async () => { + const ok = await ensureSudo(); if (!ok) { showResult('fail', '배포 취소', ['sudo 사용을 허용하지 않아 배포를 진행하지 않았습니다. 파일만 배포하려면 배포 서버 설정에서 vhost/SSL 자동 설정을 끄세요.']); return; } + showResult('busy', '배포 중…', ['파일 전송 → 도메인 연결 → SSL 발급 → 이전 버전 정리 → 접속 확인 순으로 진행합니다. SSL 발급은 1~2분 걸릴 수 있습니다.']); $('dDeploy').disabled = true; + try { + const r = await api(`/api/admin/sites/${s.id}/deploy`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); + const okAll = r.access && r.access.items.length ? r.access.items.some((i) => i.ok) : true; + showResult(okAll ? 'ok' : 'fail', okAll ? '배포 완료' : '배포는 되었지만 접속되는 주소가 없습니다', [r.detail, r.defaultServer ? `기본 오류 서버: ${r.defaultServer.ok ? '정상' : '주의'} — ${r.defaultServer.detail}` : '', r.ssl ? `SSL: ${r.ssl.detail}` : '', r.access && r.access.items.length ? '접속 가능 여부:' : '접속 주소: 도메인이나 공개 URL 이 없어 확인할 주소가 없습니다. 사이트 도메인을 입력하거나 배포 서버의 공개 URL 을 설정하세요.'].filter(Boolean), r.access); + } catch (e) { showResult('fail', '배포 실패', [e.message]); } + $('dDeploy').disabled = false; const keep = box.cloneNode(true); await openSite(s.id); $('dDeployResult').replaceWith(keep); + }; + $('dSsl').onclick = async () => { const ok = await ensureSudo(); if (!ok) return; showResult('busy', 'SSL 인증서 발급 중… (1~2분)', []); $('dSsl').disabled = true; try { const r = await api(`/api/admin/sites/${s.id}/ssl`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); showResult(r.ok ? 'ok' : 'fail', r.ok ? 'SSL 발급 완료' : 'SSL 발급 실패', [r.detail], r.access); } catch (e) { showResult('fail', 'SSL 발급 실패', [e.message]); } $('dSsl').disabled = false; }; + // v1.6.31 (요구 15): 이전 배포본으로 되돌리기 — 서버에 직전 배포본이 있을 때만 활성 + { const ds = s.deployState || {}; $('dRollback').disabled = !ds.hasPrev; $('dRollback').title = ds.hasPrev ? '배포 서버에 보관된 직전 배포본으로 운영 주소를 되돌립니다' : '배포 서버에 보관된 직전 배포본이 없습니다 (1.6.31 이후 두 번째 배포부터 가능)'; + $('dRollback').onclick = async () => { if (!confirm('운영 주소를 직전 배포본으로 되돌릴까요? (빌더의 사이트 내용은 바뀌지 않으며, 다음 배포 때 다시 현재 내용이 올라갑니다)')) return; showResult('busy', '되돌리는 중…', []); try { const r = await api(`/api/admin/recovery/action`, { method: 'POST', body: JSON.stringify({ kind: 'deploy', id: s.id, action: 'rollback' }) }); showResult(r.ok ? 'ok' : 'fail', r.ok ? '되돌리기 완료' : '되돌리기 실패', [r.detail || '']); } catch (e) { showResult('fail', '되돌리기 실패', [e.message]); } }; } + $('dAccess').onclick = async () => { showResult('busy', '접속 확인 중…', []); try { const r = await api(`/api/admin/sites/${s.id}/access-check`, { method: 'POST', body: JSON.stringify({ targetId: sel.value }) }); showResult(r.items.some((i) => i.ok) ? 'ok' : 'fail', '접속 확인 결과', [r.items.length ? '' : '확인할 주소가 없습니다 (도메인 또는 공개 URL 필요)'].filter(Boolean), r); } catch (e) { showResult('fail', '확인 실패', [e.message]); } }; + const tb = $('depTable').querySelector('tbody'); tb.innerHTML = ''; + for (const d of [...((s.deploy && s.deploy.history) || [])].reverse()) { const tr = document.createElement('tr'); td(tr, dt(d.at)); td(tr, d.targetName + ((d.domains || []).length ? ` (${d.domains.join(', ')}${d.vhostOk === false ? ' · vhost 실패' : ''}${d.sslOk === true ? ' · SSL' : (d.sslOk === false ? ' · SSL 실패' : '')})` : '')); td(tr, d.ok ? (d.access && d.access.items && d.access.items.length && !d.access.items.some((i) => i.ok) ? '성공(접속 불가)' : '성공') : '실패', d.ok ? (d.access && d.access.items && d.access.items.length && !d.access.items.some((i) => i.ok) ? 'st-cancelled' : 'st-done') : 'st-error'); const u = document.createElement('td'); if (d.url) { const a = document.createElement('a'); a.href = d.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = d.url; a.style.color = 'var(--accent2)'; u.appendChild(a); } tr.appendChild(u); td(tr, d.detail, 'wrap'); td(tr, d.by || ''); tb.appendChild(tr); } + if (!((s.deploy && s.deploy.history) || []).length) { const tr = document.createElement('tr'); td(tr, '배포 이력이 없습니다').colSpan = 6; tb.appendChild(tr); } +} + +// ---------- 회원 · 청구 ---------- +let creditsData = { users: [] }; +async function loadBilling() { + [billingData, creditsData] = await Promise.all([api('/api/admin/billing'), api('/api/admin/credits')]); const b = billingData; + const mt = $('modelTable').querySelector('tbody'); mt.innerHTML = ''; + const prices = Object.fromEntries((settingsData ? settingsData.models : []).map((m) => [m.id, m])); + for (const m of b.models) { const tr = document.createElement('tr'); td(tr, m.model); td(tr, m.calls + (m.estimatedCalls ? ` (추정 ${m.estimatedCalls})` : ''), 'num'); td(tr, fmt(m.input), 'num'); td(tr, fmt(m.output), 'num'); td(tr, fmt(m.cacheCreate), 'num'); td(tr, fmt(m.cacheRead), 'num'); td(tr, fmt(m.tokens), 'num'); td(tr, usd(m.usd), 'num'); const p = prices[m.model]; td(tr, p ? `$${p.input} / $${p.output}` : '-'); mt.appendChild(tr); } + if (!b.models.length) { const tr = document.createElement('tr'); td(tr, '사용 기록이 없습니다').colSpan = 9; mt.appendChild(tr); } + $('fxLine').textContent = `오늘 적용 환율: 1 USD = ${b.fx.today.rate.toLocaleString(uiLocale())}원 (${b.fx.mode === 'manual' ? '관리자 입력' : `${b.fx.today.source}, 중간환율 ${b.fx.today.mid} + 스프레드 ${b.fx.spreadPct}%`}) · 마진 ${b.marginPct}% · 합계: 비용 ${usd(b.totals.usd)} → 정산 ${krw(b.totals.krw)} → 청구 ${krw(b.totals.charge)} · 캐시 잔액 합계 ${krw((creditsData.users || []).reduce((a, x) => a + (x.balance || 0), 0))}${b.rateErrors.length ? ' · 환율 조회 실패: ' + b.rateErrors.join(', ') : ''}`; + const bt = $('billTable').querySelector('tbody'); bt.innerHTML = ''; + for (const u of b.users) { + const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => showBillUser(u.userId); + const cr = (creditsData.users || []).find((x) => x.id === u.userId) || { balance: 0, used: 0, waiting: 0 }; + td(tr, `${u.name || ''} (${u.userId})`); td(tr, u.pendingApproval ? '승인 대기' : (u.role === 'admin' ? '관리자 (한도 없음)' : (u.role ? '일반' : '-')), u.pendingApproval ? 'st-cancelled' : '') /* v1.5.27 승인 대기 표시 */; td(tr, u.sites, 'num'); td(tr, fmt(u.tokens), 'num'); td(tr, usd(u.usd), 'num'); td(tr, krw(cr.used), 'num'); td(tr, krw(cr.balance), 'num ' + (cr.balance <= 0 ? 'neg' : 'pos')); td(tr, cr.waiting ? `${cr.waiting}건` : '-', cr.waiting ? 'neg' : ''); + const act = document.createElement('td'); act.className = 'act'; + // v1.5.27 (요구 6) 승인 대기 계정: 승인(로그인 가능) 또는 거절(계정 삭제) + if (u.pendingApproval) { act.appendChild(mkBtn('가입 승인', 'primary', async () => { try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/approve`, { method: 'POST', body: '{}' }); loadBilling(); loadOverview(); } catch (e) { alert(e.message); } })); act.appendChild(mkBtn('거절(삭제)', 'ghost danger', async () => { if (!confirm(`${u.name || ''} (${u.userId}) 의 가입 신청을 거절하고 계정을 지울까요?`)) return; try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/reject`, { method: 'POST', body: '{}' }); loadBilling(); loadOverview(); } catch (e) { alert(e.message); } })); } + act.appendChild(mkBtn('캐시', 'primary', () => { showBillUser(u.userId); $('chargeAmount').focus(); })); + if (u.role) act.appendChild(mkBtn('비밀번호 재설정', 'ghost', async () => { const pw = prompt(`${u.name || ''} (${u.userId}) 의 새 비밀번호 (${window.pwRuleText || '6자 이상'})`); if (!pw) return; try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/password`, { method: 'POST', body: JSON.stringify({ password: pw }) }); alert('비밀번호를 재설정했습니다'); } catch (e) { alert(e.message); } })); + if (u.role) act.appendChild(mkBtn(u.role === 'admin' ? '일반으로' : '관리자로', 'ghost', async () => { if (me && u.userId === me.id) return alert('자기 자신의 역할은 바꿀 수 없습니다'); try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/role`, { method: 'POST', body: JSON.stringify({ role: u.role === 'admin' ? 'user' : 'admin' }) }); loadBilling(); loadOverview(); } catch (e) { alert(e.message); } })); + // v1.4.0 계정 종류: 서브 도메인 전용 / 자체 도메인 — 고르고 "변경" 을 눌러야 적용 (관리자는 항상 자체 도메인) + if (u.role === 'user') { + const box = document.createElement('span'); box.className = 'acct-type'; box.onclick = (e) => e.stopPropagation(); + const sel = document.createElement('select'); for (const [v, l] of [['subdomain', '서브 도메인 전용'], ['domain', '자체 도메인']]) { const o = document.createElement('option'); o.value = v; o.textContent = T(l); sel.appendChild(o); } sel.value = u.accountType || 'domain'; + box.appendChild(sel); box.appendChild(mkBtn('변경', 'ghost', async () => { try { await api(`/api/admin/users/${encodeURIComponent(u.userId)}/account-type`, { method: 'POST', body: JSON.stringify({ accountType: sel.value }) }); u.accountType = sel.value; alert(TF('계정 종류를 바꿨습니다: {1}', sel.options[sel.selectedIndex].textContent)); } catch (e) { alert(e.message); } })); + act.appendChild(box); + } + tr.appendChild(act); bt.appendChild(tr); + } + if (curBillUser) showBillUser(curBillUser); + loadPrepaid().catch(() => {}); // v1.6.40 선불 충전 상품·주문 +} +/* ---------- v1.6.40 (요구 1) 선불 충전 상품 · 주문 ---------- + * 상품: 이름 · 결제 금액 · 인센티브(정률 %/정액 원) · 표시 여부 · 순서. 적립 캐시 = 결제 금액 + 인센티브 (서버 lib/prepaid.js 가 계산해 준다). + * 주문: 사용자가 캐시·충전 화면에서 접수한 결제 대기 주문. PG 미연동이라 관리자가 입금(결제) 확인 → "입금 확인 → 적립" 으로 캐시 원장에 더한다. */ +let prepaidData = null; +const PP_STATUS = { pending: '결제 대기', paid: '적립 완료', cancelled: '취소' }; +async function loadPrepaid() { + prepaidData = await api('/api/admin/prepaid'); const d = prepaidData; + const tb = $('ppTable').querySelector('tbody'); tb.innerHTML = ''; + for (const p of d.packages) { + const tr = document.createElement('tr'); td(tr, p.name + (p.note ? ` — ${p.note}` : ''), 'wrap'); td(tr, krw(p.amount), 'num'); td(tr, p.bonusType === 'percent' ? `${p.bonusValue}% (${krw(p.bonus)})` : krw(p.bonusValue), 'num'); td(tr, krw(p.credit), 'num pos'); td(tr, p.active ? T('표시') : T('숨김'), p.active ? 'pos' : 'neg'); td(tr, String(p.sort || 0), 'num'); + const act = document.createElement('td'); act.className = 'act'; + act.appendChild(mkBtn('수정', 'ghost', () => { $('ppId').value = p.id; $('ppName').value = p.name; $('ppAmount').value = p.amount; $('ppBonusType').value = p.bonusType; $('ppBonusValue').value = p.bonusValue; $('ppSort').value = p.sort || 0; $('ppActive').checked = p.active !== false; $('ppNote').value = p.note || ''; $('ppFormTitle').textContent = TF('상품 수정 — {1}', p.name); $('ppName').focus(); })); + act.appendChild(mkBtn(p.active ? '숨기기' : '표시', 'ghost', async () => { try { await api('/api/admin/prepaid/packages', { method: 'POST', body: JSON.stringify({ id: p.id, active: !p.active }) }); await loadPrepaid(); } catch (e) { alert(e.message); } })); + act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(TF('상품 "{1}" 을 지울까요? 주문 기록이 있는 상품은 지우지 않고 숨김으로 바뀝니다.', p.name))) return; try { const r = await api(`/api/admin/prepaid/packages/${p.id}`, { method: 'DELETE' }); $('ppMsg').textContent = r.removed ? T('지웠습니다') : T('주문 기록이 있어 숨김으로 바꿨습니다'); await loadPrepaid(); } catch (e) { alert(e.message); } })); + tr.appendChild(act); tb.appendChild(tr); + } + if (!d.packages.length) { const tr = document.createElement('tr'); td(tr, '아직 상품이 없습니다 — 아래에서 만드세요').colSpan = 7; tb.appendChild(tr); } + const pend = d.orders.filter((o) => o.status === 'pending').length; const badge = $('ppBadge'); badge.textContent = pend ? String(pend) : ''; badge.classList.toggle('hidden', !pend); + drawPrepaidOrders(); +} +function drawPrepaidOrders() { + const d = prepaidData; if (!d) return; const f = $('ppOrderFilter').value; const list = d.orders.filter((o) => !f || o.status === f); + const ot = $('ppOrders').querySelector('tbody'); ot.innerHTML = ''; + for (const o of list) { + const tr = document.createElement('tr'); td(tr, dt(o.at)); td(tr, `${d.users[o.userId] || ''} (${o.userId})`); td(tr, o.name, 'wrap'); td(tr, krw(o.amount), 'num'); td(tr, krw(o.credit), 'num pos'); td(tr, T(PP_STATUS[o.status] || o.status), o.status === 'paid' ? 'pos' : (o.status === 'pending' ? 'st-cancelled' : '')); + const act = document.createElement('td'); act.className = 'act'; + if (o.status === 'pending') { + act.appendChild(mkBtn('입금 확인 → 적립', 'primary', async () => { const memo = prompt(TF('{1} ({2}) 의 입금(결제)을 확인했습니까?\n결제 {3} → 적립 {4}\n메모(입금자명·승인번호 등, 선택):', d.users[o.userId] || '', o.userId, krw(o.amount), krw(o.credit))); if (memo === null) return; try { const r = await api(`/api/admin/prepaid/orders/${o.id}`, { method: 'POST', body: JSON.stringify({ action: 'paid', memo }) }); $('ppOrderMsg').textContent = TF('적립했습니다 — 잔액 {1}', krw(r.balance)); await loadPrepaid(); loadBilling(); } catch (e) { alert(e.message); } })); + act.appendChild(mkBtn('취소', 'ghost danger', async () => { const memo = prompt(T('취소 사유 (선택):')); if (memo === null) return; try { await api(`/api/admin/prepaid/orders/${o.id}`, { method: 'POST', body: JSON.stringify({ action: 'cancel', memo }) }); await loadPrepaid(); } catch (e) { alert(e.message); } })); + } else if (o.memo) act.appendChild(document.createTextNode(o.memo)); + tr.appendChild(act); ot.appendChild(tr); + } + if (!list.length) { const tr = document.createElement('tr'); td(tr, '주문이 없습니다').colSpan = 7; ot.appendChild(tr); } +} +document.addEventListener('DOMContentLoaded', () => { + const f = $('ppOrderFilter'); if (!f) return; + f.onchange = drawPrepaidOrders; + const reset = () => { $('ppId').value = ''; $('ppName').value = ''; $('ppAmount').value = ''; $('ppBonusType').value = 'percent'; $('ppBonusValue').value = ''; $('ppSort').value = ''; $('ppActive').checked = true; $('ppNote').value = ''; $('ppFormTitle').textContent = T('새 상품'); }; + $('ppReset').onclick = reset; + $('ppSave').onclick = async () => { + const body = { id: $('ppId').value || undefined, name: $('ppName').value, amount: $('ppAmount').value, bonusType: $('ppBonusType').value, bonusValue: $('ppBonusValue').value, sort: $('ppSort').value, active: $('ppActive').checked, note: $('ppNote').value }; + try { const r = await api('/api/admin/prepaid/packages', { method: 'POST', body: JSON.stringify(body) }); $('ppMsg').textContent = TF('저장했습니다 — {1}: 결제 {2} → 적립 {3}', r.package.name, krw(r.package.amount), krw(r.package.credit)); reset(); await loadPrepaid(); } catch (e) { $('ppMsg').textContent = e.message; } + }; +}); +const LEDGER_TYPE = { set: '설정', add: '추가', adjust: '조정', usage: '사용', refund: '환불', migrate: '이관' }; +async function showBillUser(uid) { + const u = billingData.users.find((x) => x.userId === uid); if (!u) return; curBillUser = uid; + let cr; try { cr = await api(`/api/admin/credits/${encodeURIComponent(uid)}`); } catch (e) { cr = { balance: 0, used: 0, ledger: [] }; } + $('billDetail').classList.remove('hidden'); $('billDetailTitle').textContent = `${u.name || ''} (${u.userId}) — 캐시 잔액 ${krw(cr.balance)} · 누적 사용 ${krw(cr.used)} · 누적 지급 ${krw(cr.granted || 0)}${u.role === 'admin' ? ' · 관리자(한도 미적용)' : ''}`; + const dtb = $('dayTable').querySelector('tbody'); dtb.innerHTML = ''; + for (const d of u.byDay) { const tr = document.createElement('tr'); td(tr, d.day); td(tr, d.commands, 'num'); td(tr, `${d.rate.toLocaleString(uiLocale())}원`, 'num'); td(tr, usd(d.usd), 'num'); td(tr, krw(d.krw), 'num'); td(tr, krw(d.charge), 'num'); dtb.appendChild(tr); } + const ct = $('chargeTable').querySelector('tbody'); ct.innerHTML = ''; + for (const e of cr.ledger || []) { const tr = document.createElement('tr'); td(tr, dt(e.at)); td(tr, LEDGER_TYPE[e.type] || e.type, e.type === 'usage' ? 'neg' : 'pos'); td(tr, krw(e.amount), 'num ' + (e.amount < 0 ? 'neg' : 'pos')); td(tr, krw(e.balanceAfter), 'num'); td(tr, e.memo || '', 'wrap'); td(tr, e.by || ''); ct.appendChild(tr); } + if (!(cr.ledger || []).length) { const tr = document.createElement('tr'); td(tr, '기록이 없습니다').colSpan = 6; ct.appendChild(tr); } + const post = async (type) => { const amount = +$('chargeAmount').value; if (!$('chargeAmount').value.trim() || !Number.isFinite(amount)) return alert('금액을 입력하세요'); if (type === 'set' && !confirm(`${u.name || ''} (${uid}) 의 캐시 잔액을 ${krw(amount)} 으로 설정할까요?`)) return; try { await api(`/api/admin/credits/${encodeURIComponent(uid)}`, { method: 'POST', body: JSON.stringify({ type, amount, memo: $('chargeMemo').value }) }); $('chargeAmount').value = ''; $('chargeMemo').value = ''; await loadBilling(); } catch (e) { alert(e.message); } }; + $('btnCharge').onclick = () => post('add'); $('btnSetCredit').onclick = () => post('set'); + $('billDetail').scrollIntoView({ behavior: 'smooth', block: 'start' }); // v1.1.6: 충전 폼이 원장 위에 있으므로 상세를 열면 바로 보인다 +} + +// ---------- 사용자 제안·오류 (v1.1.2) ---------- +const FB_TYPE = { inquiry: '문의', suggestion: '제안', bug: '오류', request: '요구' }; const FB_STATUS = { new: '새 글', reviewing: '검토 중', done: '처리 완료', rejected: '처리 완료' }; // v1.2.0: 새 글(접수) → 검토 중(AI 검토) → 처리 완료(관리자 답변). 반려 없음 +let fbData = null; let fbCur = null; +async function loadFeedback() { + fbData = await api('/api/admin/feedback'); const tb = $('fbTable').querySelector('tbody'); tb.innerHTML = ''; + $('fbSummary').textContent = `— ${fbData.counts.total}건 (새 글 ${fbData.counts.new} · 다음 작업 반영 ${fbData.counts.planned || 0}) · AI 분석 비용 합계 ${krw(fbData.counts.costKrw)} (${usd(fbData.counts.costUsd)})`; + const tf = $('fbTypeFilter').value; // v1.3.2 종류 거르기 + for (const x of fbData.items.filter((i) => !tf || i.type === tf)) { const tr = document.createElement('tr'); tr.className = 'clickable'; tr.onclick = () => showFeedback(x.id); td(tr, dt(x.at)); td(tr, FB_TYPE[x.type] || x.type); td(tr, x.title, 'wrap'); td(tr, `${x.userName || ''} (${x.userId})`); td(tr, x.siteTitle || '-'); const a = x.analysis; td(tr, a ? `${a.category || ''} · 중요도 ${a.priority || '-'} · 작업량 ${a.effort || '-'}` : (x.comments.some((c) => c.by === 'ai') ? '분석 실패' : '분석 중…'), 'wrap'); td(tr, x.cost && x.cost.krw ? `${krw(x.cost.krw)} (${usd(x.cost.usd)})` : '-', 'num'); { const b = document.createElement('span'); if (x.planned) { b.className = 'badge planned'; b.textContent = x.collectedAt ? '반영 예정 · 수집됨' : '반영 예정'; b.title = x.planNote || ''; } else b.textContent = '-'; td(tr, b); } td(tr, FB_STATUS[x.status] || x.status, 'st-' + (x.status === 'done' ? 'done' : x.status === 'new' ? 'running' : 'idle')); tb.appendChild(tr); } + if (!fbData.items.length) { const tr = document.createElement('tr'); td(tr, '올라온 글이 없습니다').colSpan = 9; tb.appendChild(tr); } + if (fbCur) showFeedback(fbCur); +} +function showFeedback(id) { + const x = fbData.items.find((i) => i.id === id); if (!x) return; fbCur = id; $('fbDetail').classList.remove('hidden'); + $('fbdTitle').textContent = `[${FB_TYPE[x.type] || x.type}] ${x.title} — ${x.userName || ''} (${x.userId}) · ${dt(x.at)}${x.siteTitle ? ' · 사이트: ' + x.siteTitle : ''}${x.page ? ' · 화면: ' + x.page : ''}`; + $('fbdBody').textContent = x.body || '(내용 없음)'; $('fbdStatus').value = x.status; $('fbdReply').value = ''; $('fbdMsg').textContent = ''; + $('fbdEnv').textContent = x.env ? Object.entries(x.env).map(([k, v]) => `${k}: ${v}`).join('\n') : '(수집된 환경 정보 없음 — v1.1.6 이전 글)'; + $('fbdPlanned').checked = !!x.planned; $('fbdPlanNote').value = x.planNote || ''; + $('fbdPlanInfo').textContent = x.planned ? `반영 결정 ${dt(x.plannedAt)} (${x.plannedBy || ''})${x.collectedAt ? ` · 작업 서버 수집 ${dt(x.collectedAt)}` : ' · 아직 수집되지 않음'}` : (x.analysis ? 'AI 분석을 참고해 반영 여부와 반영할 내용을 정하세요.' : 'AI 분석이 끝나면 내용을 참고해 결정하세요.'); + const box = $('fbdComments'); box.innerHTML = ''; + for (const c of x.comments) { const d = document.createElement('div'); d.className = 'card'; d.style.marginBottom = '6px'; d.innerHTML = `
    ${c.by === 'ai' ? 'AI 분석' + (c.model ? ' (' + c.model + ')' : '') : c.by === 'admin' ? '관리자' + (c.byName ? ' ' + c.byName : '') : c.by} · ${dt(c.at)}${c.usd ? ` · 비용 ${krw(c.krw)} (${usd(c.usd)})` : ''}
    ${c.text.replace(/[<>&]/g, (ch) => ({ '<': '<', '>': '>', '&': '&' }[ch]))}
    `; box.appendChild(d); } + if (!x.comments.length) box.textContent = '아직 댓글이 없습니다 (AI 분석 진행 중일 수 있습니다 — 새로고침).'; + // v1.2.0: 답변을 적으면 처리 상태를 "처리 완료"로 미리 맞춰 보여 준다(서버도 관리자 답변 시 자동으로 처리 완료) + $('fbdReply').oninput = () => { if ($('fbdReply').value.trim()) $('fbdStatus').value = 'done'; }; + /** + * 저장: 반영 결정 → 상태 → 답변 순으로 서버가 처리한다. + * 1.1.6 까지는 저장 직후 목록을 다시 그리면서(showFeedback) 결과 메시지와 입력값이 지워져 "저장이 안 된 것처럼" 보였다. + * 이제 목록을 다시 불러온 뒤에 결과 메시지를 표시한다. + */ + $('fbdSave').onclick = async () => { + if ($('fbdPlanned').checked && !$('fbdPlanNote').value.trim()) { $('fbdMsg').textContent = '반영할 내용을 구체적으로 적어 주세요'; return; } + const reply = $('fbdReply').value.trim(); $('fbdSave').disabled = true; + try { + const r = await api(`/api/admin/feedback/${id}`, { method: 'PUT', body: JSON.stringify({ status: $('fbdStatus').value, reply, planned: $('fbdPlanned').checked, planNote: $('fbdPlanNote').value }) }); + await loadFeedback(); loadOverview(); + $('fbdMsg').textContent = reply ? `답변을 저장했습니다 — 상태: ${FB_STATUS[r.status] || r.status} (사용자 화면에 표시됩니다)` : `저장했습니다 — 상태: ${FB_STATUS[r.status] || r.status}`; + } catch (e) { $('fbdMsg').textContent = e.message; } + $('fbdSave').disabled = false; + }; + // v1.3.2: AI 1차 분석의 답변 초안을 답변 칸에 넣기 (관리자가 고쳐서 저장) + $('fbdDraft').classList.toggle('hidden', !(x.analysis && x.analysis.replyDraft)); $('fbdDraft').onclick = () => { $('fbdReply').value = x.analysis.replyDraft; $('fbdStatus').value = 'done'; $('fbdReply').focus(); }; + $('fbdReanalyze').onclick = async () => { try { await api(`/api/admin/feedback/${id}/analyze`, { method: 'POST', body: '{}' }); $('fbdMsg').textContent = 'AI 분석을 다시 요청했습니다. 잠시 후 새로고침하세요.'; } catch (e) { $('fbdMsg').textContent = e.message; } }; +} +// 다음 작업 반영 목록 (v1.1.6): 관리자가 반영으로 결정한 글을 마크다운으로 — 작업 서버가 같은 내용을 수집한다 +$('fbBacklogBtn').onclick = async () => { try { const r = await api('/api/admin/feedback/backlog'); $('fbBacklogMd').textContent = r.markdown; $('fbBacklogInfo').textContent = ` — ${r.items.length}건 · 수집 상태: ${r.items.filter((x) => x.collectedAt).length}건 수집됨`; $('fbBacklog').classList.remove('hidden'); } catch (e) { alert(e.message); } }; +$('fbBacklogClose').onclick = () => $('fbBacklog').classList.add('hidden'); +$('fbBacklogCopy').onclick = async () => { try { await navigator.clipboard.writeText($('fbBacklogMd').textContent); $('fbBacklogCopy').textContent = '복사됨'; setTimeout(() => ($('fbBacklogCopy').textContent = '마크다운 복사'), 1500); } catch (_) {} }; +// ---------- 언어 (v1.1.2) ---------- +async function loadI18n() { + // v1.3.5: 원본은 항상 지금 서비스 버전의 카탈로그다 (화면을 오래 열어 두면 예전 값이 남으므로 버전 감시가 다시 불러온다) + const d = await api('/api/admin/i18n'); $('i18nSource').textContent = `— 서비스 v${d.serviceVersion || d.source.version} · 원본(한국어) ${d.source.count}개 문구`; + const tb = $('i18nTable').querySelector('tbody'); tb.innerHTML = ''; + for (const l of d.languages) { const tr = document.createElement('tr'); td(tr, l.lang); td(tr, l.name); td(tr, `${l.translated} / ${l.total} (${l.coverage}%)`, 'num'); td(tr, `v${l.sourceVersion}${l.outdated ? ` ≠ 서비스 v${l.serviceVersion || d.serviceVersion}` : ' (일치)'}`, l.outdated ? 'st-error' : 'st-done'); td(tr, dt(l.uploadedAt)); + // v1.3.5: 실제 표시 상태(서버 계산)와 켜기 스위치를 따로 보여 준다 + { const c = document.createElement('div'); const st = document.createElement('span'); st.className = 'badge ' + ({ shown: 'deployed', outdated: 'empty', off: 'pending', 'off-outdated': 'pending' }[l.state] || 'pending'); st.textContent = l.stateLabel || (l.active ? '표시 중' : '숨김'); st.title = l.stateDetail || ''; c.appendChild(st); const sw = document.createElement('div'); sw.className = 'muted'; sw.style.fontSize = '11px'; sw.textContent = `스위치: ${l.enabled ? '켜짐' : '꺼짐'}`; c.appendChild(sw); td(tr, c); } + const act = document.createElement('td'); act.className = 'act'; act.appendChild(mkBtn(l.enabled ? '끄기' : '켜기', 'ghost', async () => { await api(`/api/admin/i18n/${l.lang}`, { method: 'PUT', body: JSON.stringify({ enabled: !l.enabled }) }); loadI18n(); loadOverview(); })); const ex = document.createElement('a'); ex.className = 'btn ghost'; ex.href = `/api/admin/i18n/export?lang=${encodeURIComponent(l.lang)}`; ex.textContent = '현재 번역 내려받기'; ex.title = '최신 원본 문구에 기존 번역을 채워 넣은 파일 — 바뀐 문구만 번역해 다시 올리면 됩니다'; act.appendChild(ex); act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`${l.name}(${l.lang}) 번역을 삭제할까요?`)) return; await api(`/api/admin/i18n/${l.lang}`, { method: 'DELETE' }); loadI18n(); loadOverview(); })); tr.appendChild(act); tb.appendChild(tr); } + if (!d.languages.length) { const tr = document.createElement('tr'); td(tr, '올린 언어가 없습니다. 원본 파일을 내려받아 번역한 뒤 올리세요.').colSpan = 7; tb.appendChild(tr); } + // v1.3.16 서비스 화면 기본 언어 (표시 중인 언어만) + try { const st = await api('/api/admin/settings'); const ui = st.uiLang || { default: 'ko' }; const sel = $('uiDefLang'); sel.innerHTML = ''; sel.appendChild(new Option('한국어 (ko)', 'ko')); for (const l of d.languages.filter((x) => x.state === 'shown')) sel.appendChild(new Option(`${l.name} (${l.lang})`, l.lang)); sel.value = ui.default || 'ko'; if (sel.value !== (ui.default || 'ko')) sel.value = 'ko'; $('uiDefForce').checked = false; $('uiDefMsg').textContent = ui.forcedAt ? TF('마지막 강제 적용: {1}', dt(ui.forcedAt)) : ''; markSaved(); } catch (_) {} + loadSiteMessages(); +} +$('uiDefSave').onclick = async () => { + if ($('uiDefForce').checked && !confirm(T('모든 사용자의 화면 언어를 이 언어로 바꿉니다(이후 사용자가 다시 고를 수 있음). 계속할까요?'))) return; + try { const r = await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ uiLang: { default: $('uiDefLang').value, force: $('uiDefForce').checked } }) }); $('uiDefMsg').textContent = TF('적용했습니다 — 기본 언어 {1}{2}', r.uiLang.default, $('uiDefForce').checked ? ' · ' + T('모든 사용자에게 강제') : ''); $('uiDefForce').checked = false; markSaved(); } catch (e) { $('uiDefMsg').textContent = e.message; } +}; +// ---------- v1.3.16 생성 사이트 시스템 문구 번역 ---------- +let smData = null; const smEdits = {}; +async function loadSiteMessages() { + const lang = $('smLang').value || 'en'; + try { smData = await api(`/api/admin/site-messages?lang=${encodeURIComponent(lang)}`); } catch (_) { return; } + const sel = $('smLang'); if (!sel.options.length) { for (const l of smData.locales) sel.appendChild(new Option(`${l.name} (${l.code})`, l.code)); sel.value = smData.lang; } + $('smSummary').textContent = TF('— {1}개 문구 · 번역 없음 {2}개', smData.items.length, smData.missing); renderSiteMessages(); +} +function renderSiteMessages() { + if (!smData) return; const tb = $('smTable').querySelector('tbody'); tb.innerHTML = ''; const q = $('smQ').value.trim().toLowerCase(); + for (const it of smData.items) { + if ($('smMissing').checked && it.translation) continue; if (q && !`${it.id} ${it.text} ${it.translation}`.toLowerCase().includes(q)) continue; + const tr = document.createElement('tr'); td(tr, it.id); td(tr, it.group); td(tr, it.text); + const c = document.createElement('td'); const inp = document.createElement('input'); inp.value = smEdits[it.id] !== undefined ? smEdits[it.id] : it.translation; inp.style.margin = '0'; inp.style.width = '100%'; inp.oninput = () => { smEdits[it.id] = inp.value; inp.classList.add('dirty'); }; c.appendChild(inp); tr.appendChild(c); + td(tr, { admin: T('관리자'), ai: 'AI', seed: T('기본 제공'), '': '—' }[it.origin]); tb.appendChild(tr); + } +} +$('smLang').onchange = () => { for (const k of Object.keys(smEdits)) delete smEdits[k]; loadSiteMessages(); }; +$('smQ').oninput = renderSiteMessages; $('smMissing').onchange = renderSiteMessages; +$('smSave').onclick = async () => { const n = Object.keys(smEdits).length; if (!n) { $('smMsg').textContent = T('고친 번역이 없습니다'); return; } try { await api(`/api/admin/site-messages/${encodeURIComponent($('smLang').value)}`, { method: 'PUT', body: JSON.stringify({ messages: smEdits }) }); for (const k of Object.keys(smEdits)) delete smEdits[k]; $('smMsg').textContent = TF('{1}개 번역을 저장했습니다 (다음 다국어 맞춤·배포 때 사이트에 반영)', n); loadSiteMessages(); } catch (e) { $('smMsg').textContent = e.message; } }; +async function uploadI18n(files) { + if (!files.length) return; const fd = new FormData(); for (const f of files) fd.append('files', f, f.name); $('i18nMsg').textContent = '올리는 중…'; + try { const r = await api('/api/admin/i18n/upload', { method: 'POST', body: fd }); const box = $('i18nResults'); box.innerHTML = ''; for (const x of r.results) { const d = document.createElement('div'); d.className = 'muted'; d.style.color = x.ok ? '#6ee7b7' : '#fca5a5'; d.textContent = x.ok ? `✓ ${x.file}: ${x.name}(${x.lang}) ${x.translated}개 번역 저장${x.badPatternCount ? ` — 자리표시({1} 등)가 원문과 다른 ${x.badPatternCount}개 항목은 제외했습니다(예: ${x.badPatterns.slice(0, 2).join(' / ')})` : ''}${x.outdated ? ` — 이 파일은 v${x.sourceVersion} 원본 기준이라 현재 서비스(v${r.version || '?'})에서는 화면에 표시되지 않습니다(바뀐 문구가 섞이는 것 방지). 오른쪽 링크로 "최신 원본 + 지금 올린 번역" 파일을 받아 비어 있는 translation 만 채워 다시 올리세요.` : ''}` : `✗ ${x.error}`; box.appendChild(d); + // v1.3.0: 구버전 기준 번역은 바로 이어서 보충할 수 있게 최신 원본에 기존 번역을 채운 파일 링크를 붙인다 + if (x.ok && x.outdated) { const a = document.createElement('a'); a.className = 'btn ghost'; a.style.marginLeft = '6px'; a.href = `/api/admin/i18n/export?lang=${encodeURIComponent(x.lang)}`; a.textContent = `${x.lang} 최신 원본 + 기존 번역 내려받기`; a.setAttribute('download', ''); d.appendChild(a); } } $('i18nMsg').textContent = ''; loadI18n(); loadOverview(); } + catch (e) { $('i18nMsg').textContent = '실패: ' + e.message; } +} +{ const dz = $('i18nDrop'); dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.style.background = '#1e293b'; }); dz.addEventListener('dragleave', () => { dz.style.background = ''; }); dz.addEventListener('drop', (e) => { e.preventDefault(); dz.style.background = ''; uploadI18n([...e.dataTransfer.files]); }); $('i18nFiles').onchange = (e) => { uploadI18n([...e.target.files]); e.target.value = ''; }; } +// ---------- AI 제공자 ---------- +let provData = null, provCur = null, provModels = []; +async function loadProviders() { + provData = await api('/api/admin/providers'); const tb = $('provTable').querySelector('tbody'); tb.innerHTML = ''; + for (const pv of provData.providers) { + if (pv.id === 'anthropic') continue; + const tr = document.createElement('tr'); td(tr, pv.label); td(tr, pv.enabled ? '활성' : '꺼짐', pv.enabled ? 'st-done' : 'st-idle'); td(tr, pv.id === 'openai' && pv.authMode === 'server-login' ? (pv.codex && pv.codex.loggedIn ? '서버 로그인' : '서버 로그인(미로그인)') : (pv.hasKey ? pv.apiKeyMasked : '없음'), (pv.hasKey || (pv.authMode === 'server-login' && pv.codex && pv.codex.loggedIn)) ? '' : 'st-cancelled'); td(tr, pv.models.length ? `${pv.models.length}개: ${pv.models.slice(0, 4).map((m) => m.id).join(', ')}${pv.models.length > 4 ? ' …' : ''}` : '-', 'wrap'); td(tr, pv.lastTest ? `${pv.lastTest.ok ? '성공' : '실패'} · ${dt(pv.lastTest.at)}` : '-', pv.lastTest ? (pv.lastTest.ok ? 'st-done' : 'st-error') : ''); + // v1.6.38 (요구 1): 설정 탭 등급 목록에 들어가는지 — 빠졌으면 이유 (키 없음 등). 1.6.35 까지는 저장 문구가 "반영했습니다" 라고만 해서 원인을 알 수 없었다 + td(tr, pv.enabled ? (pv.usable ? `포함 (${pv.usableModels}개)` : `제외 — ${pv.unavailableReason || ''}`) : '-', pv.enabled ? (pv.usable ? 'st-done wrap' : 'st-error wrap') : ''); + const act = document.createElement('td'); act.className = 'act'; act.appendChild(mkBtn('설정', 'ghost', () => openProv(pv))); tr.appendChild(act); tb.appendChild(tr); + } +} +/** v1.6.38 (요구 1): 제공자 폼 아래 상태 줄 — 설정 탭 등급 목록에 들어가는지, 아니면 왜 빠졌는지 */ +function showProvUsable(pv) { + const p = $('pUsable'); if (!p) return; + if (!pv.enabled) { p.textContent = '지금 상태: 꺼짐 — "사용" 을 켜고 저장하면 설정 탭 › 모델 등급 지정 목록에 이 제공자의 모델이 들어갑니다 (API 키와 제공할 모델이 있어야 함)'; p.className = 'muted'; return; } + if (pv.usable) { p.textContent = `지금 상태: 설정 탭 › 모델 등급 지정 목록에 포함됨 (모델 ${pv.usableModels}개)`; p.className = 'muted ok'; } + else { p.textContent = `지금 상태: 설정 탭의 등급 목록에서 제외됨 — ${pv.unavailableReason || ''}`; p.className = 'msg err'; } +} +function openProv(pv) { + provCur = pv; provModels = pv.models.slice(); $('provForm').classList.remove('hidden'); showProvUsable(pv); + $('pAuthRow').classList.toggle('hidden', pv.id !== 'openai'); if (pv.id === 'openai') { document.querySelector(`input[name=pAuth][value=${pv.authMode || 'api-key'}]`).checked = true; const c = pv.codex || {}; $('pCodexInfo').textContent = c.bin ? `— codex ${c.version || ''} ${c.loggedIn ? '로그인됨(' + (c.mode === 'chatgpt' ? 'ChatGPT' : 'API 키') + ')' : '로그인 안 됨: 서버에서 codex login --device-auth 실행'}` : '— 서버에 codex CLI 없음 (npm i -g @openai/codex)'; } document.querySelectorAll('input[name=pAuth]').forEach((r) => (r.onchange = () => { $('pKey').classList.toggle('hidden', r.value === 'server-login' && r.checked); })); $('pKey').classList.toggle('hidden', pv.id === 'openai' && (pv.authMode || 'api-key') === 'server-login'); $('provTitle').textContent = pv.label; $('pEnabled').checked = pv.enabled; $('pKey').value = ''; $('pKey').placeholder = pv.hasKey ? `저장됨: ${pv.apiKeyMasked} (바꾸려면 새 키 입력) · ${pv.keyHint}` : `API 키 (${pv.keyHint})`; $('pBase').classList.toggle('hidden', pv.id !== 'custom'); $('pBase').value = pv.baseUrl || ''; $('pMsg').textContent = ''; renderProvModels(); +} +function renderProvModels(fetched) { + const tb = $('pModels').querySelector('tbody'); tb.innerHTML = ''; + const list = fetched || provModels; const selected = new Set(provModels.map((m) => m.id)); + for (const m of list) { + const tr = document.createElement('tr'); const c = document.createElement('td'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.style.width = 'auto'; cb.checked = selected.has(m.id); cb.onchange = () => { if (cb.checked) { if (!provModels.some((x) => x.id === m.id)) provModels.push({ id: m.id, label: m.label || m.id, input: m.input || 0, output: m.output || 0 }); } else provModels = provModels.filter((x) => x.id !== m.id); }; c.appendChild(cb); tr.appendChild(c); + td(tr, (m.label && m.label !== m.id ? `${m.id} — ${m.label}` : m.id) + (m.description ? ` · ${m.description}` : ''), 'wrap'); // v1.4.14 서버 로그인 목록의 설명·종료 예정 표시 + for (const k of ['input', 'output']) { const cell = document.createElement('td'); const inp = document.createElement('input'); inp.type = 'number'; inp.step = '0.01'; inp.style.width = '90px'; inp.style.margin = '0'; inp.value = m[k] || 0; inp.onchange = () => { m[k] = +inp.value; const sel = provModels.find((x) => x.id === m.id); if (sel) sel[k] = +inp.value; }; cell.appendChild(inp); tr.appendChild(cell); } + tb.appendChild(tr); + } + if (!list.length) { const tr = document.createElement('tr'); td(tr, '모델 조회를 눌러 목록을 불러오세요').colSpan = 4; tb.appendChild(tr); } +} +$('pFetch').onclick = async () => { $('pMsg').textContent = '조회 중…'; try { if (provCur.id === 'openai') { const am = document.querySelector('input[name=pAuth]:checked').value; if (am !== provCur.authMode) { await api(`/api/admin/providers/openai`, { method: 'PUT', body: JSON.stringify({ authMode: am }) }); provCur.authMode = am; } } const serverLogin = provCur.id === 'openai' && provCur.authMode === 'server-login'; const r = await api(`/api/admin/providers/${provCur.id}/test`, { method: 'POST', body: JSON.stringify({ apiKey: serverLogin ? '' : ($('pKey').value.trim() || '••••'), baseUrl: $('pBase').value.trim() }) }); if (!r.ok) { $('pMsg').textContent = '실패: ' + r.error; return; } $('pMsg').textContent = `모델 ${r.models.length}개 조회됨 — 제공할 모델을 체크하세요`; renderProvModels(r.models.map((m) => { const sel = provModels.find((x) => x.id === m.id); return sel ? { ...m, input: sel.input, output: sel.output } : m; })); } catch (e) { $('pMsg').textContent = '실패: ' + e.message; } }; +$('pSave').onclick = async () => { try { const body = { enabled: $('pEnabled').checked, models: provModels, baseUrl: $('pBase').value.trim() }; if (provCur.id === 'openai') body.authMode = document.querySelector('input[name=pAuth]:checked').value; if ($('pKey').value.trim()) body.apiKey = $('pKey').value.trim(); const r = await api(`/api/admin/providers/${provCur.id}`, { method: 'PUT', body: JSON.stringify(body) }); provCur = { ...provCur, ...r }; showProvUsable(r); + // v1.6.38 (요구 1): 저장은 됐지만 등급 목록에 못 들어가면(키 없음·모델 없음·서버 로그인 안 됨) 그 이유를 바로 말한다 — "추가했는데 설정 탭에 반영이 안 됨" 의 원인 + $('pMsg').textContent = r.enabled && !r.usable ? `저장했지만 설정 탭의 등급 목록에는 나오지 않습니다 — ${r.unavailableReason || ''}` : `저장됨 (${r.models.length}개 모델 제공${r.enabled ? '' : ', 비활성'}) — 설정 탭의 "모델 등급 지정"·"내부 호출 모델" 목록에 반영했습니다`; $('pMsg').className = r.enabled && !r.usable ? 'msg err' : 'muted'; + await loadSettings(); await loadProviders(); /* v1.4.14 (요구 6): 등급 지정 목록을 바로 다시 그린다 · v1.6.38: 제공자 표의 "등급 목록" 열도 */ } catch (e) { $('pMsg').textContent = '저장 실패: ' + e.message; } }; +$('pClose').onclick = () => $('provForm').classList.add('hidden'); + +// ---------- 설정 ---------- +// ---------- 외부 연동 접속 정보 (v1.3.1) ---------- +/** + * 다른 서비스에 접속하는 주소·설정·키(API Key/Token)를 암호화 보관소(data/credentials.json)에 넣고 관리한다. + * 키 원문은 서버가 돌려주지 않으므로 화면에는 hint(앞뒤 몇 글자)만 보인다. 키 칸을 비워 저장하면 기존 키를 유지한다. + * API: GET/POST /api/admin/credentials, PUT/DELETE /api/admin/credentials/, POST /api/admin/credentials/import, POST /api/admin/credentials//test + */ +let vaultData = null; +async function loadVault() { + try { vaultData = await api('/api/admin/credentials'); } catch (_) { return; } + const sel = $('vKind'); if (!sel.options.length) for (const [k, v] of Object.entries(vaultData.kinds)) sel.appendChild(new Option(v.label, k)); + syncVaultKind(); + const tb = $('vTable').querySelector('tbody'); tb.innerHTML = ''; + for (const x of vaultData.items) { + const tr = document.createElement('tr'); td(tr, x.label === x.kindLabel ? x.kindLabel : `${x.label} · ${x.kindLabel}`, 'wrap'); td(tr, x.url || '-', 'wrap'); td(tr, x.hasSecret ? x.secretHint : '없음', x.hasSecret ? '' : 'st-error'); td(tr, x.source.startsWith('file:') ? '키 파일 ' + x.source.slice(5) : x.source.startsWith('upload:') ? '올린 파일 ' + x.source.slice(7) : x.source.startsWith('migrate:') ? '이전 설정에서 옮김' : '직접 입력', 'wrap'); td(tr, x.lastTest ? `${x.lastTest.ok ? '✓' : '✗'} ${x.lastTest.detail} (${dt(x.lastTest.at)})` : '-', 'wrap'); + const act = document.createElement('td'); act.className = 'act'; act.style.whiteSpace = 'nowrap'; + act.appendChild(mkBtn('수정', 'ghost', () => { $('vKind').value = x.kind; syncVaultKind(); $('vId').value = x.id; $('vLabel').value = x.label; $('vUrl').value = x.url; $('vSettings').value = Object.entries(x.settings || {}).map(([k, v]) => `${k}=${v}`).join('\n'); $('vSecret').value = ''; $('vSecret').placeholder = x.hasSecret ? `저장됨: ${x.secretHint} (바꾸려면 새 값 입력)` : '키 또는 토큰'; $('vMsg').textContent = ''; })); + act.appendChild(mkBtn('연결 확인', 'ghost', async () => { try { const r = await api(`/api/admin/credentials/${x.id}/test`, { method: 'POST', body: '{}' }); alert(`${r.ok ? '✓' : '✗'} ${r.detail}`); loadVault(); } catch (e) { alert(e.message); } })); + act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`"${x.label}" 접속 정보를 삭제할까요? 이 정보를 쓰는 기능이 멈출 수 있습니다.`)) return; try { await api(`/api/admin/credentials/${x.id}`, { method: 'DELETE' }); loadVault(); } catch (e) { alert(e.message); } })); + tr.appendChild(act); tb.appendChild(tr); + } + if (!vaultData.items.length) { const tr = document.createElement('tr'); td(tr, '저장된 접속 정보가 없습니다').colSpan = 6; tb.appendChild(tr); } +} +/** 종류를 바꾸면 식별자(고정 종류는 잠금)·키 칸 안내를 맞춘다 */ +function syncVaultKind() { const k = vaultData && vaultData.kinds[$('vKind').value]; if (!k) return; $('vId').disabled = !!k.fixedId; if (k.fixedId) $('vId').value = k.fixedId; $('vSecret').placeholder = k.secretLabel; if (!$('vUrl').value && k.defaultUrl) $('vUrl').placeholder = k.defaultUrl; } +$('vKind').onchange = () => { $('vUrl').value = ''; syncVaultKind(); }; +$('vSave').onclick = async () => { try { await api('/api/admin/credentials', { method: 'POST', body: JSON.stringify({ kind: $('vKind').value, id: $('vId').value.trim(), label: $('vLabel').value.trim(), url: $('vUrl').value.trim(), settings: $('vSettings').value, secret: $('vSecret').value }) }); $('vSecret').value = ''; $('vMsg').textContent = '암호화해 저장했습니다'; loadVault(); loadMail && loadMail(); } catch (e) { $('vMsg').textContent = e.message; } }; +/** v1.3.2: 키 파일 자체를 올려 가져오기 (multipart, 파일은 서버에 저장되지 않음) */ +$('vUploadGo').onclick = async () => { + const f = $('vUpload').files[0]; if (!f) return ($('vMsg').textContent = '올릴 키 파일을 고르세요'); + const fd = new FormData(); fd.append('kind', $('vKind').value); fd.append('id', $('vId').value.trim()); fd.append('label', $('vLabel').value.trim()); fd.append('file', f, f.name); + try { const r = await api('/api/admin/credentials/upload', { method: 'POST', body: fd }); $('vUpload').value = ''; $('vMsg').textContent = `${f.name} 에서 가져왔습니다 — 키 항목 ${r.found.key}${r.found.url ? ', 주소 ' + r.found.url : ''}${r.found.settings.length ? ', 설정 ' + r.found.settings.join(', ') : ''} (파일의 항목: ${r.fileItems.join(', ')}). 원래 파일은 서버에 저장하지 않았습니다.`; loadVault(); } catch (e) { $('vMsg').textContent = e.message; } +}; +$('vImport').onclick = async () => { const file = $('vFile').value.trim(); if (!file) return ($('vMsg').textContent = '키 파일 경로를 입력하세요'); try { const r = await api('/api/admin/credentials/import', { method: 'POST', body: JSON.stringify({ kind: $('vKind').value, id: $('vId').value.trim(), label: $('vLabel').value.trim(), file }) }); $('vMsg').textContent = `가져왔습니다 — 키 항목 ${r.found.key}${r.found.url ? ', 주소 ' + r.found.url : ''}${r.found.settings.length ? ', 설정 ' + r.found.settings.join(', ') : ''} (파일의 항목: ${r.fileItems.join(', ')}). 이제 평문 키 파일을 지워도 됩니다.`; loadVault(); } catch (e) { $('vMsg').textContent = e.message; } }; + +// ---------- 사이트 템플릿 (v1.3.10) ---------- +/** + * GET /api/admin/templates (tree·items·tags·mySites), POST 등록, PUT / 수정(사용자 사용 허용 포함), + * POST //resnapshot 다시 담기, DELETE /. 별도 디렉터리: POST/PUT/DELETE /api/admin/template-dirs[/]. + */ +let tpDir = ''; let tpData = null; let tpTimer = null; +const tpDirName = (dir) => { if (!dir) return T('전체'); if (dir.startsWith('ind:')) return dir.slice(4).split('/').map((x) => T(x)).join(' › '); const f = (nodes) => { for (const n of nodes || []) { if (n.id === dir) return n.name; const r = f(n.children); if (r) return r; } return null; }; return f(tpData && tpData.tree.custom) || dir; }; +function tpDirSelect(sel, value) { + sel.innerHTML = ''; sel.appendChild(new Option(T('디렉터리 선택'), '')); + const g1 = document.createElement('optgroup'); g1.label = T('업종'); for (const i of tpData.tree.industries) { g1.appendChild(new Option(T(i.name), i.id)); for (const m of i.children) g1.appendChild(new Option(` ${T(i.name)} › ${T(m.name)}`, m.id)); } sel.appendChild(g1); + const g2 = document.createElement('optgroup'); g2.label = T('별도 디렉터리'); const walk = (nodes, d) => { for (const n of nodes) { g2.appendChild(new Option(`${' '.repeat(d)}${n.name}`, n.id)); walk(n.children || [], d + 1); } }; walk(tpData.tree.custom, 0); sel.appendChild(g2); + if (value) sel.value = value; +} +async function loadTemplates() { + try { tpData = await api(`/api/admin/templates?q=${encodeURIComponent($('tpQ').value.trim())}&dir=${encodeURIComponent(tpDir)}`); } catch (_) { return; } + const d = tpData; $('tpSummary').textContent = `— ${d.tree.total}개 · ${T('사용자 허용')} ${d.items.filter((x) => x.enabled).length}개`; $('tpDirNow').textContent = tpDirName(tpDir); + // 디렉터리 트리 (업종은 템플릿이 있는 곳만, 별도 디렉터리는 모두) + const tr = $('tpTree'); tr.innerHTML = ''; + const node = (n, depth) => { + if (n.kind === 'industry' && !n.count && depth === 0) return; + const row = document.createElement('div'); row.className = 'tp-node' + (tpDir === n.id ? ' on' : ''); row.style.paddingLeft = (6 + depth * 14) + 'px'; + const b = document.createElement('button'); b.type = 'button'; b.className = 'link'; b.textContent = `${n.kind === 'industry' ? T(n.name) : '📁 ' + n.name} (${n.count})`; b.onclick = () => { tpDir = tpDir === n.id ? '' : n.id; loadTemplates(); }; row.appendChild(b); + if (n.kind === 'custom') { row.appendChild(mkBtn('이름', 'ghost', async () => { const nm = prompt(T('새 이름'), n.name); if (!nm) return; try { await api(`/api/admin/template-dirs/${encodeURIComponent(n.id)}`, { method: 'PUT', body: JSON.stringify({ name: nm }) }); loadTemplates(); } catch (e) { alert(e.message); } })); row.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(TF('"{1}" 디렉터리를 지울까요? (비어 있어야 지워집니다)', n.name))) return; try { await api(`/api/admin/template-dirs/${encodeURIComponent(n.id)}`, { method: 'DELETE' }); if (tpDir === n.id) tpDir = ''; loadTemplates(); } catch (e) { alert(e.message); } })); } + tr.appendChild(row); for (const c of n.children || []) if (c.count || c.kind === 'custom') node(c, depth + 1); + }; + const all = document.createElement('div'); all.className = 'tp-node' + (tpDir ? '' : ' on'); const ab = document.createElement('button'); ab.type = 'button'; ab.className = 'link'; ab.textContent = `${T('전체')} (${d.tree.total})`; ab.onclick = () => { tpDir = ''; loadTemplates(); }; all.appendChild(ab); tr.appendChild(all); + for (const n of d.tree.industries) node(n, 0); for (const n of d.tree.custom) node(n, 0); + // 별도 디렉터리 상위 선택, 등록 폼 + const par = $('tpDirParent'); par.innerHTML = ''; par.appendChild(new Option(T('최상위'), '')); const walk = (nodes, dd) => { for (const n of nodes) { par.appendChild(new Option(`${' '.repeat(dd)}${n.name}`, n.id)); walk(n.children || [], dd + 1); } }; walk(d.tree.custom, 0); + tpDirSelect($('tpDir'), $('tpDir').value || (tpDir || '')); + const ss = $('tpSite'); const keep = ss.value; ss.innerHTML = ''; ss.appendChild(new Option(d.mySites.length ? T('사이트 선택') : T('관리자가 만든 사이트가 없습니다 — 빌더에서 먼저 만드세요'), '')); for (const x of d.mySites) ss.appendChild(new Option(x.title, x.id)); ss.value = keep; + // 목록 + const tb = $('tpTable').querySelector('tbody'); tb.innerHTML = ''; + for (const t of d.items) { + const tr2 = document.createElement('tr'); td(tr2, `${t.title}${t.description ? '\n' + t.description : ''}`, 'wrap'); td(tr2, tpDirName(t.dir), 'wrap'); td(tr2, t.tags.map((g) => '#' + g).join(' '), 'wrap'); + { const c = document.createElement('td'); const lab = document.createElement('label'); lab.className = 'radio'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!t.enabled; cb.onchange = async () => { try { await api(`/api/admin/templates/${t.id}`, { method: 'PUT', body: JSON.stringify({ enabled: cb.checked }) }); loadTemplates(); } catch (e) { alert(e.message); cb.checked = !cb.checked; } }; lab.appendChild(cb); lab.appendChild(document.createTextNode(' ' + T(t.enabled ? '허용' : '허용 안 함'))); c.appendChild(lab); tr2.appendChild(c); } + td(tr2, `${t.files}`, 'num'); td(tr2, dt(t.snapshotAt)); + const act = document.createElement('td'); act.className = 'act'; act.style.whiteSpace = 'nowrap'; + const pv = document.createElement('a'); pv.className = 'btn ghost'; pv.href = `/api/templates/${t.id}/preview/`; pv.target = '_blank'; pv.rel = 'noopener'; pv.textContent = T('미리보기'); act.appendChild(pv); + act.appendChild(mkBtn('수정', 'ghost', () => tpEdit(t))); + act.appendChild(mkBtn('다시 담기', 'ghost', async () => { if (!confirm(T('원본 사이트의 지금 파일로 템플릿을 다시 담을까요? (이미 이 템플릿으로 만든 사용자 사이트는 바뀌지 않습니다)'))) return; try { await api(`/api/admin/templates/${t.id}/resnapshot`, { method: 'POST', body: '{}' }); loadTemplates(); } catch (e) { alert(e.message); } })); + act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(TF('템플릿 "{1}" 을 지울까요? (이 템플릿으로 만든 사이트는 그대로 남습니다)', t.title))) return; try { await api(`/api/admin/templates/${t.id}`, { method: 'DELETE' }); loadTemplates(); } catch (e) { alert(e.message); } })); + tr2.appendChild(act); tb.appendChild(tr2); + } + if (!d.items.length) { const tr2 = document.createElement('tr'); td(tr2, T('템플릿이 없습니다')).colSpan = 7; tb.appendChild(tr2); } +} +/** 템플릿 수정 (이름·설명·해시태그·디렉터리) — 간단한 입력 상자 */ +async function tpEdit(t) { + const title = prompt(T('템플릿 이름'), t.title); if (title === null) return; + const tags = prompt(T('해시태그 (#으로 구분)'), t.tags.map((g) => '#' + g).join(' ')); if (tags === null) return; + const desc = prompt(T('설명'), t.description || ''); if (desc === null) return; + const dir = prompt(T('디렉터리 ID (예: ind:음식점·카페/한식 또는 dir:…) — 비우면 그대로'), t.dir); if (dir === null) return; + try { await api(`/api/admin/templates/${t.id}`, { method: 'PUT', body: JSON.stringify({ title, tags, description: desc, ...(dir.trim() ? { dir: dir.trim() } : {}) }) }); loadTemplates(); } catch (e) { alert(e.message); } +} +$('tpQ').oninput = () => { clearTimeout(tpTimer); tpTimer = setTimeout(loadTemplates, 250); }; +$('tpDirAdd').onclick = async () => { try { await api('/api/admin/template-dirs', { method: 'POST', body: JSON.stringify({ name: $('tpDirName').value, parent: $('tpDirParent').value }) }); $('tpDirName').value = ''; loadTemplates(); } catch (e) { alert(e.message); } }; +$('tpSite').onchange = () => { const x = tpData && tpData.mySites.find((y) => y.id === $('tpSite').value); if (x && !$('tpTitle').value) $('tpTitle').value = x.title; }; +$('tpRegister').onclick = async () => { $('tpMsg').textContent = T('담는 중…'); try { const t = await api('/api/admin/templates', { method: 'POST', body: JSON.stringify({ siteId: $('tpSite').value, title: $('tpTitle').value, dir: $('tpDir').value, tags: $('tpTags').value, description: $('tpDesc').value }) }); $('tpMsg').textContent = TF('등록했습니다: {1} (파일 {2}개) — 사용자에게 보이려면 "사용자 사용 허용" 을 켜세요', t.title, t.files); $('tpTitle').value = ''; $('tpTags').value = ''; $('tpDesc').value = ''; loadTemplates(); } catch (e) { $('tpMsg').textContent = e.message; } }; + +// ---------- 검색 등록 (v1.3.5) ---------- +/** + * GET /api/admin/search — 등록을 시작한 사이트와 엔진별 단계. 동작은 POST /api/admin/search/<사이트ID>/<동작> + * (start|code|apply|step|check|indexnow|stop, 사용자 관리 탭과 같은 서버 함수 searchAction). + */ +async function loadSearchRegs() { + let d; try { d = await api('/api/admin/search'); } catch (_) { return; } + // v1.3.10: 사람이 해야 하는 단계(선택 단계 제외)만 "수동 작업" 으로 센다 — 구글 서비스 계정 키가 있으면 구글은 모두 자동 + const pendingOf = (it) => it.engines.filter((e) => e.started && e.steps.some((s) => !s.done && s.actor === 'manual' && !s.optional)).length; + const total = d.items.reduce((a, it) => a + pendingOf(it), 0); $('srBadge').textContent = total || ''; $('srBadge').classList.toggle('hidden', !total); + $('srSummary').textContent = `— 등록 진행 ${d.items.length}개 사이트 · 수동 작업 대기 ${total}건`; + const box = $('srList'); box.innerHTML = ''; + const items = $('srPendingOnly').checked ? d.items.filter((it) => pendingOf(it)) : d.items; + for (const it of items) { + const card = document.createElement('div'); card.className = 'card'; card.style.marginBottom = '10px'; + const h = document.createElement('h3'); h.textContent = `${it.title} — ${it.ownerName || ''} (${it.ownerId})`; card.appendChild(h); + const info = document.createElement('p'); info.className = 'muted'; info.textContent = it.availability.ok ? `운영 주소 ${it.liveUrl}` : `진행 불가: ${it.availability.reason}`; card.appendChild(info); + for (const e of it.engines.filter((x) => x.started)) { + const eb = document.createElement('div'); eb.style.margin = '6px 0 10px'; + const t = document.createElement('b'); t.textContent = `${T(e.label)} — ${T(e.state)}`; eb.appendChild(t); + if (e.auto) { const ap = document.createElement('p'); ap.className = e.auto.state === 'error' ? 'msg err' : 'muted'; ap.textContent = `${T('자동 진행')}: ${T(e.auto.detail)}`; eb.appendChild(ap); if (e.auto.state === 'error' && it.availability.ok) eb.appendChild(mkBtn(T('자동 진행 다시'), 'primary', async () => { try { await api(`/api/admin/search/${it.id}/auto`, { method: 'POST', body: JSON.stringify({ engine: e.engine }) }); loadSearchRegs(); } catch (er) { alert(er.message); } })); } + const ol = document.createElement('ol'); ol.style.margin = '4px 0'; + for (const st of e.steps) { + if (st.actor === 'auto' && !$('srShowAuto').checked) continue; // v1.3.10 기본은 사람이 할 단계만 보인다 + const li = document.createElement('li'); li.style.margin = '4px 0'; li.style.opacity = st.done ? '.6' : '1'; + li.appendChild(document.createTextNode(`${st.done ? '✓ ' : ''}[${st.actor === 'auto' ? '자동' : '직접'}] ${st.title} — ${st.detail}`)); + const row = document.createElement('div'); row.className = 'row'; + const run = (action, body) => async () => { try { const r = await api(`/api/admin/search/${it.id}/${action}`, { method: 'POST', body: JSON.stringify({ engine: e.engine, ...body }) }); if (r.check) alert(`${r.check.ok ? '✓' : '✗'} ${r.check.detail}`); loadSearchRegs(); } catch (er) { alert(er.message); } }; + if (st.id === 'code' && st.actor === 'manual') { const inp = document.createElement('input'); inp.placeholder = '확인 코드 또는 meta 태그'; inp.value = e.code || ''; inp.style.flex = '1'; inp.style.margin = '0'; row.appendChild(inp); row.appendChild(mkBtn('저장', 'ghost', async () => { try { await api(`/api/admin/search/${it.id}/code`, { method: 'POST', body: JSON.stringify({ engine: e.engine, code: inp.value }) }); loadSearchRegs(); } catch (er) { alert(er.message); } })); } + if (st.id === 'apply' && e.code) row.appendChild(mkBtn(st.done ? '다시 넣고 재배포' : '태그 넣고 재배포', 'primary', run('apply', {}))); + if (st.id === 'dns' && e.code) row.appendChild(mkBtn('DNS 확인', 'ghost', run('check', {}))); + if (st.id === 'verify' && e.method !== 'dns' && e.code) row.appendChild(mkBtn('운영 페이지 태그 확인', 'ghost', run('check', {}))); + if (['verify', 'sitemap', 'dns'].includes(st.id) && st.actor === 'manual') row.appendChild(mkBtn(st.done ? '완료 취소' : '완료 표시', 'ghost', run('step', { step: st.id, done: !st.done }))); + if (row.children.length && it.availability.ok) li.appendChild(row); ol.appendChild(li); + } + eb.appendChild(ol); const a = document.createElement('a'); a.className = 'btn ghost'; a.href = e.console; a.target = '_blank'; a.rel = 'noopener'; a.textContent = `${e.label} 열기`; eb.appendChild(a); card.appendChild(eb); + } + if (it.indexnow) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = `IndexNow: ${it.indexnow.last ? `${dt(it.indexnow.lastPingAt)} ${it.indexnow.last.detail}` : '키 준비됨'}`; card.appendChild(p); } + box.appendChild(card); + } + if (!items.length) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = $('srPendingOnly').checked ? '수동 작업이 남은 사이트가 없습니다' : '검색 등록을 시작한 사이트가 없습니다'; box.appendChild(p); } +} +$('srPendingOnly').onchange = loadSearchRegs; $('srShowAuto').onchange = loadSearchRegs; + +// ---------- 메일 시스템 (v1.3.2: 사이트 단위) ---------- +/** + * 메일 시스템 탭. 사이트(호스팅 도메인)마다 "메일 시스템 사용" 을 켜고 설정한다. + * GET /api/admin/mail 기본값·발송 백엔드·사이트 목록(상태) + * GET /api/admin/mail/ 한 사이트의 설정·DNS 레코드·확인 결과 + * POST /api/admin/mail/ 켜기/설정 저장 {domain, mxHost, spfInclude, dmarc} + * POST /api/admin/mail//check DNS 다시 확인, POST /api/admin/mail//disable 끄기 + * 사용자 화면에는 DNS 전파·보내기 인증이 끝나기 전까지 "메일시스템 설정중", 끝나면 "사용 가능" 으로 보인다. + */ +let msCur = null; // 지금 패널에서 설정 중인 사이트 ID +/** + * v1.6.38 (요구 2): 메일 서버(Arimail) 등록부 표 — 닉네임·IP·주소·도메인·연결 확인·기본. 기본 서버(설정 파일 mail.*)는 id 'primary'. + * 비밀번호는 서버가 hasPassword 만 내려주므로 화면에 나오지 않는다. + */ +let mvServers = []; let mvEditing = null; +function drawMailServers(list) { + mvServers = list || []; const tb = $('mvTable').querySelector('tbody'); tb.innerHTML = ''; + for (const s of mvServers) { + const tr = document.createElement('tr'); const locked = s.lockedUntil && Date.parse(s.lockedUntil) > Date.now(); + td(tr, `${s.nickname}${s.source === 'config' ? ' (설정 파일)' : ''}`, 'wrap'); td(tr, s.ip || '-'); td(tr, `${s.url || '-'}${s.apiUrl && s.apiUrl !== s.url ? ` / ${s.apiUrl}` : ''}`, 'wrap'); td(tr, s.domain || '-'); + // v1.6.39 (요구 4): 관리자 ID 칸 — 저장된 비밀번호를 [보기/숨기기] 로 확인하고 복사한다 (초기 설정 뒤 Arimail /admin 에 직접 들어갈 때 필요). 서버에서 받아 60초 뒤 자동으로 숨긴다 + const ac = document.createElement('div'); ac.style.whiteSpace = 'nowrap'; ac.appendChild(document.createTextNode(s.adminLogin + (s.hasPassword ? ' ' : ' (비밀번호 없음) '))); + if (s.hasPassword) { + const code = document.createElement('code'); code.className = 'mv-pw hidden'; code.style.userSelect = 'all'; let hideT = null; + const show = mkBtn('보기', 'ghost small', async () => { + if (!code.classList.contains('hidden')) { code.classList.add('hidden'); code.textContent = ''; show.textContent = '보기'; clearTimeout(hideT); return; } + try { const r = await api(`/api/admin/mail-servers/${s.id}/password`); code.textContent = r.password; code.classList.remove('hidden'); show.textContent = '숨기기'; hideT = setTimeout(() => { code.classList.add('hidden'); code.textContent = ''; show.textContent = '보기'; }, 60000); } + catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; } + }); + const copy = mkBtn('복사', 'ghost small', async () => { try { const r = await api(`/api/admin/mail-servers/${s.id}/password`); await navigator.clipboard.writeText(r.password); $('mvMsg').textContent = `${s.nickname}: 관리자 비밀번호를 복사했습니다 — ${r.adminUrl || 'Arimail /admin'} 에 ${r.adminLogin} 으로 로그인`; $('mvMsg').className = 'muted ok'; } catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; } }); + ac.appendChild(show); ac.appendChild(document.createTextNode(' ')); ac.appendChild(code); ac.appendChild(document.createTextNode(' ')); ac.appendChild(copy); + } + td(tr, ac, s.hasPassword ? 'wrap' : 'st-cancelled wrap'); + // 연결 확인 칸 — v1.6.39 (요구 2): 잠금 중이면 해제 시각과 남은 시간을 보여 준다 (mvTick 이 매초 갱신) + const tc = document.createElement('div'); + if (locked) { tc.className = 'st-error'; tc.dataset.lockUntil = s.lockedUntil; tc.textContent = mvLockText(s.lockedUntil); } + else tc.textContent = s.lastTest ? `${s.lastTest.ok ? '성공' : '실패'} · ${dt(s.lastTest.at)}${s.lastTest.ok ? '' : ' — ' + (s.lastTest.detail || '')}` : '-'; + // v1.7.3 추가: 웹메이커 관리자 ↔ Arimail 같은 ID 계정 비밀번호 동기화 기록(관리자 로그인 때 맞춰진다) + for (const a of (s.adminSync || [])) { const d = document.createElement('div'); d.className = 'small ' + (a.error ? 'st-error' : (a.at ? 'muted' : 'muted')); d.textContent = a.error ? `관리자 ${a.login} 비밀번호 동기화 실패 ${dt(a.errorAt)} — ${a.error}` : (a.at ? `관리자 ${a.login} 비밀번호 동기화 ✓ ${dt(a.at)}` : (a.skipped === 'no-account' ? `관리자 ${a.login}: Arimail 에 같은 ID 계정 없음(동기화 생략)` : '')); if (d.textContent) tc.appendChild(d); } + td(tr, tc, locked ? 'wrap' : (s.lastTest ? (s.lastTest.ok ? 'st-done wrap' : 'st-error wrap') : '')); + // v1.7.3 (요구 2): 사용량 API 칸 — 키 유무(앞뒤 글자)와 [사용량](모든 도메인의 메일함 용량·계정·발송량 표) · [키 발급](저장된 관리자 계정으로 Arimail 에서 받아 보관) · [키 삭제] + const uc = document.createElement('div'); uc.style.whiteSpace = 'nowrap'; + uc.appendChild(document.createTextNode(s.hasUsageKey ? `키 ${s.usageKeyHint || '있음'} ` : '키 없음 ')); + if (s.hasUsageKey) uc.appendChild(mkBtn('사용량', 'ghost small', () => loadMvUsage(s, false))); + uc.appendChild(mkBtn(s.hasUsageKey ? '키 다시 발급' : '키 발급', 'ghost small', () => mvIssueUsageKey(s))); + if (s.hasUsageKey) uc.appendChild(mkBtn('키 삭제', 'ghost small', async () => { if (!confirm(`"${s.nickname}" 에 보관한 사용량 API 키를 지울까요?\nArimail 쪽 키는 그대로이며 메일 사용량 표시만 꺼집니다.`)) return; try { const r = await api(`/api/admin/mail-servers/${s.id}/usage-key`, { method: 'POST', body: JSON.stringify({ action: 'clear' }) }); drawMailServers(r.servers); $('mvMsg').textContent = `${s.nickname}: ${r.detail}`; $('mvMsg').className = 'muted'; $('mvUsage').classList.add('hidden'); } catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; } })); + td(tr, uc, s.hasUsageKey ? 'wrap' : 'st-cancelled wrap'); + td(tr, s.isDefault ? '기본' : '', s.isDefault ? 'st-done' : ''); + const act = document.createElement('td'); act.className = 'act'; act.style.whiteSpace = 'nowrap'; + const tb2 = mkBtn('연결 확인', 'ghost', async () => { $('mvMsg').textContent = `${s.nickname}: 확인 중…`; try { const r = await api(`/api/admin/mail-servers/${s.id}/test`, { method: 'POST', body: '{}' }); $('mvMsg').textContent = `${s.nickname}: ${r.detail}`; $('mvMsg').className = r.ok ? 'muted ok' : 'msg err'; drawMailServers(r.servers); } catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; } }); + if (locked) { tb2.disabled = true; tb2.dataset.lockUntil = s.lockedUntil; tb2.title = `Arimail 로그인 잠금 — ${mvLockText(s.lockedUntil)}. 그 전에 시도하면 잠금이 길어집니다`; } + act.appendChild(tb2); + act.appendChild(mkBtn('수정', 'ghost', () => openMvForm(s))); + if (!s.isDefault) act.appendChild(mkBtn('기본으로', 'ghost', async () => { try { const r = await api(`/api/admin/mail-servers/${s.id}/default`, { method: 'POST', body: '{}' }); drawMailServers(r.servers); $('mvMsg').textContent = `${s.nickname} 을(를) 기본 메일 서버로 정했습니다`; $('mvMsg').className = 'muted ok'; loadMailSys(); } catch (e) { $('mvMsg').textContent = e.message; $('mvMsg').className = 'msg err'; } })); + // v1.6.39 (요구 2): 비밀번호를 바꾼 뒤 보관된 직전 값으로 되돌리기 (한 번 더 누르면 다시 바뀜 — 맞바꾸기) + if (s.hasPrevPassword) act.appendChild(mkBtn('이전 비밀번호로', 'ghost', async () => { + if (!confirm(`"${s.nickname}" 의 관리자 비밀번호를 ${s.prevPasswordAt ? dt(s.prevPasswordAt) + ' 에 바꾸기 전' : '이전'} 값으로 되돌릴까요?\n지금 값은 "이전 비밀번호" 로 보관되어 다시 되돌릴 수 있습니다.`)) return; + try { const r = await api(`/api/admin/mail-servers/${s.id}/revert-password`, { method: 'POST', body: '{}' }); drawMailServers(r.servers); $('mvMsg').textContent = `${s.nickname}: 이전 비밀번호로 되돌렸습니다${r.server.lockedUntil ? ' — 잠금이 풀린 뒤 "연결 확인" 을 누르세요' : '. "연결 확인" 으로 로그인되는지 확인하세요'}`; $('mvMsg').className = 'muted ok'; } catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; } + })); + // v1.6.39 (요구 4): 연동 관리자 계정 비밀번호 초기화 (새 무작위 비밀번호 → Arimail 에 설정 → 보관) + act.appendChild(mkBtn('비밀번호 초기화', 'ghost', () => mvReset(s))); + if (s.source !== 'config') act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`메일 서버 "${s.nickname}" 등록을 지울까요?\nArimail 서버 자체와 그 안의 계정·메일은 그대로이며, 이 서버를 고른 사이트는 자동 선택(도메인이 같은 서버 → 기본 서버)으로 돌아갑니다.`)) return; try { const r = await api(`/api/admin/mail-servers/${s.id}`, { method: 'DELETE' }); drawMailServers(r.servers); $('mvMsg').textContent = '지웠습니다'; $('mvMsg').className = 'muted'; loadMailSys(); } catch (e) { $('mvMsg').textContent = e.message; $('mvMsg').className = 'msg err'; } })); + tr.appendChild(act); tb.appendChild(tr); + } + if (!mvServers.length) { const tr = document.createElement('tr'); td(tr, '등록된 메일 서버가 없습니다 — "메일 서버 추가" 로 다른 서버에 설치된 Arimail 을 등록하거나, 이 서버에서 deploy/install-arimail.sh 를 실행하세요').colSpan = 8; tb.appendChild(tr); } +} +/** v1.6.39: 잠금 안내 문구 — "잠금 · 14:52 까지 (남은 12:31)" */ +function mvLockText(untilIso) { + const left = Math.max(0, Math.round((Date.parse(untilIso) - Date.now()) / 1000)); const t = new Date(untilIso); + return `Arimail 로그인 잠금 · ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')} 까지 (남은 ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')})`; +} +// 매초: 잠금 남은 시간을 갱신하고, 해제 시각이 지나면 표를 다시 그려 "연결 확인" 을 다시 켠다 +setInterval(() => { + const els = document.querySelectorAll('[data-lock-until]'); if (!els.length) return; let expired = false; + for (const e of els) { if (Date.parse(e.dataset.lockUntil) <= Date.now()) expired = true; else if (e.tagName !== 'BUTTON') e.textContent = mvLockText(e.dataset.lockUntil); } + if (expired) drawMailServers(mvServers.map((s) => ({ ...s, lockedUntil: s.lockedUntil && Date.parse(s.lockedUntil) > Date.now() ? s.lockedUntil : null }))); +}, 1000); +/** + * v1.6.39 (요구 4): 비밀번호 초기화. 먼저 저장된 비밀번호로 시도하고, 그 로그인이 안 되면(비밀번호를 잘못 저장한 경우) Arimail 의 다른 관리자 계정(초기 admin 등)으로 + * 한 번 로그인해 바꾸는 폼(#mvReset)을 연다. 새 비밀번호는 응답을 한 번 보여 주고(표의 "보기" 로 다시 볼 수 있음) 이전 값은 "이전 비밀번호로" 되돌릴 수 있다. + */ +let mvResetTarget = null; +async function mvReset(s, via) { + if (!via && !confirm(`"${s.nickname}" 의 연동 관리자 계정 "${s.adminLogin}" 비밀번호를 새 무작위 값으로 바꿀까요?\n\nArimail 의 그 계정 비밀번호가 실제로 바뀌고 웹메이커 보관소에 저장됩니다. 이전 값은 "이전 비밀번호로" 되돌릴 수 있습니다.`)) return; + $('mvMsg').textContent = `${s.nickname}: 비밀번호 초기화 중…`; $('mvMsg').className = 'muted'; + try { + const r = await api(`/api/admin/mail-servers/${s.id}/reset-password`, { method: 'POST', body: JSON.stringify(via ? { via } : {}) }); + drawMailServers(r.servers); $('mvReset').classList.add('hidden'); mvResetTarget = null; + $('mvMsg').innerHTML = ''; const b = document.createElement('b'); b.textContent = `${s.nickname}: ${r.how} 비밀번호를 초기화했습니다${r.created ? ' (계정이 없어 새로 만들었습니다)' : ''}. `; $('mvMsg').appendChild(b); + const code = document.createElement('code'); code.style.userSelect = 'all'; code.textContent = r.password; $('mvMsg').appendChild(document.createTextNode(`새 비밀번호: `)); $('mvMsg').appendChild(code); + $('mvMsg').appendChild(document.createTextNode(` — ${r.server.adminUrl || 'Arimail /admin'} 에 ${s.adminLogin} 으로 로그인할 수 있습니다. 표의 "보기" 로 다시 볼 수 있습니다.${r.test ? (r.test.ok ? ' 연결 확인 성공.' : ` 연결 확인: ${r.test.detail}`) : ''}`)); + $('mvMsg').className = 'muted ok'; + } catch (e) { + $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; + if (!via && /로그인 실패|저장된 비밀번호가 없어/.test(e.message)) { mvResetTarget = s; $('mvReset').classList.remove('hidden'); $('mvRvTitle').textContent = `다른 관리자 계정으로 재설정 — ${s.nickname} (${s.adminLogin})`; $('mvRvLogin').value = ''; $('mvRvPw').value = ''; $('mvRvMsg').textContent = ''; $('mvRvLogin').focus(); } + } +} +$('mvRvGo').onclick = () => { if (!mvResetTarget) return; const login = $('mvRvLogin').value.trim(), password = $('mvRvPw').value; if (!login || !password) { $('mvRvMsg').textContent = 'Arimail 관리자 아이디와 비밀번호를 입력하세요'; return; } $('mvRvMsg').textContent = ''; mvReset(mvResetTarget, { login, password }); }; +$('mvRvCancel').onclick = () => { $('mvReset').classList.add('hidden'); mvResetTarget = null; }; +/** v1.7.3: 바이트 표시 (관리자 콘솔은 app.js 의 fmtBytes 를 쓰지 않으므로 여기서) */ +const mvBytes = (n) => { n = Number(n) || 0; if (n < 1024) return `${n} B`; const u = ['KB', 'MB', 'GB', 'TB']; let i = -1; do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1); return `${n < 10 ? n.toFixed(2) : n < 100 ? n.toFixed(1) : Math.round(n)} ${u[i]}`; }; +/** + * v1.7.3 (요구 2): 사용량 API 키 발급 — 서버가 저장된 관리자 계정으로 Arimail 에 로그인해 POST /api/setup/usage-key 로 키를 받아 보관한다(키는 화면에 오지 않는다). + * 이미 키가 있으면 Arimail 쪽 키가 바뀌어 이전 키는 즉시 무효 — 확인 창에 적는다. + */ +async function mvIssueUsageKey(s) { + if (!confirm(`"${s.nickname}" 의 Arimail 에 저장된 관리자 계정(${s.adminLogin})으로 로그인해 사용량 API 키를 ${s.hasUsageKey ? '다시 ' : ''}발급받아 보관합니다.${s.hasUsageKey ? '\n기존 키는 Arimail 에서 즉시 무효가 됩니다.' : ''}\n조건: Arimail 1.0.9 이상, 등록 계정이 기본 도메인의 관리자. 계속할까요?`)) return; + $('mvMsg').textContent = `${s.nickname}: 키 발급 중…`; $('mvMsg').className = 'muted'; + try { const r = await api(`/api/admin/mail-servers/${s.id}/usage-key`, { method: 'POST', body: JSON.stringify({ action: 'issue' }) }); drawMailServers(r.servers); $('mvMsg').textContent = `${s.nickname}: ${r.detail}`; $('mvMsg').className = 'muted ok'; if (r.usage) renderMvUsage(r.server || s, r.usage); } + catch (e) { $('mvMsg').textContent = `${s.nickname}: ${e.message}`; $('mvMsg').className = 'msg err'; } +} +/** 사용량 표 열기 — GET /api/admin/mail-servers//usage (refresh 면 캐시 무시) */ +async function loadMvUsage(s, refresh) { + $('mvUsage').classList.remove('hidden'); $('mvUsageTitle').textContent = `메일 사용량 — ${s.nickname}`; $('mvUsageMsg').textContent = '불러오는 중…'; $('mvUsageMsg').className = 'muted'; + try { const r = await api(`/api/admin/mail-servers/${s.id}/usage${refresh ? '?refresh=1' : ''}`); renderMvUsage(s, r); } catch (e) { $('mvUsageMsg').textContent = e.message; $('mvUsageMsg').className = 'msg err'; } +} +/** 사용량 표 그리기 — 합계 한 줄 + 도메인별(계정 / 메일함 용량 / 메일 수 / 오늘·이번 주·이달 발송/수신) */ +function renderMvUsage(s, u) { + $('mvUsage').classList.remove('hidden'); $('mvUsageTitle').textContent = `메일 사용량 — ${s.nickname}`; $('mvUsageRefresh').onclick = () => loadMvUsage(s, true); + const tb = $('mvUsageTable').querySelector('tbody'); tb.innerHTML = ''; + if (!u.ok) { $('mvUsageMsg').textContent = `받지 못함 — ${u.error ? u.error.message : '알 수 없는 오류'}`; $('mvUsageMsg').className = 'msg err'; return; } + const t = u.totals; + $('mvUsageMsg').textContent = `${dt(u.at)}${u.cached ? ' (캐시)' : ''} · Arimail ${u.version || ''} · 도메인 ${t.domains}개 · 계정 ${t.accounts}개 · 메일함 ${mvBytes(t.storageBytes)}${t.largeFileBytes ? ` (+대용량 첨부 ${mvBytes(t.largeFileBytes)})` : ''} · 메일 ${t.messages.toLocaleString()}통 · 이달 발송 ${t.sentMonth.toLocaleString()} / 수신 ${t.receivedMonth.toLocaleString()}`; $('mvUsageMsg').className = 'muted'; + for (const d of u.domains) { + const tr = document.createElement('tr'); + if (d.error) { td(tr, d.domain, 'wrap'); td(tr, `조회 실패 (${d.error})`, 'st-error wrap').colSpan = 7; tb.appendChild(tr); continue; } + td(tr, `${d.domain}${d.primary ? ' (기본)' : ''}${d.enabled ? '' : ' · 중지'}`, 'wrap'); + td(tr, `${d.accounts.total}${d.accounts.limit ? ` / ${d.accounts.limit}` : ''}${d.accounts.external ? ` (웹메이커 ${d.accounts.external})` : ''}`, 'num'); + td(tr, `${mvBytes(d.storage.bytes)}${d.storage.limit ? ` / ${mvBytes(d.storage.limit)} (${d.storage.percent != null ? d.storage.percent : '-'}%)` : ''}`, 'num'); + td(tr, d.messages.total.toLocaleString(), 'num'); + td(tr, `${d.traffic.day.sent} / ${d.traffic.day.received}`, 'num'); td(tr, `${d.traffic.week.sent} / ${d.traffic.week.received}`, 'num'); + td(tr, `${d.traffic.month.sent} / ${d.traffic.month.received}${d.traffic.monthlyLimit ? ` (한도 ${d.traffic.monthlyLimit})` : ''}`, 'num'); td(tr, `${mvBytes(d.traffic.month.sentBytes)} / ${mvBytes(d.traffic.month.receivedBytes)}`, 'num'); + tb.appendChild(tr); + } + if (!u.domains.length) { const tr = document.createElement('tr'); td(tr, '도메인이 없습니다').colSpan = 8; tb.appendChild(tr); } +} +$('mvUsageClose').onclick = () => $('mvUsage').classList.add('hidden'); +/** 추가·수정 폼 열기 (s 가 있으면 수정) */ +function openMvForm(s) { + mvEditing = s || null; $('mvForm').classList.remove('hidden'); $('mvFormTitle').textContent = s ? `메일 서버 수정 — ${s.nickname}` : '메일 서버 추가'; + $('mvNick').value = s ? (s.source === 'config' && /\(설정 파일\)$/.test(s.nickname) ? '' : s.nickname) : ''; $('mvIp').value = s ? s.ip || '' : ''; $('mvUrl').value = s ? s.url || '' : ''; $('mvApi').value = s ? (s.apiUrl && s.apiUrl !== s.url ? s.apiUrl : '') : ''; $('mvDomain').value = s ? s.domain || '' : ''; $('mvLogin').value = s ? s.adminLogin || '' : 'admin'; $('mvPw').value = ''; + $('mvPwNote').textContent = s ? (s.hasPassword ? '(비우면 기존 비밀번호 유지)' : '(저장된 비밀번호가 없습니다 — 입력하세요)') : '(새 서버는 필수)'; $('mvFormMsg').textContent = ''; + $('mvUsageKey').value = ''; $('mvUkNote').textContent = s ? (s.hasUsageKey ? `(보관 중 ${s.usageKeyHint || ''} — 비우면 유지)` : '(없음 — 붙여 넣거나 표의 "키 발급")') : '(선택 — 저장 뒤 표의 "키 발급" 으로 받을 수도 있음)'; // v1.7.3 +} +$('mvAdd').onclick = () => openMvForm(null); +$('mvCancel').onclick = () => { $('mvForm').classList.add('hidden'); mvEditing = null; }; +$('mvSave').onclick = async () => { + const body = { nickname: $('mvNick').value.trim(), ip: $('mvIp').value.trim(), url: $('mvUrl').value.trim(), apiUrl: $('mvApi').value.trim(), domain: $('mvDomain').value.trim(), adminLogin: $('mvLogin').value.trim() }; if ($('mvPw').value) body.password = $('mvPw').value; + if ($('mvUsageKey').value.trim()) body.usageKey = $('mvUsageKey').value.trim(); // v1.7.3: 사용량 API 키(비우면 유지) + // v1.6.39 (요구 2): 저장된 비밀번호가 있는 서버의 비밀번호 칸에 값이 있으면 덮어쓰기 전에 확인 — 2026-10-21 운영 서버에서 동작하던 비밀번호를 실수로 덮어쓴 일의 재발 방지 + if (mvEditing && mvEditing.hasPassword && body.password && !confirm(`"${mvEditing.nickname}" 의 저장된 Arimail 관리자 비밀번호를 지금 입력한 값으로 바꿉니다.\n\n틀린 값으로 바꾸면 메일 계정 관리·연결 확인이 실패합니다(여러 번 실패하면 Arimail 이 15분간 로그인을 막습니다). 비밀번호를 그대로 두려면 "취소" 를 누르고 칸을 비우세요.\n바꾼 뒤에는 표의 "이전 비밀번호로" 로 되돌릴 수 있습니다.\n\n계속할까요?`)) { $('mvFormMsg').textContent = '비밀번호를 바꾸지 않았습니다 (저장 취소)'; $('mvFormMsg').className = 'muted'; return; } + $('mvFormMsg').textContent = '저장 중…'; + try { const r = mvEditing ? await api(`/api/admin/mail-servers/${mvEditing.id}`, { method: 'PUT', body: JSON.stringify(body) }) : await api('/api/admin/mail-servers', { method: 'POST', body: JSON.stringify(body) }); $('mvPw').value = ''; $('mvUsageKey').value = ''; drawMailServers(r.servers); $('mvFormMsg').textContent = `저장했습니다 — ${r.server.nickname}${r.server.ip ? ' (' + r.server.ip + ')' : ''}. "연결 확인" 으로 Arimail 로그인까지 확인하세요`; $('mvFormMsg').className = 'muted ok'; mvEditing = r.server; $('mvFormTitle').textContent = `메일 서버 수정 — ${r.server.nickname}`; $('mvPwNote').textContent = '(비우면 기존 비밀번호 유지)'; loadMailSys(); } + catch (e) { $('mvFormMsg').textContent = e.message; $('mvFormMsg').className = 'msg err'; } +}; +async function loadMailSys() { + let v; try { v = await api('/api/admin/mail'); } catch (_) { return; } + const c = v.config; $('msDefMx').value = c.mxHost || ''; $('msDefSpf').value = c.spfInclude || ''; $('msDefDmarc').value = c.dmarc || ''; + drawMailServers(v.servers || []); // v1.6.38 + // v1.5.7: 받는 메일은 연동 모듈(Arimail) 상태로 — 연결되어 있으면 주소·도메인·버전, 아니면 연결 방법(시스템 설정 탭) + const am = v.backend.arimail; + $('msBackend').textContent = `보내기: ${v.backend.canSend ? '가능 — 메일 발송 키(Resend) 사용, 메일 도메인이 Resend 에서 확인되면 발송' : '불가 — 설정 탭 › 외부 연동 접속 정보에 메일 발송(Resend) 키를 넣으세요'} · 받는 메일·웹메일(기본 서버): ${am ? (am.ok ? `Arimail ${am.version || ''} 연결됨 (${am.nickname || am.host}${am.ip ? ' ' + am.ip : ''} · ${am.host}, 도메인 ${am.domain}) — 메일 도메인이 ${am.domain} 인 사이트에 적용` : `Arimail 설정은 있지만 응답이 없습니다 (${am.detail})`) : '메일 서버(Arimail) 미연결 — 아래 "메일 서버 추가" 로 등록하거나(다른 서버에 설치된 Arimail 도 가능) 이 서버에서 deploy/install-arimail.sh 를 실행'}`; + const tb = $('msSites').querySelector('tbody'); tb.innerHTML = ''; + for (const x of v.sites) { + const tr = document.createElement('tr'); td(tr, x.title, 'wrap'); td(tr, `${x.ownerName || ''} (${x.ownerId || '-'})`, 'wrap'); td(tr, x.domains.join(', ') || '-', 'wrap'); td(tr, x.domain || '-'); + const st = document.createElement('span'); st.className = 'ms-pill ' + x.level; st.textContent = x.label; st.title = x.pending && x.pending.length ? `미확인: ${x.pending.join(', ')}` : ''; td(tr, st); + td(tr, x.enabled ? `${x.accounts}개` : '-', 'num'); + const act = document.createElement('td'); act.className = 'act'; act.style.whiteSpace = 'nowrap'; + act.appendChild(mkBtn(x.enabled ? '설정' : '메일 시스템 사용', x.enabled ? 'ghost' : 'primary', () => openMailPanel(x.id))); + tr.appendChild(act); tb.appendChild(tr); + } + if (!v.sites.length) { const tr = document.createElement('tr'); td(tr, '사이트가 없습니다').colSpan = 7; tb.appendChild(tr); } + if (msCur) openMailPanel(msCur, true); +} +/** 사이트 메일 설정 패널 열기 (keep: 새로고침 때 입력값을 덮어쓰지 않음) */ +async function openMailPanel(id, keep) { + let d; try { d = await api(`/api/admin/mail/${id}`); } catch (e) { return alert(e.message); } + msCur = id; $('msPanel').classList.remove('hidden'); $('msPanelTitle').textContent = `${d.title} — ${d.enabled ? d.label : '메일 시스템 사용 설정'}`; + const dl = $('msDomainList'); dl.innerHTML = ''; for (const dm of d.domains) dl.appendChild(new Option(dm, dm)); + // v1.6.38 (요구 2): 메일 서버 선택 — 자동(메일 도메인이 같은 서버 → 기본 서버) 또는 등록부의 서버(닉네임 — IP (도메인)) + const ss = $('msServer'); const curSrv = keep ? ss.value : (d.mailServer || ''); ss.innerHTML = ''; ss.appendChild(new Option('자동 — 메일 도메인이 같은 서버, 없으면 기본 서버', '')); + for (const sv of mvServers) ss.appendChild(new Option(sv.label + (sv.isDefault ? ' · 기본' : '') + (sv.hasPassword ? '' : ' · 비밀번호 없음'), sv.id)); ss.value = mvServers.some((x) => x.id === curSrv) ? curSrv : ''; + $('msServerNote').textContent = d.pickedServer ? `(지금 쓰일 서버: ${d.pickedServer.label})` : '(등록된 메일 서버가 없습니다)'; + if (!keep) { $('msDomain').value = d.domain || d.domains[0] || ''; $('msMx').value = (d.settings && d.settings.mxHost) || ''; $('msSpf').value = (d.settings && d.settings.spfInclude) || ''; $('msDmarc').value = (d.settings && d.settings.dmarc) || ''; $('msMsg').textContent = ''; } + $('msMxDefault').textContent = d.effective && d.effective.mxHost ? `(기본값: ${d.effective.mxHost})` : '(기본값 없음 — 꼭 입력)'; + $('msApply').textContent = d.enabled ? '설정 저장' : '메일 시스템 사용 · 설정 저장'; + $('msCheck').disabled = !d.enabled; $('msOff').classList.toggle('hidden', !d.enabled); + const st = d.status; const box = $('msStatus'); box.innerHTML = ''; + const line = (k, v) => { const p = document.createElement('div'); const b = document.createElement('b'); b.textContent = k + ' '; p.appendChild(b); p.appendChild(document.createTextNode(v)); box.appendChild(p); }; + line('사용자 화면 표시:', st.label); line('상태:', st.message); + // v1.7.3 (요구 2): 메일 사용량 — 이 사이트 메일 도메인의 메일함 용량·계정·메일 수·이달 발송/수신 (Arimail 사용량 API). 미연동이면 이유 + if (d.usage) { const u = d.usage; line('메일 사용량:', u.ok ? `메일함 ${mvBytes(u.storage.bytes)}${u.storage.limit ? ` / ${mvBytes(u.storage.limit)}` : ''} · 계정 ${u.accounts.total}개(이 사이트 ${u.accounts.mine}개, ${mvBytes(u.myBytes || 0)}) · 메일 ${u.messages.total.toLocaleString()}통 · 이달 발송 ${u.traffic.month.sent} / 수신 ${u.traffic.month.received} — ${u.server ? u.server.nickname : ''} ${dt(u.at)}${u.cached ? ' (캐시)' : ''}` : `(미연동) ${u.message}`); } + if (d.enabled) { line('메일 주소 형식:', `아이디@${d.domain}`); line('웹메일:', d.webmailUrl); if (st.dns) line('마지막 확인:', dt(st.dns.checkedAt)); } + if (!d.domains.length) line('참고:', '이 사이트에 연결된 도메인이 없습니다. 메일 도메인을 직접 입력할 수 있습니다(도메인 소유자가 DNS 를 등록해야 함).'); + const rt = $('msRecords').querySelector('tbody'); rt.innerHTML = ''; + const chk = {}; for (const r of ((st.dns && st.dns.records) || [])) chk[r.key] = r; + for (const r of d.records || []) { const tr = document.createElement('tr'); const c = chk[r.key]; td(tr, r.type); td(tr, r.host, 'wrap'); td(tr, r.value, 'wrap'); td(tr, r.priority || '', 'num'); td(tr, r.purpose === 'send' ? '보내기 인증' : r.required ? '받기 (필수)' : '권장'); td(tr, c ? `${c.ok ? '✓ 확인됨' : '✗ 미확인'} — 현재: ${c.found}` : '-', c && c.ok ? 'st-done wrap' : 'st-error wrap'); rt.appendChild(tr); } + if (!(d.records || []).length) { const tr = document.createElement('tr'); td(tr, d.recordsError || '메일 시스템을 켜면 이 도메인에 등록할 DNS 레코드가 표시됩니다').colSpan = 6; rt.appendChild(tr); } +} +$('msApply').onclick = async () => { + if (!msCur) return; $('msMsg').textContent = '저장하고 DNS 를 확인하는 중…'; + try { await api(`/api/admin/mail/${msCur}`, { method: 'POST', body: JSON.stringify({ domain: $('msDomain').value.trim(), mxHost: $('msMx').value.trim(), spfInclude: $('msSpf').value.trim(), dmarc: $('msDmarc').value.trim(), mailServer: $('msServer').value /* v1.6.38 */ }) }); $('msMsg').textContent = '저장했습니다. DNS 가 전파·확인될 때까지 사용자에게 "메일시스템 설정중" 으로 표시됩니다.'; await loadMailSys(); } + catch (e) { $('msMsg').textContent = e.message; } +}; +$('msCheck').onclick = async () => { if (!msCur) return; $('msMsg').textContent = '확인 중…'; try { await api(`/api/admin/mail/${msCur}/check`, { method: 'POST', body: '{}' }); $('msMsg').textContent = '다시 확인했습니다'; await loadMailSys(); } catch (e) { $('msMsg').textContent = e.message; } }; +$('msOff').onclick = async () => { if (!msCur || !confirm('이 사이트의 메일 시스템을 끕니다. 사용자는 웹메일에 들어갈 수 없게 되며, 계정과 메일은 남아 다시 켜면 이어서 씁니다. 계속할까요?')) return; try { await api(`/api/admin/mail/${msCur}/disable`, { method: 'POST', body: '{}' }); $('msMsg').textContent = '껐습니다'; await loadMailSys(); } catch (e) { $('msMsg').textContent = e.message; } }; +$('msClose').onclick = () => { msCur = null; $('msPanel').classList.add('hidden'); }; +$('msSave').onclick = async () => { try { await api('/api/admin/mail', { method: 'PUT', body: JSON.stringify({ mxHost: $('msDefMx').value.trim(), spfInclude: $('msDefSpf').value.trim(), dmarc: $('msDefDmarc').value.trim() }) }); $('msDefMsg').textContent = '기본값을 저장했습니다'; loadMailSys(); } catch (e) { $('msDefMsg').textContent = e.message; } }; + +// ---------- Webmaker 화면 외형 (v1.2.0) ---------- +/** + * 관리자 테마 편집기. GET /api/admin/settings/theme 로 현재 값·기본값·미리 만든 색 구성을 받고, + * PUT /api/admin/settings {theme} 로 저장한다(서버가 색은 #rrggbb, 외형은 허용 범위로 검증). + * 저장된 테마는 /theme.css 로 사용자 화면에 적용된다. 아래 미리보기 상자는 CSS 변수로 즉시 반영한다. + */ +const TH_COLOR_LABEL = { accent: '강조색 (버튼·선택)', accent2: '강조색 진하게 (마우스 올림)', bg: '바탕', panel: '패널·카드', text: '글자', muted: '보조 글자', line: '테두리', ok: '성공', warn: '경고', topBg: '위쪽 영역 바탕' }; +let thInfo = null; let thCur = null; +async function loadTheme() { + try { thInfo = await api('/api/admin/settings/theme'); } catch (_) { return; } + thCur = JSON.parse(JSON.stringify(thInfo.theme)); + const sel = $('thPreset'); sel.innerHTML = ''; + for (const [id, p] of Object.entries(thInfo.presets)) sel.appendChild(new Option(p.label, id)); + sel.appendChild(new Option('직접 조정', 'custom')); + $('thState').textContent = thInfo.saved ? '— 사용자 지정 적용 중' : '— 기본값'; + renderTheme(); buildPreview(); +} +$('thRevert').onclick = () => { if (!thInfo) return; thCur = JSON.parse(JSON.stringify(thInfo.theme)); renderTheme(); $('thMsg').textContent = '저장된 값으로 되돌렸습니다 (아직 저장 안 함)'; }; +function renderTheme() { + const c = thCur.colors, l = thCur.layout; $('thPreset').value = thCur.preset || 'default'; + const box = $('thColors'); box.innerHTML = ''; + for (const k of Object.keys(thInfo.defaults.colors)) { + const lab = document.createElement('label'); lab.textContent = TH_COLOR_LABEL[k] || k; + const inp = document.createElement('input'); inp.type = 'color'; inp.value = c[k]; inp.style.height = '32px'; inp.style.padding = '2px'; + inp.oninput = () => { c[k] = inp.value; thCur.preset = 'custom'; $('thPreset').value = 'custom'; previewTheme(); }; + lab.appendChild(inp); box.appendChild(lab); + } + $('thLeftWidth').value = l.leftWidth; $('thFontSize').value = l.fontSize; $('thRadius').value = l.radius; $('thDensity').value = l.density; $('thPanelSide').value = l.panelSide; $('thHeaderStyle').value = l.headerStyle; + previewTheme(); +} +// ---------- 화면 설정 미리보기 (v1.3.2) ---------- +/** + * 실제 사용자 화면(빌더 index.html·login.html·site-admin.html)의 HTML 을 받아 스크립트를 빼고 예시 내용을 채운 뒤 + * 같은 출처의 iframe(srcdoc)에 넣는다. 테마 CSS 는 서버가 만든 것(POST /api/admin/settings/theme-preview)을 + * 각 iframe 의 '); } +async function buildPreview() { + for (const v of ['index', 'login', 'site-admin']) if (!thPages[v]) { try { thPages[v] = previewDoc((await api(`/api/admin/settings/preview-page/${v}`)).html); } catch (_) { thPages[v] = '

    미리보기를 불러오지 못했습니다

    '; } } + const box = $('thFrames'); box.innerHTML = ''; box.className = 'th-frames ' + (thView === 'all' ? 'all' : 'one'); + const views = thView === 'all' ? Object.keys(TH_VIEWS) : [thView]; + for (const v of views) { + const conf = TH_VIEWS[v]; const wrap = document.createElement('div'); wrap.className = 'th-frame-wrap'; wrap.dataset.view = v; + const cap = document.createElement('div'); cap.className = 'muted'; cap.textContent = `${conf.label} — ${conf.w}px`; wrap.appendChild(cap); + const vp = document.createElement('div'); vp.className = 'th-vp'; const f = document.createElement('iframe'); f.width = conf.w; f.height = conf.h; f.srcdoc = thPages[conf.page]; f.setAttribute('sandbox', 'allow-same-origin'); + f.onload = () => { try { fillSample(f.contentDocument, conf.page); applyCss(f); } catch (_) {} }; + vp.appendChild(f); wrap.appendChild(vp); box.appendChild(wrap); + } + fitFrames(); +} +/** 각 iframe 을 칸 폭에 맞게 축소 (실제 화면 폭으로 그린 뒤 transform: scale) */ +function fitFrames() { document.querySelectorAll('#thFrames .th-frame-wrap').forEach((w) => { const f = w.querySelector('iframe'); const vp = w.querySelector('.th-vp'); const avail = vp.clientWidth || w.clientWidth; const sc = Math.min(1, avail / f.width); f.style.transform = `scale(${sc})`; vp.style.height = Math.round(f.height * sc) + 'px'; }); } +window.addEventListener('resize', () => { if (!$('tab-theme').classList.contains('hidden')) fitFrames(); }); +function applyCss(f) { try { const st = f.contentDocument.getElementById('wmPreviewTheme'); if (st) st.textContent = thCss; } catch (_) {} } +document.querySelectorAll('#thViews button').forEach((b) => (b.onclick = () => { thView = b.dataset.view; document.querySelectorAll('#thViews button').forEach((x) => x.classList.toggle('active', x === b)); buildPreview(); })); +/** 편집 값이 바뀌면(0.15초 모아서) 서버에서 CSS 를 받아 모든 미리보기 iframe 에 넣는다 */ +function previewTheme() { + const l = thCur.layout; $('thLeftWidthV').textContent = l.leftWidth + 'px'; $('thFontSizeV').textContent = l.fontSize + 'px'; $('thRadiusV').textContent = l.radius + 'px'; + clearTimeout(thTimer); thTimer = setTimeout(async () => { try { thCss = (await api('/api/admin/settings/theme-preview', { method: 'POST', body: JSON.stringify({ theme: thCur }) })).css; document.querySelectorAll('#thFrames iframe').forEach(applyCss); } catch (_) {} }, 150); +} +$('thPreset').onchange = () => { + const id = $('thPreset').value; if (id === 'custom') { thCur.preset = 'custom'; return; } + const p = thInfo.presets[id]; thCur = { preset: id, colors: { ...thInfo.defaults.colors, ...(p.colors || {}) }, layout: { ...thCur.layout, ...(p.layout || {}) } }; renderTheme(); +}; +for (const [id, key, num] of [['thLeftWidth', 'leftWidth', 1], ['thFontSize', 'fontSize', 1], ['thRadius', 'radius', 1], ['thDensity', 'density', 0], ['thPanelSide', 'panelSide', 0], ['thHeaderStyle', 'headerStyle', 0]]) $(id).oninput = $(id).onchange = () => { thCur.layout[key] = num ? Number($(id).value) : $(id).value; previewTheme(); }; +$('thSave').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: thCur }) }); $('thMsg').textContent = '저장했습니다. 사용자 화면을 새로고침하면 적용됩니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } }; +$('thReset').onclick = async () => { if (!confirm('화면 외형을 기본값으로 되돌릴까요?')) return; try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: { reset: true } }) }); $('thMsg').textContent = '기본값으로 되돌렸습니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } }; + +async function loadSettings() { + settingsData = await api('/api/admin/settings'); const s = settingsData; + loadVault(); + $('ver').textContent = s.version ? `v${s.version}` : ''; + renderTiers(s); + $('sSignupCredit').value = s.signupCredit || 0; $('sMaxEdits').value = s.maxEdits == null ? 10 : s.maxEdits; + // v1.5.27 (요구 6) 회원가입 규칙: ON/OFF 는 설정 파일(service.allowSignup, /api/admin/config), 승인 방식은 settings.signupApproval + $('suApproval').value = s.signupApproval || 'immediate'; + api('/api/admin/config').then((c) => { const k = ((c.config && c.config.groups) || []).flatMap((g) => g.keys || []).find((x) => x.key === 'service.allowSignup'); if (k) $('suEnabled').checked = k.value !== false && k.value !== 'false' && k.value !== '0'; markSaved(); }).catch(() => {}); + fillDomains(s.domainsConfig); // v1.4.0 도메인 서비스 설정 + fillWelcome(s); // v1.4.5 사용자 첫 화면 페이지 + cashUnit = s.cashUnit || '원'; $('sCashUnit').value = cashUnit; document.querySelectorAll('.cash-unit').forEach((e) => { e.textContent = cashUnit; }); $('chargeAmount').placeholder = TF('금액({1})', cashUnit); // v1.4.1 캐시 표시 단위 + $('sBacklogKey').value = ''; $('sBacklogKey').placeholder = s.backlogKeyHint ? `저장됨: ${s.backlogKeyHint} (바꾸려면 새 키 입력)` : '수집 키 (비워 두면 비활성)'; + $('sServiceKey').value = ''; $('sServiceKey').placeholder = s.serviceApiKeyHint ? `저장됨: ${s.serviceApiKeyHint} (바꾸려면 새 키 입력)` : '서비스 키 (비워 두면 외부 연동 비활성)'; + renderClaudeModels(s); // v1.5.16 AI 연결 탭: 사용할 Claude 모델 체크 목록 + document.querySelector(`input[name=authMode][value=${s.auth.mode}]`).checked = true; + $('sOauth').value = ''; $('sOauth').placeholder = s.auth.oauthTokenHint ? `저장됨: ${s.auth.oauthTokenHint} (바꾸려면 새 토큰 입력)` : 'OAuth 토큰 붙여넣기'; + $('sApiKey').value = ''; $('sApiKey').placeholder = s.auth.apiKeyHint ? `저장됨: ${s.auth.apiKeyHint} (바꾸려면 새 키 입력)` : 'sk-ant-api03-…'; + $('serverLoginInfo').textContent = (s.serverLogin.email ? `— 서버에 로그인된 계정: ${s.serverLogin.email}${s.serverLogin.billing ? ` (${s.serverLogin.billing})` : ''}` : '— 서버에 로그인된 계정 없음') + (s.claude ? (s.claude.bin ? ` · claude ${s.claude.version || ''} @ ${s.claude.bin}` : ` · claude 실행 파일 없음 (탐색: ${s.claude.searched.join(', ')})`) : ''); + document.querySelector(`input[name=fxMode][value=${s.fx.mode}]`).checked = true; $('sSpread').value = s.fx.spreadPct; $('sManualRate').value = s.fx.manualRate || ''; $('sMargin').value = s.marginPct; $('sLeEmail').value = s.letsEncryptEmail || ''; + // v1.3.17 비밀번호 규칙 (없으면 기본: 6자 이상) + { const pp = { minLength: 6, maxLength: 256, ...(s.passwordPolicy || {}) }; $('ppMin').value = pp.minLength; $('ppMax').value = pp.maxLength; $('ppLetter').checked = !!pp.requireLetter; $('ppMixed').checked = !!pp.requireMixedCase; $('ppDigit').checked = !!pp.requireDigit; $('ppSymbol').checked = !!pp.requireSymbol; $('ppNoId').checked = !!pp.forbidId; $('ppMsg').textContent = TF('지금 규칙: {1}', ppDescribe(pp)); } + markSaved(); // v1.3.16 지금 값을 "저장된 값" 으로 기억 (고치면 .dirty 표시) + refreshFxNow(false); loadProviders().catch(() => {}); loadMail().catch(() => {}); loadGit().catch(() => {}); loadSsl().catch(() => {}); // v1.4.14 와일드카드 인증서 상태 +} +async function loadGit() { + $('gStatus').textContent = '확인 중…'; $('gStatus').style.color = ''; + const g = await api('/api/admin/settings/git'); + $('gStatus').textContent = (g.enabled ? `원격: ${g.remote} · ` : '') + g.detail + (g.ms ? ` (${g.ms}ms)` : ''); + $('gStatus').style.color = g.ok ? '#6ee7b7' : (g.enabled ? '#fca5a5' : ''); + if (g.publicKey) { $('gPubKey').value = `이 서버의 공개키 (${g.keyFile || ''}):\n${g.publicKey}`; $('gPubKey').classList.remove('hidden'); } else $('gPubKey').classList.add('hidden'); +} +$('gTest').onclick = () => loadGit().catch((e) => { $('gStatus').textContent = '확인 실패: ' + e.message; }); +/** + * 모델 등급 지정 (기본·중급·고급 → 실제 모델). + * v1.4.14 (요구 6): 사용 가능한 모델 전체를 제공자별 묶음(optgroup)으로 보이고, 켜 두었지만 목록에 못 들어간 제공자는 이유를 아래에 적는다. + * 1.4.5 까지는 AI 제공자에서 모델을 추가·저장해도 이 목록이 다시 그려지지 않아(설정 화면을 새로 열어야 보임) "추가한 모델이 안 보인다" 는 문제가 있었다 + * → pSave 가 저장 뒤 loadSettings() 를 다시 부른다. + */ +function renderTiers(s) { + const box = $('tierRows'); box.innerHTML = ''; + const models = s.availableModels || []; + const groups = []; for (const m of models) { const key = m.providerLabel || m.provider || ''; let g = groups.find((x) => x.key === key); if (!g) { g = { key, items: [] }; groups.push(g); } g.items.push(m); } + /** 사용 가능한 모델 전체를 제공자별 묶음으로 채운 select. cur 가 목록에 없으면(제공자를 껐거나 모델을 뺌) "현재 사용 불가" 항목으로 보인다 — v1.5.16: 내부 호출 모델도 같은 목록을 쓴다 */ + const fillModelSelect = (sel, cur) => { + sel.innerHTML = ''; + for (const g of groups) { const og = document.createElement('optgroup'); og.label = g.key; for (const m of g.items) og.appendChild(new Option(`${m.label} (${m.ref}) — 입력 ${m.input} / 출력 ${m.output} per 1M`, m.ref)); sel.appendChild(og); } + if (cur && ![...sel.options].some((o) => o.value === cur)) sel.appendChild(new Option(`${cur} (현재 사용 불가 — AI 연결 탭에서 제공자·모델 확인)`, cur)); + sel.value = cur; + }; + for (const t of s.tierCatalog || []) { + const row = document.createElement('div'); row.className = 'row'; row.style.marginBottom = '6px'; + const lab = document.createElement('span'); lab.style.minWidth = '110px'; lab.innerHTML = `${t.label} ${t.desc}`; row.appendChild(lab); + const sel = document.createElement('select'); sel.dataset.tier = t.id; sel.style.margin = '0'; sel.style.flex = '1'; + fillModelSelect(sel, (s.tiers || {})[t.id] || ''); row.appendChild(sel); box.appendChild(row); + } + // v1.5.16 (요구 2): 내부 호출 모델 — 등급 지정과 같은 목록(AI 연결 탭에서 켠 모델)에서 고른다. 저장은 "모델 변경" 버튼(btnSaveInternal) + fillModelSelect($('sInternalRef'), s.internalRef || ''); + const note = document.createElement('p'); note.className = 'muted'; note.style.margin = '2px 0 6px'; + const miss = s.unavailableProviders || []; + note.textContent = TF('고를 수 있는 모델 {1}개 ({2})', models.length, groups.map((g) => `${g.key} ${g.items.length}`).join(', ')); + box.appendChild(note); + // v1.6.38 (요구 1): 켜 두었지만 목록에 못 들어간 제공자는 빨간 줄로 따로 — 회색 한 줄에 묻혀 "추가한 모델이 안 보인다" 로 이어지던 문제 + if (miss.length) { const warn = document.createElement('p'); warn.className = 'msg err'; warn.style.margin = '0 0 6px'; warn.textContent = TF('목록에 들어가지 못한 AI 제공자: {1} — AI 연결 탭에서 고치면 바로 이 목록에 나옵니다', miss.map((x) => `${x.label}(${x.reason})`).join(' / ')); box.appendChild(warn); } + const dsel = $('sDefaultTier'); dsel.innerHTML = ''; for (const t of s.tierCatalog || []) dsel.appendChild(new Option(t.label, t.id)); dsel.value = s.defaultTier || 'standard'; +} +$('btnStatsCollect').onclick = async () => { $('statsCollectLine').textContent = '접속 로그 수집 중…'; try { const r = await api('/api/admin/stats/collect', { method: 'POST', body: '{}' }); const l = r.local || {}; const rem = (r.remote && r.remote.items) || []; $('statsCollectLine').textContent = `접속 로그 수집: 로컬 ${l.ok ? `정상 (${l.lines || 0}줄)` : '실패 — ' + (l.detail || '')}${rem.length ? ' · 원격 ' + rem.map((x) => `${x.target}/${x.site}: ${x.ok ? x.lines + '줄' : '실패(' + x.detail + ')'}`).join(', ') : ''}`; loadOverview(); } catch (e) { $('statsCollectLine').textContent = '수집 실패: ' + e.message; } }; +$('btnSaveSignup').onclick = async () => { + // v1.5.27: 두 저장소를 차례로 — 설정 파일(가입 허용) → 관리자 설정(승인 방식) + $('suMsg').textContent = T('저장 중…'); + try { await api('/api/admin/config', { method: 'PUT', body: JSON.stringify({ values: { 'service.allowSignup': $('suEnabled').checked } }) }); await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ signupApproval: $('suApproval').value }) }); await loadSettings(); $('suMsg').textContent = TF('저장했습니다 — 회원가입 {1}, {2}', $('suEnabled').checked ? T('허용') : T('닫힘'), $('suApproval').value === 'approval' ? T('관리자 승인 후 이용') : T('가입 즉시 이용')); $('suMsg').className = 'muted ok'; } + catch (e) { $('suMsg').textContent = T('실패') + ': ' + e.message; $('suMsg').className = 'msg err'; } +}; +$('btnSaveMaxEdits').onclick = () => saveSetting({ maxEdits: $('sMaxEdits').value }, 'maxEditsMsg', (s) => (s.maxEdits ? TF('저장했습니다 — {1}회', s.maxEdits) : T('저장했습니다 — 무제한'))); +/** v1.4.5 사용자 첫 화면 페이지 선택 상자 (관리자가 만든 사이트) */ +function fillWelcome(s) { + const sel = $('sWelcome'); const list = s.welcomeCandidates || []; sel.innerHTML = ''; + sel.appendChild(new Option(T('기본 안내 (페이지 없음)'), '')); + for (const c of list) { const o = new Option(`${c.title} — ${c.published ? T('배포됨') : (c.hasIndex ? T('미배포') : T('첫 페이지 없음'))}`, c.id); o.disabled = !c.published && !c.hasIndex; sel.appendChild(o); } + sel.value = list.some((c) => c.id === s.welcomeSiteId) ? s.welcomeSiteId : ''; + const cur = list.find((c) => c.id === sel.value); $('welcomeOpen').href = cur ? cur.url : '#'; $('welcomeOpen').classList.toggle('hidden', !cur); + if (!list.length) $('welcomeMsg').textContent = T('관리자 계정이 만든 사이트가 없습니다. /builder 에서 먼저 만드세요'); +} +$('btnSaveWelcome').onclick = () => saveSetting({ welcomeSiteId: $('sWelcome').value }, 'welcomeMsg', (s) => { const c = (s.welcomeCandidates || []).find((x) => x.id === s.welcomeSiteId); return c ? TF('저장했습니다 — {1}', c.title) : T('저장했습니다 — 기본 안내'); }); +$('btnSaveCashUnit').onclick = () => saveSetting({ cashUnit: $('sCashUnit').value }, 'cashUnitMsg', () => TF('저장했습니다 — 예: {1}', krw(12000))); // v1.4.1 저장 후 loadSettings 가 cashUnit 을 바꾼 뒤 예시를 보인다 +$('btnSaveSignupCredit').onclick = () => saveSetting({ signupCredit: $('sSignupCredit').value }, 'signupCreditMsg', (s) => TF('저장했습니다 — {1}', krw(s.signupCredit || 0))); +$('btnSaveTiers').onclick = async () => { const tiers = {}; document.querySelectorAll('#tierRows select').forEach((sel) => { tiers[sel.dataset.tier] = sel.value; }); try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ tiers, defaultTier: $('sDefaultTier').value }) }); $('tierMsg').textContent = '저장되었습니다 — 다음 작업부터 적용'; loadOverview(); } catch (e) { $('tierMsg').textContent = '실패: ' + e.message; } }; +$('btnGenBacklogKey').onclick = () => { const a = new Uint8Array(24); crypto.getRandomValues(a); $('sBacklogKey').value = 'wmb_' + [...a].map((x) => x.toString(16).padStart(2, '0')).join(''); }; +$('btnSaveBacklogKey').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ backlogKey: $('sBacklogKey').value.trim() }) }); $('backlogKeyMsg').textContent = $('sBacklogKey').value.trim() ? '저장되었습니다. 이 키를 작업 서버의 수집 설정에 등록하세요(다시 표시되지 않음).' : '수집을 껐습니다.'; await loadSettings(); } catch (e) { $('backlogKeyMsg').textContent = '실패: ' + e.message; } }; +$('btnGenServiceKey').onclick = () => { const a = new Uint8Array(24); crypto.getRandomValues(a); $('sServiceKey').value = 'wmk_' + [...a].map((x) => x.toString(16).padStart(2, '0')).join(''); }; +$('btnSaveServiceKey').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ serviceApiKey: $('sServiceKey').value.trim() }) }); $('serviceKeyMsg').textContent = $('sServiceKey').value.trim() ? '저장되었습니다. 이 키는 다시 표시되지 않으니 외부 서비스에 지금 등록하세요.' : '외부 연동을 비활성화했습니다.'; await loadSettings(); } catch (e) { $('serviceKeyMsg').textContent = '실패: ' + e.message; } }; +async function loadMail() { + const m = await api('/api/admin/settings/mail'); $('mKeyFile').value = m.keyFile || ''; $('mApiKey').value = ''; $('mApiKey').placeholder = m.source === 'vault' ? '암호화 보관소의 키 사용 중 (바꾸려면 새 키 입력)' : m.source === 'settings' ? '저장된 키 사용 중 (바꾸려면 새 키 입력)' : '또는 Resend API 키 직접 입력 (re_…)'; $('mDomain').value = m.defaultDomain || ''; $('mFromName').value = m.fromName || ''; $('mFormBase').value = m.formBase || ''; + $('mStatus').textContent = m.configured ? `키 확인됨 (${m.source === 'vault' ? '암호화 보관소' : m.source === 'file' ? '평문 키 파일 ' + (m.keyName || '') : '직접 입력'}) · Resend 검증 도메인: ${m.verifiedDomains.length ? m.verifiedDomains.join(', ') : '없음/조회 실패'}${m.error ? ' · ' + m.error : ''}` : `미설정: ${m.error || ''}`; +} +$('mSave').onclick = async () => { try { const body = { mail: { keyFile: $('mKeyFile').value, defaultDomain: $('mDomain').value, fromName: $('mFromName').value, formBase: $('mFormBase').value } }; if ($('mApiKey').value.trim()) body.mail.apiKey = $('mApiKey').value.trim(); await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(body) }); await loadMail(); alert('저장되었습니다'); } catch (e) { alert(e.message); } }; +$('mTest').onclick = async () => { const to = $('mTestTo').value.trim(); if (!to) return alert('테스트 수신 주소를 입력하세요'); $('mTestMsg').textContent = '발송 중…'; try { const r = await api('/api/admin/settings/mail-test', { method: 'POST', body: JSON.stringify({ to, domain: $('mDomain').value }) }); $('mTestMsg').textContent = r.ok ? `발송 성공 (발신 ${r.from})` : '실패: ' + r.error; } catch (e) { $('mTestMsg').textContent = '실패: ' + e.message; } }; +async function refreshFxNow(force) { try { const r = force ? await api('/api/admin/settings/refresh-rate', { method: 'POST', body: '{}' }) : (billingData ? billingData.fx.today : await api('/api/admin/settings/refresh-rate', { method: 'POST', body: '{}' })); $('fxNow').textContent = `현재 적용: 1 USD = ${r.rate.toLocaleString(uiLocale())}원 (${r.source}${r.spreadPct ? `, 스프레드 ${r.spreadPct}%` : ''})`; } catch (e) { $('fxNow').textContent = '환율 조회 실패: ' + e.message; } } +$('btnSaveAuth').onclick = async () => { + const mode = document.querySelector('input[name=authMode]:checked').value; const body = { auth: { mode } }; + if ($('sOauth').value.trim()) body.auth.oauthToken = $('sOauth').value.trim(); if ($('sApiKey').value.trim()) body.auth.apiKey = $('sApiKey').value.trim(); + if (mode === 'oauth-token' && !body.auth.oauthToken && !settingsData.auth.oauthToken) return alert('OAuth 토큰을 입력하세요'); if (mode === 'api-key' && !body.auth.apiKey && !settingsData.auth.apiKey) return alert('API 키를 입력하세요'); + try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(body) }); $('connResult').textContent = '저장됨. 연결 테스트로 확인하세요. (인증이 되면 사용할 Claude 모델이 설정 탭의 등급 목록에 나옵니다)'; loadSettings(); } catch (e) { alert(e.message); } +}; +$('btnTestConn').onclick = async () => { $('connResult').textContent = '테스트 중… (10~30초)'; $('btnTestConn').disabled = true; try { const r = await api('/api/admin/settings/test-connection', { method: 'POST', body: '{}' }); $('connResult').textContent = r.ok ? `연결 성공 — 모델 ${r.model}, 응답 "${r.reply}", 비용 ${usd(r.costUsd)}${r.claude && r.claude.bin ? ` (${r.claude.bin})` : ''}` : `연결 실패 — ${r.error || r.subtype}`; } catch (e) { $('connResult').textContent = '연결 실패 — ' + e.message; } $('btnTestConn').disabled = false; }; +$('aPwSave').onclick = async () => { try { await api('/api/auth/password', { method: 'POST', body: JSON.stringify({ current: $('aPwCur').value, next: $('aPwNew').value }) }); $('aPwMsg').textContent = '변경되었습니다'; $('aPwCur').value = $('aPwNew').value = ''; } catch (e) { $('aPwMsg').textContent = e.message; } }; +/** + * v1.3.16 설정 값 하나(또는 한 묶음)를 저장하고, 서버에 다시 물어 실제로 저장된 값을 화면에 보인다. + * 저장 버튼이 값마다 따로 있어 "고쳤는데 저장 안 됨" 을 막고, 결과 메시지에 저장된 값을 그대로 적어 확인할 수 있게 한다. + * @param {object} patch PUT /api/admin/settings 본문 + * @param {string} msgId 결과를 쓸 요소 id + * @param {(s:object)=>string} done 저장 후 서버 설정으로 만든 확인 문구 + */ +async function saveSetting(patch, msgId, done) { + $(msgId).textContent = T('저장 중…'); + try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(patch) }); await loadSettings(); $(msgId).textContent = done(settingsData); $(msgId).className = 'muted ok'; } + catch (e) { $(msgId).textContent = T('실패') + ': ' + e.message; $(msgId).className = 'msg err'; } +} +$('btnSaveFx').onclick = async () => { await saveSetting({ fx: { mode: document.querySelector('input[name=fxMode]:checked').value, spreadPct: $('sSpread').value, manualRate: $('sManualRate').value } }, 'fxMsg', (s) => TF('저장했습니다 — {1}', s.fx.mode === 'manual' ? TF('직접 입력 1 USD = {1}원', s.fx.manualRate) : TF('자동 조회, 스프레드 {1}%', s.fx.spreadPct))); billingData = null; refreshFxNow(true); }; +/** v1.3.17 비밀번호 규칙 설명 (서버 lib/passpolicy describe 와 같은 문장) */ +function ppDescribe(p) { const parts = [TF('{1}자 이상', p.minLength)]; if (p.maxLength < 256) parts.push(TF('{1}자 이하', p.maxLength)); if (p.requireLetter) parts.push(T('영문자 포함')); if (p.requireMixedCase) parts.push(T('대문자·소문자 모두 포함')); if (p.requireDigit) parts.push(T('숫자 포함')); if (p.requireSymbol) parts.push(T('특수문자 포함')); if (p.forbidId) parts.push(T('아이디를 포함하지 않음')); return parts.join(', '); } +/* v1.4.0 도메인 서비스 설정 — 값마다 자기 저장 버튼 (금액·비율은 바로 옆 버튼으로만 바뀐다) */ +function fillDomains(d) { + if (!d || !$('dmMargin')) return; + $('dmMargin').value = d.marginPct; $('dmPlatform').value = d.platformDomain || ''; $('dmIp').value = d.targetIp || ''; + // v1.5.36: DNS 공급자·등록 업체 선택 상자 (준비 여부 표시). 목록은 /api/admin/settings 의 domainProviders + { const cat = (settingsData && settingsData.domainProviders) || { dns: [], registrar: [] }; const fill = (sel, items, cur) => { sel.innerHTML = ''; for (const it of items) sel.appendChild(new Option(`${it.label}${it.id === 'manual' ? '' : (it.ready ? ' — 준비됨' : ' — 접속 정보 없음')}`, it.id)); sel.value = cur; if (sel.value !== cur && items.length) sel.value = items[0].id; }; fill($('dmDns'), cat.dns, d.dnsProvider || d.platformDns || 'manual'); fill($('dmRegistrar'), cat.registrar, d.registrar || 'openprovider'); $('dmProvNote').textContent = '접속 정보는 왼쪽 "외부 연동 접속 정보" 에서 종류 Openprovider · Porkbun · Route 53 · Google Cloud DNS 로 넣고 "연결 확인" 으로 검사합니다. 구매한 도메인의 영역은 DNS 공급자에 만들어지고, 등록 업체와 공급자가 다르면 네임서버를 자동으로 지정합니다.'; const dz = $('dzProvider'); if (dz) { dz.innerHTML = ''; for (const it of cat.dns.filter((x) => x.id !== 'manual')) dz.appendChild(new Option(`${it.label}${it.ready ? '' : ' (접속 정보 없음)'}`, it.id)); dz.value = (d.dnsProvider && d.dnsProvider !== 'manual') ? d.dnsProvider : (cat.dns.find((x) => x.ready && x.id !== 'manual') || {}).id || ''; } } + $('dmTlds').value = (d.tlds || []).join(', '); $('dmReserved').value = (d.reserved || []).join(', '); $('dmDefType').value = d.defaultAccountType; $('dmUserMail').checked = d.userMail !== false; + if ($('dmAllowSub')) $('dmAllowSub').checked = d.allowSubdomain !== false; // v1.6.35 (요구 1-1) 서브 도메인 등록 허용 +} +if ($('btnDmAllowSub')) $('btnDmAllowSub').onclick = () => saveDomains({ allowSubdomain: $('dmAllowSub').checked }, 'dmAllowSubMsg', (d) => (d.allowSubdomain !== false ? T('저장했습니다 — 서브 도메인 등록 허용') : T('저장했습니다 — 서브 도메인 등록 금지 (기존 등록은 유지)'))); +const saveDomains = (patch, msgId, done) => saveSetting({ domains: patch }, msgId, (s) => { fillDomains(s.domainsConfig); return done(s.domainsConfig || {}); }); +$('btnDmMargin').onclick = () => saveDomains({ marginPct: $('dmMargin').value }, 'dmMarginMsg', (d) => TF('저장했습니다 — 사용자 가격 = 공급사 가격 × {1}%', d.marginPct)); +$('btnDmPlatform').onclick = () => saveDomains({ platformDomain: $('dmPlatform').value, dnsProvider: $('dmDns').value, registrar: $('dmRegistrar').value }, 'dmPlatformMsg', (d) => TF('저장했습니다 — {1} (DNS {2} · 등록 {3})', d.platformDomain || '-', d.dnsProvider || 'manual', d.registrar || 'openprovider')); // v1.5.36: DNS 공급자·등록 업체 함께 저장 +$('btnDmIp').onclick = () => saveDomains({ targetIp: $('dmIp').value }, 'dmIpMsg', (d) => (d.targetIp ? TF('저장했습니다 — {1}', d.targetIp) : T('비웠습니다 — 서비스 주소의 IP 를 씁니다'))); +$('btnDmTlds').onclick = () => saveDomains({ tlds: $('dmTlds').value }, 'dmTldsMsg', (d) => TF('저장했습니다 — {1}', (d.tlds || []).join(', '))); +$('btnDmReserved').onclick = () => saveDomains({ reserved: $('dmReserved').value }, 'dmReservedMsg', (d) => TF('저장했습니다 — {1}개', (d.reserved || []).length)); +$('btnDmDefType').onclick = () => saveDomains({ defaultAccountType: $('dmDefType').value }, 'dmDefTypeMsg', (d) => TF('저장했습니다 — 새 가입자: {1}', d.defaultAccountType === 'domain' ? T('자체 도메인') : T('서브 도메인 전용'))); +$('btnDmUserMail').onclick = () => saveDomains({ userMail: $('dmUserMail').checked }, 'dmUserMailMsg', (d) => (d.userMail ? T('저장했습니다 — 켜짐') : T('저장했습니다 — 꺼짐'))); +$('btnSavePp').onclick = () => saveSetting({ passwordPolicy: { minLength: $('ppMin').value, maxLength: $('ppMax').value, requireLetter: $('ppLetter').checked, requireMixedCase: $('ppMixed').checked, requireDigit: $('ppDigit').checked, requireSymbol: $('ppSymbol').checked, forbidId: $('ppNoId').checked } }, 'ppMsg', (s) => TF('저장했습니다 — {1}', ppDescribe({ minLength: 6, maxLength: 256, ...(s.passwordPolicy || {}) }))); +$('btnSaveMargin').onclick = () => saveSetting({ marginPct: $('sMargin').value }, 'marginMsg', (s) => TF('마진 {1}% 로 변경했습니다', s.marginPct)).then(() => { billingData = null; }); +// ---------- v1.4.14 (요구 9) 플랫폼 도메인 와일드카드 인증서 ---------- +/** + * 상태를 받아 그린다. 발급이 진행 중이면 10초마다 다시 받아 수동 DNS 모드의 "넣을 TXT" 와 결과를 보인다. + * cert.hasCert → 만료일·남은 일수, pending → TXT 이름·값, last → 마지막 발급 결과 + */ +let sslTimer = null; +async function loadSsl() { + let st; try { st = await api('/api/admin/ssl'); } catch (e) { $('sslStatus').textContent = T('상태를 불러오지 못했습니다') + ': ' + e.message; return; } + if (st.platformDomain) $('sslPlatformEx').textContent = `이름.${st.platformDomain}`; + const parts = []; + parts.push(st.platformDomain ? TF('플랫폼 도메인 {1} · 인증서 이름 {2}', st.platformDomain, st.certName) : T('플랫폼 도메인 없음')); + parts.push(TF('DNS 인증 방식: {1}', st.dnsMode === 'openprovider' ? T('자동 (Openprovider)') : T('수동 (TXT 직접 입력)'))); + if (st.target) parts.push(TF('배포 서버: {1}', st.target.name)); + if (st.cert && st.cert.hasCert) parts.push(TF('인증서 있음 — {1}, 만료 {2} ({3}일 남음)', (st.cert.sans || []).join(', '), st.cert.expires || '?', st.cert.daysLeft == null ? '?' : st.cert.daysLeft) + (st.external ? ' · ' + TF('서버에 미리 발급된 인증서 {1} 를 씁니다 (서브 도메인에 자동 적용, 갱신은 그 인증서의 certbot 타이머)', st.certName) : '')); // v1.5.17 + if (st.autoApply && st.autoApply.reconfigured) parts.push(TF('기동 때 자동 적용: {1} — 사이트 {2}개 HTTPS 로 갱신', dt(st.autoApply.at), st.autoApply.reconfigured)); + else if (st.supported) parts.push(T('인증서 없음 — 서브 도메인 사이트는 지금 HTTP 로 열립니다')); + if (!st.supported && st.reason) parts.push(st.reason); + if (st.running) parts.push(TF('발급 진행 중 ({1} 시작)', dt(st.job.startedAt))); + if (st.last) parts.push(`${T('마지막 발급')}: ${dt(st.last.at)} ${st.last.ok ? T('성공') : T('실패')} — ${st.last.detail}${st.last.reconfigured ? ` · ${TF('사이트 {1}개 설정 갱신', st.last.reconfigured)}` : ''}`); + if (st.cert && st.cert.hasCert && st.cert.daysLeft != null && st.cert.daysLeft < 20) parts.push(T('⚠ 만료가 가까워 갱신이 필요합니다 — 수동 DNS 방식이면 "발급·갱신" 을 누르고 뜨는 TXT 를 넣어 주세요')); + $('sslStatus').textContent = parts.join(' · '); + $('sslStatus').style.color = st.cert && st.cert.hasCert && (st.cert.daysLeft == null || st.cert.daysLeft >= 20) ? '#6ee7b7' : (st.supported ? '' : '#fca5a5'); + const pend = st.pending; $('sslPending').classList.toggle('hidden', !pend); + if (pend) { $('sslTxtName').value = pend.name; $('sslTxtValue').value = pend.value; $('sslPendingNote').textContent = pend.auto ? T('(자동 등록됨 — 전파 확인 중)') : TF('— DNS 관리 화면에 넣으면 자동으로 이어집니다 (최대 {1}분 대기, {2} 요청)', pend.waitMinutes || st.waitMinutes, dt(pend.at)); } + $('sslIssue').disabled = !st.supported || st.running; $('sslCancel').classList.toggle('hidden', !st.running); + clearTimeout(sslTimer); if (st.running) sslTimer = setTimeout(loadSsl, 10000); +} +$('sslRefresh').onclick = loadSsl; +$('sslIssue').onclick = async () => { + if (!confirm(T('와일드카드 인증서를 발급(또는 갱신)합니다. 수동 DNS 방식이면 잠시 뒤 TXT 레코드가 표시되며, DNS 에 넣기 전까지 진행이 멈춰 있습니다. 계속할까요?'))) return; + $('sslIssue').disabled = true; $('sslMsg').textContent = T('시작 중…'); + try { const r = await api('/api/admin/ssl/wildcard', { method: 'POST', body: '{}' }); $('sslMsg').textContent = r.mode === 'manual' ? TF('시작했습니다 — 몇 초 뒤 위에 TXT 레코드가 표시됩니다 (최대 {1}분 대기)', r.waitMinutes) : T('시작했습니다 — DNS 에 TXT 를 자동으로 넣고 인증합니다 (수 분)'); setTimeout(loadSsl, 4000); } + catch (e) { $('sslMsg').textContent = T('실패') + ': ' + e.message; $('sslIssue').disabled = false; } +}; +$('sslCancel').onclick = async () => { try { await api('/api/admin/ssl/wildcard', { method: 'DELETE' }); $('sslMsg').textContent = T('취소했습니다'); setTimeout(loadSsl, 2000); } catch (e) { $('sslMsg').textContent = e.message; } }; +document.querySelectorAll('[data-copy]').forEach((b) => { b.onclick = async () => { const v = $(b.dataset.copy).value; try { await navigator.clipboard.writeText(v); b.textContent = T('복사됨'); setTimeout(() => (b.textContent = T('복사')), 1200); } catch (_) { prompt('', v); } }; }); +$('btnSaveLeEmail').onclick = () => saveSetting({ letsEncryptEmail: $('sLeEmail').value }, 'leMsg', (s) => (s.letsEncryptEmail ? TF('저장했습니다 — {1}', s.letsEncryptEmail) : T('비웠습니다 (이메일 없이 발급)'))); +$('btnSaveInternal').onclick = () => saveSetting({ internalRef: $('sInternalRef').value }, 'internalMsg', (s) => TF('저장했습니다 — {1}', s.internalRef)).then(() => loadOverview()); // v1.5.16 내부 호출 모델 +/** + * v1.5.16 (요구 3) AI 연결 탭 › 사용할 Claude 모델: Claude 가격표(settings.MODELS)를 체크 목록으로 보이고, 체크한 id 배열을 settings.claudeModels 로 저장한다. + * 비어 있으면(= 저장한 적 없음) 전체 사용으로 본다. 저장 뒤 loadSettings 가 등급·내부 호출 목록을 다시 그린다. + */ +function renderClaudeModels(s) { + const box = $('claudeModelRows'); if (!box) return; box.innerHTML = ''; + const on = new Set((s.claudeModels && s.claudeModels.length ? s.claudeModels : (s.models || []).map((m) => m.id))); + for (const m of s.models || []) { + const l = document.createElement('label'); l.className = 'radio'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = m.id; cb.checked = on.has(m.id); cb.dataset.claudeModel = '1'; + l.appendChild(cb); l.appendChild(document.createTextNode(` ${m.label} (${m.id}) — 입력 ${m.input} / 출력 ${m.output} per 1M${m.note ? ' · ' + m.note : ''}`)); box.appendChild(l); + } + const used = new Set(Object.values(s.tiers || {}).concat([s.internalRef || '']).filter((r) => String(r).startsWith('anthropic:')).map((r) => r.slice(10))); + const note = document.createElement('p'); note.className = 'muted'; note.textContent = used.size ? TF('지금 등급·내부 호출에 지정된 Claude 모델: {1} — 이 모델을 끄면 설정 탭에서 "현재 사용 불가" 로 보입니다', [...used].join(', ')) : ''; box.appendChild(note); +} +$('btnSaveClaudeModels').onclick = () => { + const ids = [...document.querySelectorAll('#claudeModelRows input[data-claude-model]')].filter((c) => c.checked).map((c) => c.value); + if (!ids.length) { $('claudeModelsMsg').textContent = T('적어도 한 모델은 사용으로 두세요'); $('claudeModelsMsg').className = 'msg err'; return; } + saveSetting({ claudeModels: ids }, 'claudeModelsMsg', (s) => TF('저장했습니다 — 사용 {1}개 (설정 탭의 등급·내부 호출 목록에 반영)', (s.claudeModels || []).length || (s.models || []).length)); +}; +/** + * v1.3.16 저장하지 않은 변경 표시: data-dirty-watch 입력칸은 불러온 값(dataset.saved)과 다르면 노란 테두리(.dirty)로 표시하고, + * 그런 칸이 있는 채로 페이지를 떠나거나 새로고침하면 브라우저가 확인을 묻는다. + */ +function markSaved() { document.querySelectorAll('[data-dirty-watch]').forEach((i) => { i.dataset.saved = i.type === 'radio' || i.type === 'checkbox' ? String(i.checked) : i.value; i.classList.remove('dirty'); }); } +document.addEventListener('input', (e) => { const i = e.target; if (!i.matches || !i.matches('[data-dirty-watch]')) return; if (i.type === 'radio') { document.querySelectorAll(`input[name="${i.name}"]`).forEach((r) => r.classList.toggle('dirty', String(r.checked) !== r.dataset.saved)); return; } i.classList.toggle('dirty', (i.type === 'checkbox' ? String(i.checked) : i.value) !== i.dataset.saved); }); +document.addEventListener('change', (e) => { if (e.target.matches && e.target.matches('select[data-dirty-watch], input[type=radio][data-dirty-watch]')) e.target.dispatchEvent(new Event('input', { bubbles: true })); }); +window.addEventListener('beforeunload', (e) => { if (document.querySelector('.dirty')) { e.preventDefault(); e.returnValue = ''; } }); +$('btnRefreshFx').onclick = () => refreshFxNow(true); + +/** + * 서비스 버전 감시 (v1.3.5). 관리자 화면을 열어 둔 채 서비스가 새 버전으로 배포되면, 화면의 데이터(언어 상태·원본 버전 등)가 + * 예전 값으로 남아 "표시 중", "v1.3.1" 처럼 현재와 다르게 보였다. 1분마다 버전을 확인해 바뀌면 지금 탭과 개요를 다시 불러오고, + * 새 화면 코드를 받으려면 페이지를 새로 열라고 띠로 알린다. + */ +let loadedVersion = null; +setInterval(async () => { + let v; try { v = (await api('/api/config')).version; } catch (_) { return; } + if (!loadedVersion) { loadedVersion = v; return; } + if (v && v !== loadedVersion) { + const bar = $('verBar'); bar.classList.remove('hidden'); bar.textContent = ''; bar.appendChild(document.createTextNode(`서비스가 v${loadedVersion} → v${v} 로 업데이트되어 화면 데이터를 다시 불러왔습니다. 새 기능 화면까지 받으려면 `)); const a = document.createElement('button'); a.type = 'button'; a.className = 'ghost'; a.textContent = '페이지 새로 열기'; a.onclick = () => location.reload(); bar.appendChild(a); + loadedVersion = v; $('btnReload').click(); + } +}, 60000); +(async () => { const c = await api('/api/config'); me = c.user; loadedVersion = c.version; $('who').textContent = `${me.name} (${me.id})`; settingsData = await api('/api/admin/settings').catch(() => null); await loadOverview(); loadSearchRegs(); { const h = location.hash.slice(1); if (h && h !== 'detail' && document.getElementById('tab-' + h)) openTab(h); } })(); + +$('fbTypeFilter').onchange = () => { if (fbData) loadFeedback(); }; + +// ---------- v1.5.7 (요구 2·7) 시스템 설정 탭: 설정 파일·연동 서비스·AI 엔진 — 초기 설정 페이지와 같은 화면(public/setup.js)을 관리자 API 로 붙인다 ---------- +// 탭을 처음 열 때 한 번 붙이고, 이후에는 화면 안의 "새로고침" 으로 다시 읽는다 (설정 저장 뒤 서비스를 재시작하면 서버 상태가 바뀌므로). +let sysMounted = null; +document.querySelectorAll('.tabs button[data-tab="system"]').forEach((b) => b.addEventListener('click', () => { if (!sysMounted && window.WMSetup) sysMounted = WMSetup.mount($('sysConfig'), { apiBase: '/api/admin/config' }); })); + +/* ============================================================================ + * v1.5.36 (요구 5) DNS 영역 관리 (관리자) — /api/admin/dns/* + * 목록(영역·네임서버·회원 소유 여부) · 만들기 · 이전된 영역 받기 · 레코드 보기/추가/삭제 · 지우기(회원 도메인은 force 확인) · 감사 기록 + * ========================================================================== */ +let dzZones = []; let dzDomains = []; let dzCur = null; +async function dzLoad() { + const prov = $('dzProvider').value; $('dzMsg').textContent = T('불러오는 중…'); + try { const info = await api('/api/admin/dns'); dzDomains = info.domains || []; const at = $('dzAudit').querySelector('tbody'); at.innerHTML = ''; for (const a of info.audit || []) { const tr = document.createElement('tr'); td(tr, dt(a.at)); td(tr, a.by || ''); td(tr, a.action); td(tr, a.provider || ''); td(tr, a.zone || ''); td(tr, Array.isArray(a.records) ? a.records.join(' · ') : (a.imported != null ? TF('{1}개 가져옴', a.imported) : ''), 'wrap'); at.appendChild(tr); } } catch (_) {} + const tb = $('dzTable').querySelector('tbody'); tb.innerHTML = ''; + if (!prov) { $('dzMsg').textContent = T('준비된 DNS 공급자가 없습니다 — 외부 연동 접속 정보에 넣으세요'); return; } + try { const r = await api(`/api/admin/dns/zones?provider=${encodeURIComponent(prov)}`); dzZones = r.zones || []; $('dzMsg').textContent = TF('{1}: 영역 {2}개', prov, dzZones.length); } + catch (e) { $('dzMsg').textContent = T('실패') + ': ' + e.message; return; } + for (const z of dzZones) { + const tr = document.createElement('tr'); td(tr, z.name); td(tr, (z.nameServers || []).join(', ') || '-', 'wrap'); const owner = dzDomains.find((d) => d.name === z.name); td(tr, owner ? `${owner.userId}${owner.dnsProvider ? ' (' + owner.dnsProvider + ')' : ''}` : '-'); td(tr, z.records != null ? String(z.records) : '-', 'num'); + const act = document.createElement('td'); act.className = 'act'; act.appendChild(mkBtn('레코드', 'ghost', () => dzRecords(z.name))); act.appendChild(mkBtn('지우기', 'ghost danger', async () => { if (!confirm(`${z.name} 영역을 ${prov} 에서 지울까요? 레코드가 모두 사라집니다.`)) return; let q = ''; if (owner) { if (!confirm(`이 영역은 회원 ${owner.userId} 의 도메인입니다. 그래도 지울까요?`)) return; q = '&force=1'; } try { await api(`/api/admin/dns/zones/${encodeURIComponent(z.name)}?provider=${encodeURIComponent(prov)}${q}`, { method: 'DELETE' }); dzLoad(); } catch (e) { alert(e.message); } })); tr.appendChild(act); tb.appendChild(tr); + } + if (!dzZones.length) { const tr = document.createElement('tr'); td(tr, T('영역이 없습니다')).colSpan = 5; tb.appendChild(tr); } +} +async function dzRecords(zone) { + dzCur = zone; $('dzRecBox').classList.remove('hidden'); $('dzRecTitle').textContent = `${zone} — ${$('dzProvider').value}`; const tb = $('dzRecTable').querySelector('tbody'); tb.innerHTML = ''; + try { const r = await api(`/api/admin/dns/records/${encodeURIComponent(zone)}?provider=${encodeURIComponent($('dzProvider').value)}`); for (const rec of r.records || []) { const tr = document.createElement('tr'); td(tr, rec.name || '@'); td(tr, rec.type); td(tr, rec.value, 'wrap'); td(tr, rec.ttl, 'num'); td(tr, rec.prio != null ? rec.prio : '', 'num'); const act = document.createElement('td'); act.className = 'act'; if (!(rec.type === 'NS' && !rec.name) && rec.type !== 'SOA') act.appendChild(mkBtn('삭제', 'ghost danger', async () => { if (!confirm(`${rec.type} ${rec.name || '@'} ${rec.value} 레코드를 지울까요?`)) return; try { await api(`/api/admin/dns/records/${encodeURIComponent(zone)}?provider=${encodeURIComponent($('dzProvider').value)}`, { method: 'DELETE', body: JSON.stringify({ records: [rec] }) }); dzRecords(zone); } catch (e) { alert(e.message); } })); tr.appendChild(act); tb.appendChild(tr); } if (!(r.records || []).length) { const tr = document.createElement('tr'); td(tr, T('레코드가 없습니다')).colSpan = 6; tb.appendChild(tr); } } + catch (e) { const tr = document.createElement('tr'); td(tr, T('실패') + ': ' + e.message).colSpan = 6; tb.appendChild(tr); } +} +if ($('dzRefresh')) { + $('dzRefresh').onclick = dzLoad; $('dzProvider').onchange = dzLoad; + // v1.6.35 (요구 1-1): "영역 만들기"·"이전된 영역 받기" 는 사용자 도메인 탭(이미 가진 도메인 등록 › 네임서버 이전)으로 옮겨 관리자 화면에서 뺐다. API(/api/admin/dns/zones POST)는 호환을 위해 남아 있다 + $('dzRecAdd').onclick = async () => { if (!dzCur) return; const rec = { name: $('dzRecName').value.trim(), type: $('dzRecType').value, value: $('dzRecValue').value.trim(), prio: $('dzRecPrio').value || undefined }; if (!rec.value) return alert('값을 입력하세요'); try { await api(`/api/admin/dns/records/${encodeURIComponent(dzCur)}?provider=${encodeURIComponent($('dzProvider').value)}`, { method: 'POST', body: JSON.stringify({ records: [rec] }) }); $('dzRecValue').value = ''; dzRecords(dzCur); } catch (e) { alert(e.message); } }; + $('dzRecClose').onclick = () => { $('dzRecBox').classList.add('hidden'); dzCur = null; }; +} + +/* ============================================================================ + * v1.6.31 (요구 20·21) 운영 복구 · 작업 기록 + * ========================================================================== */ +async function loadRecovery() { + const tb = $('rcTable').querySelector('tbody'); tb.innerHTML = ''; $('rcMsg').textContent = T('불러오는 중…'); + let r; try { r = await api('/api/admin/recovery'); } catch (e) { $('rcMsg').textContent = T('실패') + ': ' + e.message; return; } + const items = r.items || []; const b = $('rcBadge'); if (b) { b.textContent = items.length ? String(items.length) : ''; b.classList.toggle('hidden', !items.length); } + $('rcMsg').textContent = items.length ? TF('{1}건 — 관리자 확인 필요 {2}건', items.length, items.filter((x) => x.needsAdmin).length) : T('정상 완료되지 않은 작업이 없습니다'); + const KIND = { deploy: '배포', purchase: '도메인 구매', domain: '도메인 등록', mail: '메일 계정', cert: '인증서', data: '데이터 파일', job: 'AI 작업' }; + for (const it of items) { + const tr = document.createElement('tr'); td(tr, KIND[it.kind] || it.kind); td(tr, `${it.siteTitle ? it.siteTitle + ' ' : ''}${it.siteId ? '(' + it.siteId.slice(0, 8) + '…) ' : ''}${it.userId || ''}`, 'wrap'); td(tr, (it.needsAdmin ? '⚠ ' : '') + (it.stateLabel || it.state), it.needsAdmin ? 'st-cancelled' : 'st-running'); td(tr, it.at ? dt(it.at) : '-'); td(tr, it.detail || '', 'wrap'); td(tr, it.userTodo || '', 'wrap'); + const act = document.createElement('td'); act.className = 'act'; + for (const a of it.actions || []) act.appendChild(mkBtn(a.label, 'ghost', async () => { if (!confirm(`${a.label} — 실행할까요?`)) return; try { const res = await api('/api/admin/recovery/action', { method: 'POST', body: JSON.stringify({ kind: it.kind, id: it.id, action: a.id }) }); alert(res.detail || res.message || T('실행했습니다')); loadRecovery(); } catch (e) { alert(e.message); } })); + if (it.siteId && it.kind === 'deploy') act.appendChild(mkBtn('사이트', 'ghost', () => openSite(it.siteId))); + tr.appendChild(act); tb.appendChild(tr); + } +} +async function loadAudit() { + const q = new URLSearchParams(); for (const [k, id] of [['userId', 'auUser'], ['siteId', 'auSite'], ['action', 'auAction'], ['q', 'auQ']]) { const v = $(id).value.trim(); if (v) q.set(k, v); } + const tb = $('auTable').querySelector('tbody'); tb.innerHTML = ''; + try { const r = await api('/api/admin/audit?' + q.toString()); for (const e of r.items || []) { const tr = document.createElement('tr'); td(tr, dt(e.at)); td(tr, e.by || ''); td(tr, e.action); td(tr, `${e.targetType || ''} ${e.target || ''}${e.userId && e.userId !== e.target ? ' / ' + e.userId : ''}`, 'wrap'); td(tr, e.siteId ? e.siteId.slice(0, 8) + '…' : ''); td(tr, e.ok === false ? '실패' : '완료', e.ok === false ? 'st-error' : 'st-done'); td(tr, e.detail ? (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)).slice(0, 300) : '', 'wrap'); tb.appendChild(tr); } if (!(r.items || []).length) { const tr = document.createElement('tr'); td(tr, T('기록이 없습니다')).colSpan = 7; tb.appendChild(tr); } } + catch (e) { const tr = document.createElement('tr'); td(tr, T('실패') + ': ' + e.message).colSpan = 7; tb.appendChild(tr); } +} +if ($('rcRefresh')) { $('rcRefresh').onclick = loadRecovery; $('auSearch').onclick = loadAudit; } diff --git a/modules/webmaker/public/app.js b/modules/webmaker/public/app.js new file mode 100644 index 0000000..0a72147 --- /dev/null +++ b/modules/webmaker/public/app.js @@ -0,0 +1,1180 @@ +'use strict'; +const $ = (id) => document.getElementById(id); +const fmt = (n) => (n || 0).toLocaleString(uiLocale()); +const fmtCost = (c) => '$' + (c || 0).toFixed(4); +const fmtDur = (ms) => ms ? (ms / 1000).toFixed(1) + 's' : ''; + +let current = null; // 현재 사이트 (서버 응답) +let es = null; // EventSource +let config = { publicBaseUrl: location.origin, tiers: [] }; let credit = null; + +/** + * 서버 API 호출 공통 함수. + * v1.2.0: 화면 언어(I18N.lang)를 X-UI-Lang 헤더로 보낸다. 서버는 사이트를 만들거나 수정 지시를 받을 때 + * 이 값을 site.uiLang 에 저장하고, AI 가 생성 코드에 다는 주석을 그 언어로 쓰게 한다. + */ +/** + * 생성 옵션 "검색·AI 최적화" 체크 값 읽기 (v1.3.5). 각 시작 카드(AI·내가 고르기·복사) 안의 [data-seo-opts] 블록. + * @param {string} cardId 시작 카드 요소 ID + * @returns {{seo: boolean, aeo: boolean, geo: boolean}} + */ +function seoOptsOf(cardId) { const box = document.querySelector(`#${cardId} [data-seo-opts]`); const on = (k) => !!(box && box.querySelector(`input[data-opt="${k}"]`) && box.querySelector(`input[data-opt="${k}"]`).checked); return { seo: on('seo'), aeo: on('aeo'), geo: on('geo') }; } +/** + * v1.3.10 다국어 생성 옵션: 생성 카드의 [data-lang-opts] 에 기본 언어 선택과 추가 언어 체크를 그린다 (언어 목록은 /api/config 의 siteLocales). + * 언어 이름은 그 언어로 쓴 이름(English, 日本語 …)과 화면 언어 이름을 같이 보인다. + */ +function fillLangOpts() { + const locs = (config && config.siteLocales) || []; if (!locs.length) return; + for (const box of document.querySelectorAll('[data-lang-opts]')) { + const sel = box.querySelector('[data-lang-default]'); const ex = box.querySelector('[data-lang-extra]'); const keepDef = sel.value || 'ko'; const keepOn = new Set([...ex.querySelectorAll('input:checked')].map((i) => i.value)); + sel.innerHTML = ''; ex.innerHTML = ''; + for (const l of locs) { + sel.appendChild(new Option(`${l.name} — ${T(l.label)}`, l.code)); + const lab = document.createElement('label'); lab.className = 'chk lang-chip'; const c = document.createElement('input'); c.type = 'checkbox'; c.value = l.code; c.checked = keepOn.has(l.code); + lab.appendChild(c); lab.appendChild(document.createTextNode(` ${l.name}`)); lab.title = T(l.label) + (l.dir === 'rtl' ? ' · ' + T('오른쪽→왼쪽') : ''); ex.appendChild(lab); + } + sel.value = keepDef; + // v1.3.16 x-default 선택: 고른 언어들 + 언어 선택 페이지 (추가 언어가 있을 때만 보인다) + const xs = box.querySelector('[data-lang-xdefault]'); const xwrap = box.querySelector('.lang-xdef'); + const fillX = () => { if (!xs) return; const keep = xs.value; const chosen = [sel.value, ...[...ex.querySelectorAll('input:checked')].map((i) => i.value).filter((c) => c !== sel.value)]; xs.innerHTML = ''; for (const c of chosen) { const l = locs.find((x) => x.code === c); xs.appendChild(new Option(`${l ? l.name : c} (${c})`, c)); } xs.appendChild(new Option(T('언어 선택 페이지 (자동 이동 없음)'), 'root')); xs.value = [...xs.options].some((o) => o.value === keep) ? keep : sel.value; xwrap.classList.toggle('hidden', chosen.length < 2); }; + const sync = () => { for (const i of ex.querySelectorAll('input')) { const isDef = i.value === sel.value; i.disabled = isDef; if (isDef) i.checked = false; i.parentElement.classList.toggle('off', isDef); } fillX(); }; + sel.onchange = sync; ex.onchange = fillX; sync(); + } +} +/** 생성 카드의 다국어 옵션 값: { default, list } — 추가 언어가 없으면 단일 언어 */ +function langOptsOf(cardId) { + const box = document.querySelector(`#${cardId} [data-lang-opts]`); if (!box) return null; + const def = box.querySelector('[data-lang-default]').value || 'ko'; const extra = [...box.querySelectorAll('[data-lang-extra] input:checked')].map((i) => i.value).filter((c) => c !== def); + const xs = box.querySelector('[data-lang-xdefault]'); + return { default: def, list: [def, ...extra], xDefault: xs && xs.value ? xs.value : def }; +} +/** + * v1.6.31 (요구 4): 서비스 API 는 세션 쿠키만으로는 쓸 수 없고, 출처가 이 화면(Referer)이거나 X-WM-CSRF 헤더가 있어야 한다. + * 토큰은 /api/auth/csrf 가 "출처가 빌더·관리자 화면인 요청" 에만 준다 — 같은 주소에서 열린 생성 사이트 페이지는 받을 수 없다. + */ +let _csrf = null; let _csrfP = null; +async function csrf() { if (_csrf) return _csrf; if (!_csrfP) _csrfP = fetch('/api/auth/csrf', { headers: { 'X-UI-Lang': (window.I18N && window.I18N.lang) || 'ko' } }).then((r) => r.ok ? r.json() : {}).then((b) => { _csrf = (b && b.csrf) || ''; return _csrf; }).catch(() => ''); return _csrfP; } +async function api(path, opt = {}) { + const headers = opt.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; + if (!/^\/api\/auth\/csrf/.test(path)) { const tok = await csrf(); if (tok) headers['X-WM-CSRF'] = tok; } + headers['X-UI-Lang'] = (window.I18N && window.I18N.lang) || 'ko'; + const r = await fetch(path, { headers, ...opt }); + const body = await r.json().catch(() => ({})); + if (r.status === 401) { location.href = '/login'; throw new Error('로그인이 필요합니다'); } + if (!r.ok) throw Object.assign(new Error(body.error || `HTTP ${r.status}`), { status: r.status, body }); // v1.4.0: 오류 응답 본문(예: 바뀐 견적 quote)을 화면 코드가 쓸 수 있게 + return body; +} + +// ---------- 탭 (생성 / 수정 / 관리 / 도메인 / 설정) ---------- +/** + * 좌측 패널의 탭 (v1.4.14 에서 "수정" 탭 신설, 순서 고정: 생성 → 수정 → 관리 → 도메인 → 설정). + * - 생성 : 새 사이트 생성 ~ 생성 완료 + * - 수정 : 사이트 고치기 — 수정 지시·기본 사항·발행(배포)·다국어·검색 최적화·검색 등록·버전 되돌리기·첨부 (1.4.5 까지 관리 탭에 있던 것) + * - 관리 : 사이트 운영 — 기능 모듈·사이트 관리자 페이지·메일·문의 접수·사이트 현황·현황 분석·캐시 사용·사이트 설정(폐기). + * 이 탭에서는 오른콴 미리보기 창이 "사이트 관리 창"(사이트 관리자 페이지 + 현황)으로 바뀐다 (public/manage-ui.js) + * - 도메인: 서브 도메인·도메인 구매·DNS (public/domains-ui.js) - 설정: 계정 + * 진행 상태 상자는 어느 탭에서나 위쪽에 그대로 보인다. + */ +/** + * v1.6.40 (요구 1): 다섯 탭을 "작업 흐름" 내비게이션(왼쪽 레일)으로 재구성했다. 값은 레일 버튼의 data-tab 과 같다. + * - home : 작업 흐름 지도 — 만들기 → 수정 → 도메인 → DNS·메일 → 배포 → 운영 의 상태와 다음 할 일 (public/flow-ui.js) + * - create : 만들기(1.6.39 까지 "생성") - edit : 수정(발행·배포 카드는 deploy 로 옮김) + * - deploy : 배포 — 점검표 + 발행·배포 · 원본 서버 반영 · 검색사이트 등록 (이전 단계가 갖춰져야 하는 마지막 작업) + * - manage : 운영(1.6.39 까지 "관리") — 오른쪽 캔버스가 사이트 관리 창으로 바뀐다 + * - domains / dns / mail : 도메인(구매·등록·연결) · DNS(레코드·전파·확인) · 메일(MX 자동 설정·웹메일) — 서로 독립적으로 열 수 있지만 배포·메일은 DNS 에 의존한다 (public/domains-ui.js) + * - credits : 캐시·충전 — 잔액·선불 충전 상품·주문 (public/credits-ui.js) - settings : 계정 + */ +const TABS = ['home', 'create', 'edit', 'deploy', 'manage', 'domains', 'dns', 'mail', 'credits', 'settings']; +/** 사이트를 "고른" 상태로 다루는 화면 — 수정·배포·운영은 같은 사이트(manageId)를 가리킨다 */ +const SITE_TABS = ['edit', 'deploy', 'manage']; +/** 레일에서 고른 화면의 이름·설명 (작업 칸 머리 #paneTitle · #paneDesc). 사이트와 무관한 설명은 짧게, 의존 관계는 한 줄로 */ +const PANE_META = { + home: ['홈', '사이트 만들기부터 배포·운영까지, 지금 어디까지 되었고 다음에 무엇을 하면 되는지 한눈에 봅니다.'], + create: ['만들기', 'AI 에게 맡기거나 직접 고르거나 템플릿·기존 사이트로 새 사이트를 만듭니다. 다른 작업과 무관하게 바로 시작할 수 있습니다.'], + edit: ['수정', '수정 지시·기본 사항·다국어·검색 최적화·버전 되돌리기. 수정은 배포와 무관하게 계속할 수 있고, 배포는 "배포" 화면에서 합니다.'], + deploy: ['배포', '사이트 내용·도메인·DNS 가 갖춰졌을 때 운영 주소로 올립니다. 배포 뒤의 수정은 "수정" 에서, 운영은 "운영" 에서 이어집니다.'], + manage: ['운영', '기능 모듈·사이트 관리자 페이지·문의·접속 통계·캐시 사용. 오른쪽에 사이트 관리 창이 열립니다.'], + domains: ['도메인', '도메인을 사거나(장바구니) 이미 가진 도메인을 등록하고 사이트에 연결합니다. 사이트 제작과 무관하게 먼저 준비해 둘 수 있습니다.'], + dns: ['DNS', '도메인의 DNS 레코드와 전파 상태입니다. 웹 제작과는 무관하지만 배포(웹서버 연결)와 메일(MX)이 이 설정에 달려 있습니다.'], + mail: ['메일', '도메인에 메일(MX·SPF·DMARC·DKIM)을 자동 설정하고 사이트 메일 시스템·웹메일을 확인합니다. DNS 가 준비된 도메인에서 동작합니다.'], + credits: ['캐시 · 충전', '작업 비용은 선불 적립 캐시에서 차감됩니다. 잔액·사용 내역을 보고 선불 충전 상품을 골라 충전을 신청합니다.'], + settings: ['설정', '계정 정보 · 내 문의 내역 · 비밀번호'], +}; +let activeTab = 'home'; +/** + * v1.1.0: 생성 탭과 수정·관리 탭은 서로 다른 사이트를 가리킬 수 있다. + * - createId : 생성 탭에서 만들고 있는(아직 생성이 끝나지 않은) 사이트 + * - manageId : 수정·관리 탭에서 고른 사이트 (두 탭은 같은 사이트를 공유한다 — 고친 사이트를 바로 관리하는 흐름) + * 탭을 바꾸면 그 탭의 사이트를 다시 열어(`openSite`) 화면과 진행 상태 스트림을 그 사이트로 맞춘다. + */ +let createId = null, manageId = null; try { createId = localStorage.getItem('createSiteId') || null; manageId = localStorage.getItem('currentSiteId') || null; } catch (_) {} +// 가져온(import)·복사한(copy) 사이트는 기획 단계가 없으므로 "생성 중" 으로 보지 않는다 (복사 중에는 관리 탭의 진행 상태 상자로 표시) +const inProgress = (site) => !!(site && site.plan && !['generated', 'published'].includes(site.plan.stage) && !['import', 'copy', 'template'].includes(site.mode)); +let switching = false; +/** + * v1.3.17 (B21): 소스 편집기에 저장하지 않은 변경이 있으면 다른 사이트로 바꾸기 전에 묻는다 + * (파일을 바꿀 때만 묻고, 사이트를 바꾸면 말없이 지워지던 문제). 같은 사이트면 묻지 않는다. + * @param {string|null} targetId 열려는 사이트 (null = 새 사이트 만들기 화면) + */ +function discardOk(targetId) { + if (!ed.dirty || (current && targetId === current.id)) return true; + if (!confirm(T('저장하지 않은 소스 변경이 있습니다. 버리고 다른 사이트로 바꿀까요?'))) return false; + ed.dirty = false; return true; +} +/** + * 탭 버튼·왼쪽 패널·오른쪽 창을 탭에 맞춘다 (화면만 바꾸고 사이트는 열지 않는다). + * 오른쪽: 관리 탭이면 사이트 관리 창(#manageView), 그 밖에는 미리보기(도구 줄 + #previewArea). + */ +function applyTabView(name) { + activeTab = name; + document.querySelectorAll('#mainTabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === name)); + for (const t of TABS) $('pane-' + t).classList.toggle('hidden', t !== name); + const manage = name === 'manage'; + $('manageView').classList.toggle('hidden', !manage); $('previewArea').classList.toggle('hidden', manage); document.querySelector('.right > .toolbar').classList.toggle('hidden', manage); + if (manage && window.ManageUI) ManageUI.show(current); + // v1.6.40: 작업 칸 머리 — 화면 이름·설명·(사이트 화면이면) 사이트 이름 + const meta = PANE_META[name] || [name, '']; if ($('paneTitle')) { $('paneTitle').textContent = T(meta[0]); $('paneDesc').textContent = T(meta[1]); $('paneSite').textContent = current && (SITE_TABS.includes(name) || name === 'home') ? current.title : ''; } + if (window.FlowUI) FlowUI.onTab(name); +} +async function showTab(name) { + if (!TABS.includes(name)) name = 'create'; + { const target = name === 'create' ? createId : SITE_TABS.includes(name) ? manageId : (current && current.id); if (!['settings', 'domains', 'dns', 'mail', 'credits', 'home'].includes(name) && !discardOk(target || null)) return; } + applyTabView(name); + try { localStorage.setItem('activeTab', name); } catch (_) {} + if (switching) return; switching = true; + try { + if (name === 'create') { + if (createId && (!current || current.id !== createId)) await openSite(createId, { tab: 'create' }).catch(() => { createId = null; try { localStorage.removeItem('createSiteId'); } catch (_) {} showCreateChooser(); }); + else if (!createId) showCreateChooser(); + } else if (SITE_TABS.includes(name)) { + // 수정·관리: 고른 사이트가 아직 열려 있지 않으면 연다. 둘은 같은 사이트를 공유하므로 탭만 바꾸면 다시 열지 않는다 + if (manageId && (!current || current.id !== manageId)) await openSite(manageId, { tab: name }).catch(() => { manageId = null; resetToNew(false); }); + renderManageHint(); loadLedger(); + if (name === 'manage' && window.ManageUI) ManageUI.show(current); + } else if (['domains', 'dns', 'mail'].includes(name) && window.DomainsUI) DomainsUI.load(name); // v1.4.0 도메인 탭 → v1.6.40 도메인·DNS·메일 세 화면: 열 때마다 최신 상태(전파·잔액)로 + else if (name === 'credits' && window.CreditsUI) CreditsUI.load(); // v1.6.40 캐시·충전 + else if (name === 'home') { if (manageId && (!current || current.id !== manageId)) await openSite(manageId, { tab: 'home' }).catch(() => { manageId = null; resetToNew(false); }); if (window.FlowUI) FlowUI.renderHome(); } // v1.6.40 홈: 고른 사이트의 작업 흐름 + } finally { switching = false; syncPicker(); } +} +/** + * 상단 사이트 선택 상자를 지금 보이는 사이트에 맞춘다 (v1.2.0). + * 생성 탭에서 새 사이트 방식 선택 화면이면 "+ 새 사이트 만들기", 그 밖에는 현재 사이트. + */ +function syncPicker() { + const sel = $('siteSelect'); if (!sel) return; + const v = activeTab === 'create' ? (createId && current && current.id === createId ? createId : '') : (SITE_TABS.includes(activeTab) || activeTab === 'home' ? (manageId && current && current.id === manageId ? manageId : '') : (current ? current.id : '')); + sel.value = [...sel.options].some((o) => o.value === v) ? v : ''; + // v1.4.14 (요구 4): 운영 주소 줄 상자는 숨기지 않는다 — 고른 사이트가 없으면 안내 한 줄만 보여 높이를 유지한다 + if (!sel.value) renderSiteMetaEmpty(); +} +/** + * 사이트를 고르지 않은 상태의 상태·운영 주소 줄 (같은 높이 유지). + * v1.4.15: 표시 클래스는 `no-site` — 1.4.14 에서 `empty` 를 썼는데 이는 오른쪽 미리보기의 "빈 화면" 규칙(.empty = position:absolute; inset:0)과 + * 이름이 같아 이 줄이 화면 전체를 덮는 투명한 층이 되어 모든 클릭을 삼켰다(빌더가 멈춘 것처럼 보임). + */ +function renderSiteMetaEmpty() { $('siteStatePill').textContent = ''; $('siteLiveLine').textContent = T('사이트를 고르면 상태와 운영 주소가 여기에 표시됩니다'); $('siteMetaBox').classList.add('no-site'); } +/** 생성 탭을 "새 사이트 만들기" 초기 화면으로 (수정·관리 탭의 선택은 그대로) */ +function showCreateChooser() { if (window.Wizard) Wizard.showModeChooser(); $('planCard').classList.add('hidden'); } +/** 생성이 끝난 사이트를 수정 탭으로 넘기고 생성 탭은 비운다 (v1.4.14: 1.4.5 까지는 관리 탭으로) */ +function finishCreate(id) { manageId = id; try { localStorage.setItem('currentSiteId', id); } catch (_) {} createId = null; try { localStorage.removeItem('createSiteId'); } catch (_) {} showTab('edit'); } +/** 진행 중 생성을 버리고(실행 중이면 중단) 새로 시작 */ +async function resetCreate() { + if (createId) { try { const s = await api(`/api/sites/${createId}`); if (s.running) await api(`/api/sites/${createId}/cancel`, { method: 'POST', body: '{}' }); } catch (_) {} } + createId = null; try { localStorage.removeItem('createSiteId'); } catch (_) {} + if (current && (!manageId || current.id !== manageId)) { if (es) es.close(); current = null; prog.state = 'idle'; prog.label = ''; setStatus('idle'); } + showCreateChooser(); refreshSiteList(); +} +window.finishCreate = finishCreate; window.resetCreate = resetCreate; +/** 수정 중인 사이트를 생성 탭에서 다시 손보기(선택값 수정 후 재생성) 위해 생성 컨텍스트로 지정 */ +window.setCreating = (id) => { createId = id; try { localStorage.setItem('createSiteId', id); } catch (_) {} }; +document.querySelectorAll('#mainTabs button').forEach((b) => (b.onclick = () => showTab(b.dataset.tab))); +{ const cp = $('creditPill'); if (cp) cp.onclick = () => showTab('credits'); } // v1.6.40 잔여 캐시 → 캐시·충전 화면 +$('btnAbortCreate').onclick = async () => { if (!confirm('진행 중인 생성을 중단하고 새 사이트 만들기로 돌아갈까요? 지금까지 만들어진 내용은 위쪽 사이트 목록에 남습니다.')) return; await resetCreate(); }; +/** + * ? 도움말 풍선 (v1.1.4): 하나의 #helpTip 요소를 공유하며, 버튼 위치에 따라 위/아래로 뒤집고 + * 좌우로 밀어 항상 화면(뷰포트) 안에 들어오게 한다. PC 는 마우스오버, 터치는 눌러서 열고 닫는다. + */ +let tipFor = null; +function showTip(h) { + const tip = $('helpTip'); const text = h.getAttribute('data-tip'); if (!text) return; tipFor = h; + tip.textContent = text; tip.classList.remove('hidden', 'above', 'below'); + const r = h.getBoundingClientRect(); const vw = window.innerWidth, vh = window.innerHeight; const w = Math.min(300, vw - 16); + tip.style.width = w + 'px'; + let left = r.left + r.width / 2 - w / 2; left = Math.max(8, Math.min(left, vw - w - 8)); tip.style.left = left + 'px'; + const th = tip.offsetHeight || 60; const spaceAbove = r.top - 10; const below = spaceAbove < th + 8 && (vh - r.bottom) >= spaceAbove; + let top = below ? r.bottom + 10 : r.top - th - 10; top = Math.max(8, Math.min(top, vh - th - 8)); tip.style.top = top + 'px'; + tip.classList.add(below ? 'below' : 'above'); tip.style.setProperty('--ax', Math.max(12, Math.min(w - 12, r.left + r.width / 2 - left)) + 'px'); +} +function hideTip() { $('helpTip').classList.add('hidden'); tipFor = null; document.querySelectorAll('.help.open').forEach((x) => x.classList.remove('open')); } +document.addEventListener('mouseover', (e) => { const h = e.target.closest('.help'); if (h) showTip(h); else if (tipFor && !tipFor.classList.contains('open')) hideTip(); }); +document.addEventListener('click', (e) => { const h = e.target.closest('.help'); if (h) { e.preventDefault(); const open = h.classList.contains('open'); hideTip(); if (!open) { h.classList.add('open'); showTip(h); } } else hideTip(); }); +window.addEventListener('scroll', () => { if (tipFor) showTip(tipFor); }, true); window.addEventListener('resize', () => { if (tipFor) showTip(tipFor); }); +/** + * 수정·관리 탭 맨 위 안내 한 줄 (v1.4.14: 두 탭에 각각). + * - 사이트를 고르지 않았으면 무엇을 할 수 있는 탭인지, 생성이 끝나지 않은 사이트면 생성 탭으로 안내한다. + */ +function renderManageHint() { + const eh = $('editHint'); const mh = $('manageHint'); + if (!current) { + eh.textContent = '왼쪽 레일에서 사이트를 고르면 수정 지시·기본 사항·다국어·검색 최적화·버전 되돌리기를 할 수 있습니다. 새 사이트는 "만들기" 에서 만들고, 발행·배포는 "배포" 에서 합니다.'; + mh.textContent = '왼쪽 레일에서 사이트를 고르면 기능 모듈·사이트 관리자 페이지·문의 접수·접속 통계·캐시 사용을 관리할 수 있습니다. 오른쪽 창에 사이트 관리자 페이지와 현황이 열립니다.'; + return; + } + const p = current.plan; const generated = !p || ['generated', 'published'].includes(p.stage) || current.mode === 'import' || current.mode === 'copy'; + eh.textContent = generated ? '' : '이 사이트는 아직 생성이 끝나지 않았습니다. "만들기" 에서 기획을 이어서 진행하세요.'; + mh.textContent = generated ? '' : '이 사이트는 아직 생성이 끝나지 않았습니다. 생성이 끝나면 기능 모듈·관리자 페이지를 쓸 수 있습니다.'; +} +/** 캐시 원장(모든 사이트·충전 포함)을 받아 둔다. 화면에는 요약(잔액)만 두고, 전체 내역과 기간별 사용량은 "상세 보기" 대화상자에서 본다 (v1.1.4) */ +let ledgerData = null; +async function loadLedger() { + try { ledgerData = await api('/api/me/credit'); } catch (_) { return; } + if (ledgerData) renderCredit({ ...credit, ...ledgerData }); +} +/** KST 기준 일자/주(월요일 시작)/월 키 */ +const kstDay = (at) => new Date(new Date(at).getTime() + 9 * 3600000).toISOString().slice(0, 10); +const weekKey = (day) => { const d = new Date(day + 'T00:00:00Z'); const dow = (d.getUTCDay() + 6) % 7; d.setUTCDate(d.getUTCDate() - dow); return d.toISOString().slice(0, 10) + ' 주'; }; +/** 캐시 상세 보기: 이 사이트 작업 내역(전체) + 일간/주간/월간 사용량 + 전체 원장(모든 사이트·충전) */ +async function showUsageDetail() { + // v1.1.6: 대화상자를 넓히고 열 폭을 고정(table-layout: fixed)해 좌우 스크롤 없이 보이게, 기간별 표는 3열로 나란히 + if (!ledgerData) { try { ledgerData = await api('/api/me/credit'); } catch (e) { return alert(e.message); } } + // v1.2.0 (사용자 제안 반영): 열은 그대로 두고 간격만 줄여 가로 스크롤을 없앴다. + // 대화상자를 세 부분으로 나눈다 — 위(고정): 잔액 요약·일/주/월 합계, 가운데(세로 스크롤): 작업 내역·전체 히스토리, 아래(고정): 닫기. + const root = $('usageDialogBody'); root.innerHTML = ''; + const headBox = document.createElement('div'); headBox.className = 'ud-head'; root.appendChild(headBox); + const scrollBox = document.createElement('div'); scrollBox.className = 'ud-scroll'; root.appendChild(scrollBox); + let body = headBox; // sec/table 의 기본 출력 위치 (요약 부분이 끝나면 scrollBox 로 바꾼다) + const sec = (title, into) => { const h = document.createElement('div'); h.className = 'field-label'; h.textContent = title; (into || body).appendChild(h); }; + /** heads: [{text, num, col}] col = 열 폭 클래스(c-time/c-amt/c-kind/c-st), 없으면 남는 폭 */ + const table = (heads, rows, into) => { const w = document.createElement('div'); w.className = 'table-wrap usage-detail'; const t = document.createElement('table'); const cg = document.createElement('colgroup'); heads.forEach((h) => { const c = document.createElement('col'); if (h.col) c.className = h.col; cg.appendChild(c); }); t.appendChild(cg); const tr = document.createElement('tr'); heads.forEach((h) => { const th = document.createElement('th'); th.textContent = h.text; if (h.num) th.className = 'num'; tr.appendChild(th); }); t.appendChild(tr); for (const r of rows) { const row = document.createElement('tr'); r.forEach((c, i) => { const td = document.createElement('td'); td.textContent = c; if (heads[i] && heads[i].num) td.className = 'num'; row.appendChild(td); }); t.appendChild(row); } if (!rows.length) { const row = document.createElement('tr'); const td = document.createElement('td'); td.colSpan = heads.length; td.className = 'muted'; td.textContent = '내역이 없습니다'; row.appendChild(td); t.appendChild(row); } w.appendChild(t); (into || body).appendChild(w); }; + // v1.3.17 (B20): 기간 합계는 서버가 준 전체 사용 기록(usageAll, 최근 400일)으로 — 최근 30건 원장만 더하던 문제 + const usage = ledgerData.usageAll || (ledgerData.ledger || []).filter((e) => e.type === 'usage'); + const agg = (keyFn) => { const m = {}; for (const e of usage) { const k = keyFn(kstDay(e.at)); m[k] = (m[k] || 0) + (-e.amount); } return Object.entries(m).sort((a, b) => b[0].localeCompare(a[0])); }; + const info = document.createElement('div'); info.className = 'small'; info.textContent = `잔액 ${krwFmt(ledgerData.balance)} · 누적 사용 ${krwFmt(ledgerData.used)} · 누적 지급 ${krwFmt(ledgerData.granted)}`; body.appendChild(info); + const grid = document.createElement('div'); grid.className = 'usage-periods'; body.appendChild(grid); + const col = (title, heads, rows) => { const d = document.createElement('div'); sec(title, d); table(heads, rows, d); grid.appendChild(d); }; + col('일간 (최근 14일)', [{ text: '일자' }, { text: '사용', num: true, col: 'c-amt' }], agg((d) => d).slice(0, 14).map(([k, v]) => [k.slice(5), krwFmt(v)])); + col('주간 (최근 8주)', [{ text: '주 시작(월)' }, { text: '사용', num: true, col: 'c-amt' }], agg(weekKey).slice(0, 8).map(([k, v]) => [k.replace(' 주', '').slice(5), krwFmt(v)])); + col('월간', [{ text: '월' }, { text: '사용', num: true, col: 'c-amt' }], agg((d) => d.slice(0, 7)).slice(0, 12).map(([k, v]) => [k, krwFmt(v)])); + body = scrollBox; + if (current) { sec(`이 사이트 작업 내역 전체 (${current.title})`); const groups = groupCommands(current.commands || []); table([{ text: '종류', col: 'c-kind' }, { text: '지시' }, { text: '캐시', num: true, col: 'c-amt' }, { text: '상태', col: 'c-st' }], groups.map((g) => [g.label, g.instruction || '', g.krw ? krwFmt(g.krw) + (g.estimated ? '*' : '') : '-', g.hasRunning ? '실행 중' : (CMD_STATUS_LABEL[g.status] || fmtDur(g.ms))])); } + sec('전체 캐시 히스토리 (모든 사이트 · 충전 포함)'); const TYPE = { set: '설정', add: '충전', adjust: '조정', usage: '사용', refund: '환불', migrate: '이관' }; + table([{ text: '일시', col: 'c-time' }, { text: '내용' }, { text: '금액', num: true, col: 'c-amt' }, { text: '잔액', num: true, col: 'c-amt' }], (ledgerData.ledger || []).map((e) => [new Date(e.at).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }), `[${TYPE[e.type] || e.type}] ${e.memo || ''}`, `${e.amount > 0 ? '+' : ''}${krwFmt(e.amount)}`, krwFmt(e.balanceAfter)])); + const dlg = $('usageDialog'); if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', ''); +} +$('btnUsageDetail').onclick = showUsageDetail; $('usageDialogClose').onclick = () => $('usageDialog').close(); +/** 하단 요약 푸터는 1.1.4 에서 제거됨 — 계정 탭 한 줄만 유지 */ +function renderCreditFooter(info) { if (!info) return; $('pfCreditLine').textContent = `캐시 잔액 ${krwFmt(info.balance)} · 누적 사용 ${krwFmt(info.used)} · 누적 지급 ${krwFmt(info.granted)}`; } + +// ---------- 진행 표시기 ---------- +// 상세 진행 내용은 고객에게 보이지 않는다. 진행 중/완료/오류만 시각적으로 표시하고, 오류 메시지 한 줄만 노출한다. +const prog = { state: 'idle', label: '', startedAt: null, timer: null, finishedIn: null, deploy: false, deployResult: null }; +function fmtElapsed(ms) { const s = Math.max(0, Math.round(ms / 1000)); return s < 60 ? `${s}초` : `${Math.floor(s / 60)}분 ${s % 60}초`; } +function renderProgress() { + const box = $('progress'); box.className = 'progress ' + prog.state; + const t = $('progressText'), sub = $('progressSub'); + if (prog.state === 'running') { t.textContent = `${prog.label || '작업'} 진행 중…`; sub.textContent = prog.startedAt ? `경과 ${fmtElapsed(Date.now() - prog.startedAt)}` : ''; } + else if (prog.state === 'done') { t.textContent = `${prog.label || '작업'} 완료`; sub.textContent = prog.deploy && prog.deployResult ? `${prog.deployResult.redeploy ? '재배포' : '배포'} 성공${prog.finishedIn != null ? ' · 소요 ' + fmtElapsed(prog.finishedIn) : ''}` : (prog.finishedIn != null ? `소요 ${fmtElapsed(prog.finishedIn)}` : ''); } + else if (prog.state === 'error') { t.textContent = prog.deploy ? `${prog.label || '배포'} 실패` : `${prog.label || '작업'} 중 문제가 발생했습니다`; sub.textContent = prog.deploy ? '아래 안내를 확인한 뒤 다시 배포해 주세요' : '아래 안내를 확인하거나 다시 시도해 주세요'; } + else if (prog.state === 'waiting-credit') { t.textContent = '캐시 소진 — 충전 대기 중'; sub.textContent = '지금까지의 작업은 저장되었습니다. 캐시가 충전되면 남은 작업을 자동으로 이어서 진행합니다.'; } + else if (prog.state === 'cancelled') { t.textContent = `${prog.label || '작업'} 중단됨`; sub.textContent = prog.finishedIn != null ? `${fmtElapsed(prog.finishedIn)} 진행 후 중단 · 지금까지 만들어진 내용은 유지됩니다` : ''; } + else { t.textContent = '대기 중'; sub.textContent = '지시를 보내면 여기에 진행 상태가 표시됩니다'; } + $('btnCancel').classList.toggle('hidden', prog.state !== 'running' || prog.deploy); + if (prog.state !== 'running') { clearTimeout(prog.msgTimer); $('progressMsg').textContent = ''; } + renderDeployResult(); +} +/** v1.5.31 (요구 1·3): 배포 결과 상자는 없앴다 — 결과는 deploy 이벤트에서 메시지창(pushMsg)으로 들어간다. 호환용 빈 함수 */ +function renderDeployResult() {} +// v1.3.10: 통화 단위도 번역 사전("{1}원")을 따른다. v1.4.1: 관리자가 캐시 표시 단위를 바꾸면(/api/config cashUnit) 그 글자를 쓴다(번역하지 않음) +const krwFmt = (n) => { if (n == null) return '-'; const u = (typeof config !== 'undefined' && config && config.cashUnit) || '원'; const v = Math.round(n).toLocaleString(uiLocale()); return u === '원' ? TF('{1}원', v) : `${v}${/^[가-힣]+$/.test(u) ? '' : ' '}${u}`; }; +/** + * 작업 중 잔여 캐시 실시간 표시 (v1.2.0). + * 1.1.x 의 "이번 회차 캐시" 상자는 없애고, 헤더(버전 줄 오른쪽)의 잔여 캐시만 실시간으로 갱신한다. + * @param {object|null} u 토큰 사용량 (표시에는 쓰지 않음, 호출 호환용) + * @param {boolean} final 작업이 끝났으면 true (실시간 표시 강조 해제) + * @param {number|null} krw 이번 작업 추정/확정 캐시 (표시하지 않음) + * @param {number|undefined} left 남은 캐시. undefined 면 갱신하지 않음 + */ +function renderRunTokens(u, final, krw, left) { + const pill = $('creditBadge').parentElement; pill.classList.toggle('live', !final && !!u); + if (left === undefined || left === null) return; + const next = krwFmt(left); const el = $('creditBadge'); + if (el.textContent !== next) { el.textContent = next; pill.classList.add('bump'); setTimeout(() => pill.classList.remove('bump'), 160); } + pill.classList.toggle('neg', left <= 0); +} +/** 헤더·사용 내역의 캐시 잔액 표시 */ +function renderCredit(info) { + if (!info) return; credit = info; + $('creditBadge').textContent = krwFmt(info.balance); $('creditBadge').parentElement.classList.toggle('neg', info.balance <= 0); + // v1.4.1 관리자는 캐시 한도를 받지 않으므로 "충전 요청" 안내를 보이지 않는다. 금액(-)은 누적 사용액이라 그대로 표시 + const isAdmin = typeof config !== 'undefined' && config && config.user && config.user.role === 'admin'; + $('creditNote').textContent = info.balance <= 0 && !isAdmin ? '캐시가 없습니다. 캐시·충전 화면에서 충전하세요.' : ''; // v1.6.40: 사용자가 스스로 충전 신청 + $('tBalance').textContent = krwFmt(info.balance); if (info.used != null) renderCreditFooter(info); + if (window.FlowUI) FlowUI.onCredit(info); if (window.CreditsUI) CreditsUI.onCredit(info); // v1.6.40 레일 배지·캐시 화면 갱신 +} +/** 작업 단계 띠(지시 전달 → AI 작업 → 버전 저장 → 완료). 배포에는 쓰지 않는다 */ +function setStep(n) { + const ol = $('progressSteps'); if (n === 0) { ol.classList.add('hidden'); return; } ol.classList.remove('hidden'); + [...ol.children].forEach((li, i) => { li.className = i + 1 < n ? 'done' : (i + 1 === n ? 'now' : ''); }); +} +function setProgressMsg(text) { + if (prog.state === 'running' && !prog.deploy && text) { if (/버전을 저장/.test(text)) setStep(3); else if (/연결됨|구상|작성|수정|읽|확인|파일/.test(text)) setStep(2); } + const el = $('progressMsg'); if (el.textContent === text || prog.state !== 'running') return; + clearTimeout(prog.msgTimer); el.classList.add('flash'); prog.msgTimer = setTimeout(() => { if (prog.state === 'running') el.textContent = text; el.classList.remove('flash'); }, 120); +} +/** + * v1.5.27 (요구 1·2) 메시지창. 아래 작업 영역의 결과(경고·배포 결과·발행 취소·문의 메일 등)를 위쪽 상태 영역의 고정 크기 창에 쌓는다. + * - 최신 메시지가 맨 위, 지난 메시지는 아래로 밀려 창 안에서 스크롤된다(최대 MSG_MAX 개 보관). + * - 메시지마다 × 로 지우고, "모두 지우기" 로 비운다. 사이트를 바꾸면 그 사이트의 메시지만 보이도록 비운다. + * - 종류(kind): warn(오류·경고, 빨강) / ok(성공, 초록) / info(안내). 서버 로그 이벤트 중 level=warn 은 항상, info 는 notify 표시가 있을 때만 넣는다 + * (AI 작업 중 흐르는 진행 로그로 창이 넘치지 않게). + */ +const MSG_MAX = 50; let msgs = []; let msgSeq = 0; +function pushMsg(kind, text, extra) { + if (!text) return; + const top = msgs[0]; if (top && top.text === text && top.kind === kind && Date.now() - top.at < 3000) return; // 같은 문구가 연달아 오면 한 번만 + msgs.unshift({ id: ++msgSeq, kind, text, at: Date.now(), links: (extra && extra.links) || [], lines: (extra && extra.lines) || [] }); if (msgs.length > MSG_MAX) msgs.length = MSG_MAX; + renderMsgs(true); +} +function renderMsgs(flash) { + const box = $('msgBox'); const ul = $('msgList'); if (!box || !ul) return; + box.classList.toggle('hidden', !msgs.length); ul.innerHTML = ''; $('msgCount').textContent = msgs.length ? TF('{1}개', msgs.length) : ''; + msgs.forEach((m, i) => { + const li = document.createElement('li'); li.className = m.kind + (flash && i === 0 ? ' new' : ''); + const t = document.createElement('span'); t.className = 't'; t.textContent = new Date(m.at).toLocaleTimeString(uiLocale(), { hour: '2-digit', minute: '2-digit' }); li.appendChild(t); + const x = document.createElement('span'); x.className = 'x'; x.textContent = m.text; + for (const u of m.links) { x.appendChild(document.createTextNode(' ')); const a = document.createElement('a'); a.href = u; a.target = '_blank'; a.rel = 'noopener'; a.textContent = u; x.appendChild(a); } + for (const ln of m.lines || []) { const d = document.createElement('div'); d.className = 'ln'; if (ln.url) { const a = document.createElement('a'); a.href = ln.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = ln.url; d.appendChild(a); } d.appendChild(document.createTextNode(ln.text || '')); x.appendChild(d); } // v1.5.31 여러 줄(배포 주소별 판정) + li.appendChild(x); + const del = document.createElement('button'); del.type = 'button'; del.className = 'del'; del.title = T('이 메시지 지우기'); del.textContent = '×'; del.onclick = () => { msgs = msgs.filter((y) => y.id !== m.id); renderMsgs(false); }; li.appendChild(del); + ul.appendChild(li); + }); + ul.scrollTop = 0; +} +function clearMsgs() { msgs = []; renderMsgs(false); } +/** + * v1.5.31 (요구 1·3) 배포 결과를 메시지창에 한 건으로: 첫 줄 "배포 완료 (재배포)" / "배포 실패 — 원인", 다음 줄부터 주소마다 "주소 — 판정" (주소는 링크). + * 1.5.27 까지 진행 상자 아래 별도 상자(progressDeploy)에 보이던 내용이며, 이제 시각과 함께 쌓이고 지울 수 있다. + */ +function pushDeployMsg(ev) { + const items = (ev.access && ev.access.items) || []; const bad = items.filter((i) => !i.ok); + const head = ev.ok ? `${ev.label || '배포'} 완료 (${ev.redeploy ? '재배포' : '첫 배포'})` : `${ev.label || '배포'} 실패 — ${(ev.detail || '').slice(0, 300)}`; + const lines = items.length ? items.map((i) => ({ url: i.url, text: ` — ${i.note || (i.ok ? '정상' : '확인 필요')}` })) : (ev.ok ? (ev.urls || []).slice(0, 3).map((u) => ({ url: u, text: '' })) : []); + if (!ev.ok) lines.push({ text: '자세한 원인은 관리자에게 전달되었습니다. 접속 정보나 배포 서버 설정을 확인한 뒤 다시 시도해 주세요.' }); + pushMsg(ev.ok ? (bad.length ? 'warn' : 'ok') : 'warn', head, { lines }); +} +document.addEventListener('DOMContentLoaded', () => { const b = $('msgClear'); if (b) b.onclick = clearMsgs; }); +function logLine(ev) { + // 경고는 항상, 안내는 서버가 notify 로 표시한 것만 메시지창으로. 그 밖의 상세 로그는 표시하지 않는다. + if (!ev) return; + if (ev.level === 'warn') pushMsg('warn', ev.text); + else if (ev.notify && (ev.level === 'info' || !ev.level)) pushMsg(ev.notifyKind || 'ok', ev.text); +} +function setStatus(s, label, startedAt, deploy) { + if (s === 'running') { prog.deploy = !!deploy; if (deploy) prog.deployResult = null; } + else if (s === 'idle') { prog.deploy = false; prog.deployResult = null; } + else if (!deploy) prog.deploy = false; + const p = $('statusPill'); + const map = { idle: ['idle', '대기'], running: ['running', '실행 중'], error: ['error', '오류'], done: ['done', '완료'], cancelled: ['cancelled', '중단됨'], 'waiting-credit': ['waiting', '캐시 충전 대기'] }; + const [cls, txt] = map[s] || map.idle; + p.className = 'pill ' + cls; p.textContent = txt; + const running = s === 'running'; + if (running) { if (prog.state !== 'running') { prog.startedAt = startedAt ? new Date(startedAt).getTime() : Date.now(); renderRunTokens({ total: 0 }, false, null); if (!deploy) setStep(1); setProgressMsg('작업을 준비하고 있습니다'); } if (label) prog.label = label; if (deploy) setStep(0); } + else if (s === 'done' && !deploy && prog.state === 'running') setStep(4); else if (s !== 'done') setStep(0); + else if (prog.state === 'running') prog.finishedIn = Date.now() - (prog.startedAt || Date.now()); + prog.state = s === 'idle' && prog.state === 'done' ? 'done' : (map[s] ? s : 'idle'); + clearInterval(prog.timer); if (running) prog.timer = setInterval(renderProgress, 1000); + renderProgress(); + $('btnUpdate').disabled = running || s === 'waiting-credit' || !!(current && current.editLimit && current.editLimit.locked); if (window.Wizard) Wizard.setRunning(running); +} + +// ---------- 토큰 사용량 ---------- +const CMD_KIND = { create: '생성', update: '수정', 'plan-analyze': '이해 확인', 'plan-structure': '구조 제안', 'plan-design': '디자인 3안', 'plan-verify': '검증', 'site-analyze': '사이트 분석', 'seo-draft': '검색·AI 최적화 초안' }; +const CMD_STATUS_LABEL = { running: '실행 중', cancelled: '중단', interrupted: '끊김', 'paused-credit': '캐시 대기' }; +/** + * 캐시 사용 내역 (v1.1.2): 사용자에게는 "작업 단위" 합계만 보여 준다. + * - 생성: 이해 확인·구조 제안·디자인·생성·검증 등 생성에 들어간 단계들을 한 묶음으로 + * - 수정: 수정 지시 1건(+뒤따르는 자동 검증)을 한 묶음으로 + * 세부 단계별 금액은 관리자 콘솔에서만 본다. 묶음 행을 누르면 단계가 펼쳐진다(금액 없이 단계 이름만). + */ +function groupCommands(cmds) { + const groups = []; let cur = null; + const start = (label, c) => { cur = { label, items: [c], krw: 0, status: c.status, at: c.startedAt, instruction: c.instruction }; groups.push(cur); }; + for (const c of cmds) { + const ins = String(c.instruction || ''); + if (c.kind === 'create') { if (cur && cur.label === '생성' && !cur.items.some((x) => x.kind === 'create')) cur.items.push(c); else start('생성', c); cur.status = c.status; cur.instruction = '사이트 생성'; } + else if (c.kind === 'update' && /^\(검증 결과 자동 수정\)/.test(ins)) { start('검증 반영', c); cur.instruction = '검증에서 발견된 항목 자동 수정'; } + else if (c.kind === 'update' && /^\(이어서\)/.test(ins)) start('수정(이어서)', c); + else if (c.kind === 'update' && /^\(Brief 기반 재생성\)/.test(ins)) { start('재생성', c); cur.instruction = '선택값 변경 후 재생성'; } + else if (c.kind === 'update') start('수정', c); + else if (c.kind === 'plan-verify') { start('검증', c); cur.instruction = '검증 리포트 작성 (Brief 대조)'; } // 검증은 항상 별도 행으로 (v1.1.4) + else if (c.kind.startsWith('plan-')) { if (!cur || cur.label !== '생성' || cur.items.some((x) => x.kind === 'create')) start('생성', c); else cur.items.push(c); } + else start(CMD_KIND[c.kind] || c.kind, c); + } + for (const g of groups) { g.krw = g.items.reduce((a, c) => a + (c.chargeKrw || 0), 0); g.estimated = g.items.some((c) => c.usageEstimated); const last = g.items[g.items.length - 1]; g.status = g.items.some((c) => c.status === 'running') ? 'running' : last.status; g.ms = g.items.reduce((a, c) => a + (c.durationMs || 0), 0); g.hasRunning = g.items.some((c) => c.status === 'running'); } + return groups; +} +function renderUsage(site) { + const t = site.totals || {}; + $('tKrw').textContent = krwFmt(t.chargeKrw || 0); if (site.credit) renderCredit(site.credit); + const tb = $('usageTable').querySelector('tbody'); tb.innerHTML = ''; + const groups = groupCommands(site.commands || []); const older = groups.slice(0, Math.max(0, groups.length - 15)); + if (older.length) { const tr = document.createElement('tr'); tr.className = 'muted'; const td = document.createElement('td'); td.colSpan = 5; td.textContent = `이전 작업 ${older.length}건 — 캐시 ${krwFmt(older.reduce((a, g) => a + g.krw, 0))} (총액만 표시)`; tr.appendChild(td); tb.appendChild(tr); } + groups.slice(-15).forEach((g, gi) => { + const tr = document.createElement('tr'); tr.className = 'usage-group' + (g.status === 'error' || g.status === 'interrupted' ? ' err' : (g.status === 'cancelled' || g.status === 'paused-credit' ? ' cancelled' : '')); + const cells = [String(groups.length - Math.min(15, groups.length) + gi + 1), g.label, g.instruction || '', g.hasRunning ? '실행 중' : (g.krw ? krwFmt(g.krw) + (g.estimated ? '*' : '') : '-'), g.hasRunning ? '실행 중' : (CMD_STATUS_LABEL[g.status] || fmtDur(g.ms))]; + cells.forEach((v, i) => { const td = document.createElement('td'); td.textContent = v; if (i === 2) { td.className = 'instr'; td.title = g.instruction || ''; } tr.appendChild(td); }); + tr.title = '누르면 단계를 펼칩니다'; tb.appendChild(tr); + const subs = g.items.map((c) => { const sr = document.createElement('tr'); sr.className = 'usage-sub hidden'; [``, `└ ${CMD_KIND[c.kind] || c.kind}`, c.kind === 'update' || c.kind === 'create' ? '' : (c.instruction || ''), '', CMD_STATUS_LABEL[c.status] || fmtDur(c.durationMs)].forEach((v) => { const td = document.createElement('td'); td.textContent = v; sr.appendChild(td); }); tb.appendChild(sr); return sr; }); + tr.onclick = () => subs.forEach((sr) => sr.classList.toggle('hidden')); + }); + let tf = $('usageTable').querySelector('tfoot'); + if (!tf) { tf = document.createElement('tfoot'); $('usageTable').appendChild(tf); } + tf.innerHTML = ''; + const tr = document.createElement('tr'); + ['', '합계', `작업 ${groups.length}건`, krwFmt(t.chargeKrw || 0), ''].forEach((v) => { const td = document.createElement('td'); td.textContent = v; tr.appendChild(td); }); + tf.appendChild(tr); +} + +// ---------- 모델 등급 (기본 · 중급 · 고급) ---------- +function fillTiers() { + const sel = $('tierSelect'); const prev = sel.value; sel.innerHTML = ''; + for (const t of config.tiers || []) sel.appendChild(new Option(`${T(t.label)} — ${T(t.desc)}`, t.id)); // 서버 목록 문구를 조각별로 번역 + const want = current ? current.tier : (localStorage.getItem('preferredTier') || config.defaultTier); + sel.value = [...sel.options].some((o) => o.value === want) ? want : (prev || config.defaultTier || 'standard'); + if (![...sel.options].some((o) => o.value === sel.value)) sel.selectedIndex = 0; + renderTierGuide(); +} +/** 등급 선택 상자 공통: 생성(tierSelect)·수정(editTierSelect) */ +function fillEditTier(site) { + const sel = $('editTierSelect'); sel.innerHTML = ''; + for (const t of config.tiers || []) sel.appendChild(new Option(t.label, t.id)); + sel.value = (site && site.tier) || config.defaultTier || 'standard'; renderEditCost(); +} +const krwRange = (n) => (n == null ? T('산출 불가') : TF('약 {1} ~ {2}', krwFmt(Math.round(n * 0.6 / 100) * 100), krwFmt(Math.round(n * 1.6 / 100) * 100))); +/** 수정 카드: 선택한 등급의 수정 1회 예상 비용 */ +function renderEditCost() { + const g = (config.tierGuide || []).find((t) => t.id === $('editTierSelect').value); const el = $('editCostLine'); + el.innerHTML = g ? `${TF('수정 1회 예상 {1}', krwRange(g.editKrw))} ` : ''; +} +$('editTierSelect').onchange = renderEditCost; +/** 생성 탭: 등급별 특징·예상 비용 표 */ +function renderTierGuide() { + const box = $('tierGuideBox'); const list = config.tierGuide || []; if (!list.length) { box.classList.add('hidden'); return; } + const cur = $('tierSelect').value; + // 짧은 한 줄만 표에 두고, 자세한 설명은 ? 풍선으로 (화면이 복잡해지지 않게) + // v1.5.19 (요구 1): 특징 열 문구 — 서버의 등급 설명(tierGuide.summary)과 같은 내용 + const short = { basic: 'HTML5, CSS3, 자바스크립트 구조의 정적 웹페이지 생성', standard: '웹페이지의 시각적 완성도(UI/UX)와 심미성 우수', premium: '복잡한 UI/UX의 프런트엔드 아키텍쳐 구현' }; + box.innerHTML = `${list.map((t) => ``).join('')}
    등급특징생성 1회수정 1회
    ${T(t.title)}${T(short[t.id] || '')} ${krwRange(t.createKrw)}${krwRange(t.editKrw)}
    예상 비용(현재 환율·수수료 반영)
    `; +} +$('tierSelect').onchange = async () => { + const tier = $('tierSelect').value; try { localStorage.setItem('preferredTier', tier); } catch (_) {} + if (current) { try { await api(`/api/sites/${current.id}/model`, { method: 'PUT', body: JSON.stringify({ tier }) }); current.tier = tier; } catch (e) { alert('등급 변경 실패: ' + e.message); fillTiers(); } } + renderTierGuide(); +}; + +// ---------- 기본 사항 수정 ---------- +const BASICS = [['title', 'bTitle'], ['purpose', 'bPurpose'], ['content', 'bContent'], ['design', 'bDesign'], ['extra', 'bExtra'], ['contactEmail', 'bEmail']]; +function renderBasics(site) { + for (const [k, id] of BASICS) $(id).value = site[k] || ''; + updateBasicsState(); +} +function basicsDirty() { return !!current && BASICS.some(([k, id]) => $(id).value.trim() !== (current[k] || '')); } +function updateBasicsState() { + const dirty = basicsDirty(); + $('btnSaveBasics').disabled = !dirty; $('btnRevertBasics').disabled = !dirty; + const st = $('basicsState'); + if (dirty) { st.textContent = '변경됨 (미저장)'; st.className = 'basics-state'; } + else if (current && current.basicsChanged && current.basicsChanged.length) { st.textContent = '다음 지시에 반영 예정'; st.className = 'basics-state pending'; } + else { st.textContent = ''; } +} +for (const [, id] of BASICS) $(id).addEventListener('input', updateBasicsState); +$('btnSaveBasics').onclick = async () => { + if (!current) return; + const body = {}; for (const [k, id] of BASICS) body[k] = $(id).value; + if (!body.title.trim()) { alert('페이지 제목은 비울 수 없습니다.'); return; } + $('btnSaveBasics').disabled = true; + try { + const r = await api(`/api/sites/${current.id}`, { method: 'PATCH', body: JSON.stringify(body) }); + Object.assign(current, r); $('siteTitle').textContent = current.title; renderBasics(current); refreshSiteList(); + } catch (e) { alert('저장 실패: ' + e.message); $('btnSaveBasics').disabled = false; } +}; +$('btnRevertBasics').onclick = () => { if (current) renderBasics(current); }; + +// ---------- 첨부 파일 ---------- +function renderAttachments(site) { + const list = (site.attachments || []); + $('attachCard').classList.remove('hidden'); + $('attachCount').textContent = list.length ? `${list.length}개` : '없음'; + const ul = $('attachList'); ul.innerHTML = ''; + for (const a of list) { + const li = document.createElement('li'); + const url = site.url + a.rel; + if (a.kind === 'image') { const img = document.createElement('img'); img.className = 'thumb'; img.src = url + '?t=' + Date.now(); img.alt = a.name; li.appendChild(img); } + else { const d = document.createElement('div'); d.className = 'doc'; d.textContent = (a.name.split('.').pop() || 'DOC').toUpperCase().slice(0, 4); li.appendChild(d); } + const info = document.createElement('div'); info.className = 'info'; + const name = document.createElement('span'); name.className = 'name'; + const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener'; link.textContent = a.name; name.appendChild(link); + if (!a.usedInCommands || !a.usedInCommands.length) { const tag = document.createElement('span'); tag.className = 'tag'; tag.textContent = '다음 지시에 사용'; name.appendChild(tag); } + const meta = document.createElement('span'); meta.className = 'meta'; + meta.textContent = `${a.kind === 'image' ? '이미지' : '문서'} · ${fmtSize(a.size)} · ${a.rel}` + (a.usedInCommands && a.usedInCommands.length ? ` · 명령 #${a.usedInCommands.join(', #')}` : ''); + info.appendChild(name); info.appendChild(meta); li.appendChild(info); + const del = document.createElement('button'); del.className = 'del'; del.title = '삭제'; del.textContent = '×'; + del.onclick = async () => { + if (!confirm(`${a.name} 을(를) 삭제할까요?`)) return; + try { const r = await api(`/api/sites/${site.id}/uploads/${encodeURIComponent(a.name)}`, { method: 'DELETE' }); current.attachments = r.attachments; renderAttachments(current); } + catch (e) { alert('삭제 실패: ' + e.message); } + }; + li.appendChild(del); ul.appendChild(li); + } +} +function fmtSize(n) { return n < 1024 ? n + 'B' : n < 1048576 ? (n / 1024).toFixed(0) + 'KB' : (n / 1048576).toFixed(1) + 'MB'; } +// 첨부 대기 목록 (생성 폼 / 수정 폼). 드롭·선택한 파일을 모아 두고 요청 시 함께 보낸다. +const pending = { create: [], update: [] }; +function acceptedExt(name) { return !config.upload || config.upload.extensions.includes((name.split('.').pop() || '').toLowerCase()); } +function addPending(target, files) { + const rejected = []; + for (const f of files) { + if (!acceptedExt(f.name)) { rejected.push(f.name); continue; } + if (config.upload && f.size > config.upload.maxFileBytes) { rejected.push(f.name + ' (25MB 초과)'); continue; } + if (!pending[target].some((p) => p.name === f.name && p.size === f.size)) pending[target].push(f); + } + renderPending(target); + if (rejected.length) alert('첨부할 수 없는 파일: ' + rejected.join(', ')); +} +function renderPending(target) { + const ul = $(target === 'create' ? 'pendingCreate' : 'pendingUpdate'); ul.innerHTML = ''; + pending[target].forEach((f, i) => { + const li = document.createElement('li'); + const n = document.createElement('span'); n.className = 'pn'; n.textContent = f.name; n.title = f.name; + const sz = document.createElement('span'); sz.className = 'ps'; sz.textContent = fmtSize(f.size); + const x = document.createElement('button'); x.type = 'button'; x.textContent = '×'; x.title = '제거'; x.onclick = () => { pending[target].splice(i, 1); renderPending(target); }; + li.appendChild(n); li.appendChild(sz); li.appendChild(x); ul.appendChild(li); + }); +} +function appendFiles(fd, target) { for (const f of pending[target]) fd.append('files', f, f.name); } +function clearPending(target) { pending[target] = []; renderPending(target); } +async function uploadNow(files) { + if (!current || !files.length) return; + const fd = new FormData(); for (const f of files) fd.append('files', f, f.name); + try { const r = await api(`/api/sites/${current.id}/uploads`, { method: 'POST', body: fd }); current.attachments = r.attachments; renderAttachments(current); } + catch (err) { alert('업로드 실패: ' + err.message); } +} +function routeFiles(target, files) { + if (target === 'now') uploadNow([...files]); + else addPending(target, [...files]); +} +// 파일 선택 input → 대기 목록/즉시 업로드 +$('fFiles').onchange = (e) => { addPending('create', [...e.target.files]); e.target.value = ''; }; +$('fUpdFiles').onchange = (e) => { addPending('update', [...e.target.files]); e.target.value = ''; }; +// 드롭존 +for (const dz of document.querySelectorAll('.dropzone')) { + dz.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.add('dragover'); }); + dz.addEventListener('dragleave', () => dz.classList.remove('dragover')); + dz.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.remove('dragover'); hideOverlay(); routeFiles(dz.dataset.target, e.dataTransfer.files); }); +} +// 화면 전체 드롭: 사이트가 열려 있으면 즉시 업로드, 아니면 생성 폼 대기 목록에 추가 +let dragDepth = 0; +function hideOverlay() { dragDepth = 0; $('dropOverlay').classList.add('hidden'); } +document.addEventListener('dragenter', (e) => { + if (![...(e.dataTransfer.types || [])].includes('Files')) return; + dragDepth += 1; + $('dropOverlayText').textContent = current ? '파일을 놓으면 이 사이트에 바로 업로드됩니다' : '파일을 놓으면 생성 폼의 첨부 목록에 추가됩니다'; + $('dropOverlay').classList.remove('hidden'); +}); +document.addEventListener('dragleave', () => { dragDepth = Math.max(0, dragDepth - 1); if (!dragDepth) $('dropOverlay').classList.add('hidden'); }); +document.addEventListener('dragover', (e) => e.preventDefault()); +document.addEventListener('drop', (e) => { + e.preventDefault(); hideOverlay(); + if (!e.dataTransfer.files.length) return; + if (current) routeFiles('now', e.dataTransfer.files); else routeFiles('create', e.dataTransfer.files); +}); +window.addEventListener('blur', hideOverlay); + +// ---------- 수정 완료 안내 + 상세 내역 (v1.1.2) ---------- +const FILE_ST = { A: '추가', M: '수정', D: '삭제', R: '이름 변경' }; +/** 마지막 수정 작업 결과를 배너로. 상세는 대화상자(지시·AI 요약·바뀐 파일) */ +function renderDoneBanner(site, cmd) { + const b = $('doneBanner'); if (!cmd || cmd.kind !== 'update') { b.classList.add('hidden'); return; } + const ok = cmd.status === 'done'; b.classList.remove('hidden'); b.classList.toggle('fail', !ok); + $('dbTitle').textContent = ok ? '수정 완료' : (cmd.status === 'cancelled' ? '수정 중단됨' : cmd.status === 'paused-credit' ? '캐시 소진으로 일시 중지' : '수정 실패'); + const files = (cmd.git && cmd.git.files) || []; + $('dbMeta').textContent = `${new Date(cmd.finishedAt || cmd.startedAt).toLocaleString(uiLocale())} · ${files.length ? TF('파일 {1}개 변경', files.length) : T('파일 변경 없음')}${cmd.chargeKrw != null ? ` · ${T('캐시')} ${krwFmt(cmd.chargeKrw)}` : ''}`; // v1.3.10 조각 번역 + const summary = String(cmd.resultText || '').replace(/[\s\S]*?<\/brief>/, '').trim(); + $('dbSummary').textContent = ok ? (summary.split('\n').slice(0, 3).join('\n') || '요청한 내용을 반영했습니다. 우측 미리보기에서 확인하세요.') : (cmd.error || ''); + $('dbDetail').onclick = () => showCommandDetail(cmd); +} +$('dbClose').onclick = () => $('doneBanner').classList.add('hidden'); +function showCommandDetail(cmd) { + const d = $('detailDialog'); const body = $('ddBody2'); body.innerHTML = ''; + $('ddTitle2').textContent = `${cmd.kind === 'create' ? '생성' : '수정'} #${cmd.no} 상세 내역`; + const sec = (title, node) => { const h = document.createElement('div'); h.className = 'field-label'; h.textContent = title; body.appendChild(h); body.appendChild(node); }; + const ins = document.createElement('div'); ins.className = 'small'; ins.style.whiteSpace = 'pre-wrap'; ins.textContent = cmd.instruction || ''; sec('지시 내용', ins); + const res = document.createElement('div'); res.className = 'small'; res.style.whiteSpace = 'pre-wrap'; res.textContent = String(cmd.resultText || '').replace(/[\s\S]*?<\/brief>/, '').trim() || '(요약 없음)'; sec('AI 작업 요약 (어느 페이지를 어떻게 바꿨는지)', res); + const files = (cmd.git && cmd.git.files) || []; const ul = document.createElement('ul'); ul.className = 'detail-files'; ul.style.listStyle = 'none'; ul.style.padding = '0'; + for (const f of files) { const li = document.createElement('li'); const st = document.createElement('span'); st.className = 'fs ' + f.status; st.textContent = FILE_ST[f.status] || f.status; const pth = document.createElement('span'); pth.textContent = f.path; li.appendChild(st); li.appendChild(pth); ul.appendChild(li); } + if (!files.length) { const li = document.createElement('li'); li.className = 'muted'; li.textContent = '변경된 파일이 기록되지 않았습니다'; ul.appendChild(li); } + sec(`변경된 파일 ${files.length}개${cmd.git && cmd.git.stat ? ' — ' + cmd.git.stat : ''}`, ul); + const meta = document.createElement('div'); meta.className = 'muted small'; meta.textContent = `상태 ${CMD_STATUS_LABEL[cmd.status] || cmd.status} · 소요 ${fmtDur(cmd.durationMs)} · 캐시 ${cmd.chargeKrw != null ? krwFmt(cmd.chargeKrw) : '-'}${cmd.git && cmd.git.no ? ` · 버전 #${cmd.git.no}` : ''}`; body.appendChild(meta); + if (typeof d.showModal === 'function') d.showModal(); else d.setAttribute('open', ''); +} +$('ddClose2').onclick = () => $('detailDialog').close(); +// ---------- 제안 · 오류 신고 (v1.1.2) ---------- +/** + * 제안·오류 신고에 함께 보내는 사용 환경 (v1.1.6). 문제 재현에 필요한 정보만, 입력 내용·비밀번호는 넣지 않는다. + * 대화상자에 "보낼 정보 보기" 로 그대로 보여 준다(사전 고지). + */ +function collectEnv() { + const ua = navigator.userAgent || ''; const nav = navigator.userAgentData; + const browser = (() => { const m = ua.match(/(Edg|OPR|Whale|SamsungBrowser|Firefox|Chrome|Safari)\/([\d.]+)/); if (!m) return ''; const name = { Edg: 'Edge', OPR: 'Opera' }[m[1]] || m[1]; const ver = m[1] === 'Safari' ? ((ua.match(/Version\/([\d.]+)/) || [])[1] || m[2]) : m[2]; return `${name} ${ver}`; })(); + const os = (() => { if (/Windows NT ([\d.]+)/.test(ua)) return 'Windows ' + RegExp.$1; if (/Mac OS X ([\d_]+)/.test(ua)) return 'macOS ' + RegExp.$1.replace(/_/g, '.'); if (/Android ([\d.]+)/.test(ua)) return 'Android ' + RegExp.$1; if (/(iPhone|iPad).*OS ([\d_]+)/.test(ua)) return 'iOS ' + RegExp.$2.replace(/_/g, '.'); if (/Linux/.test(ua)) return 'Linux'; return nav && nav.platform ? nav.platform : (navigator.platform || ''); })(); + let tz = ''; try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) {} + return { + browser, os, userAgent: ua, mobile: nav ? !!nav.mobile : /Mobi|Android|iPhone/.test(ua), + screen: `${screen.width}x${screen.height}`, viewport: `${window.innerWidth}x${window.innerHeight}`, devicePixelRatio: window.devicePixelRatio || 1, colorDepth: screen.colorDepth, + language: navigator.language || '', uiLanguage: window.I18N ? I18N.lang : 'ko', timezone: tz, utcOffsetMin: -new Date().getTimezoneOffset(), + online: navigator.onLine, cookieEnabled: navigator.cookieEnabled, touchPoints: navigator.maxTouchPoints || 0, + appVersion: config.version || '', tab: activeTab, siteId: current ? current.id : '', siteMode: current ? (current.mode || '') : '', siteStage: current && current.plan ? current.plan.stage : '', siteStatus: current ? (current.status || '') : '', + progressState: prog.state, previewMode: $('previewArea').classList.contains('mobile') ? 'mobile' : 'pc', path: location.pathname, time: new Date().toISOString(), + }; +} +/** + * 지금 탭의 화면 이름 (v1.3.2). 1.3.1 까지는 탭 ID(create/manage/settings)를 그대로 써서 "manage 탭" 처럼 보였고 번역도 되지 않았다. + * 한국어 이름을 쓰면 번역 사전에 있는 탭 이름("관리 탭" 등)으로 바뀐다. + */ +const TAB_LABEL = { home: '홈 화면', create: '만들기 화면', edit: '수정 화면', deploy: '배포 화면', manage: '운영 화면', domains: '도메인 화면', dns: 'DNS 화면', mail: '메일 화면', credits: '캐시·충전 화면', settings: '설정 화면' }; // v1.6.40 새 화면 이름 +function tabLabel() { return TAB_LABEL[activeTab] || activeTab; } +function openFeedback() { + $('fbEnvPreview').classList.add('hidden'); $('fbEnvToggle').textContent = '보낼 정보 보기'; const d = $('fbDialog'); $('fbMsg').textContent = ''; $('fbContext').textContent = current ? `현재 사이트: ${current.title} · 화면: ${tabLabel()} (함께 전달됩니다)` : `화면: ${tabLabel()} (함께 전달됩니다)`; if (typeof d.showModal === 'function') d.showModal(); else d.setAttribute('open', ''); } +$('fbEnvToggle').onclick = () => { const pre = $('fbEnvPreview'); const show = pre.classList.contains('hidden'); if (show) pre.textContent = Object.entries(collectEnv()).map(([k, v]) => `${k}: ${v}`).join('\n'); pre.classList.toggle('hidden', !show); $('fbEnvToggle').textContent = show ? '닫기' : '보낼 정보 보기'; }; +$('btnFeedback').onclick = openFeedback; $('btnFeedback2').onclick = openFeedback; $('fbCancel').onclick = () => $('fbDialog').close(); +$('fbSend').onclick = async () => { + const title = $('fbTitle').value.trim(), body = $('fbBody').value.trim(); if (!title && !body) return ($('fbMsg').textContent = '내용을 입력하세요'); + $('fbSend').disabled = true; + try { await api('/api/feedback', { method: 'POST', body: JSON.stringify({ type: $('fbType').value, title, body, siteId: current ? current.id : null, page: `${activeTab} 탭 (${tabLabel()})`, env: collectEnv() }) }); $('fbTitle').value = ''; $('fbBody').value = ''; $('fbDialog').close(); alert('보냈습니다. AI 가 내용을 분석해 운영팀에 전달하며, 답변은 설정 탭 "내 문의 내역"에서 볼 수 있습니다.'); loadMyFeedback(); } + catch (e) { $('fbMsg').textContent = e.message; } + $('fbSend').disabled = false; +}; +const FB_TYPE = { inquiry: '문의', suggestion: '제안', bug: '오류', request: '요구' }; const FB_STATUS = { new: '접수', reviewing: '검토 중', done: '처리 완료', rejected: '처리 완료' }; // v1.2.0: 반려 없음 (예전 반려 글은 처리 완료로 표시) +/** + * 설정 탭 "내 제안 · 오류 신고" 목록 (v1.2.0 흐름 정리). + * 상태: 접수(new) → 검토 중(reviewing, AI 분석 완료) → 처리 완료(done, 운영팀 답변 저장 시 자동). + * 처리 완료된 글은 운영팀 답변을 목록에 바로 펼쳐 보여 준다 (예전에는 마우스를 올려야 보였음). + */ +async function loadMyFeedback() { + const ul = $('myFeedbackList'); let r; try { r = await api('/api/feedback'); } catch (_) { return; } + ul.innerHTML = ''; + for (const x of r.items.slice(0, 30)) { + const li = document.createElement('li'); li.className = 'fb-item'; + const head = document.createElement('div'); head.className = 'fb-head'; + const k = document.createElement('span'); k.className = 'fb-st ' + (x.status || 'new'); k.textContent = FB_STATUS[x.status] || x.status; + const t = document.createElement('span'); t.className = 'fb-title'; t.textContent = `[${FB_TYPE[x.type] || x.type}] ${x.title}`; t.title = x.body || ''; + const d = document.createElement('span'); d.className = 'muted small'; d.textContent = new Date(x.at).toLocaleDateString(uiLocale()); + head.appendChild(k); head.appendChild(t); head.appendChild(d); li.appendChild(head); + const admin = (x.comments || []).filter((c) => c.by === 'admin'); + if (admin.length) { for (const c of admin.slice(-2)) { const a = document.createElement('div'); a.className = 'fb-answer'; a.textContent = `운영팀 답변 (${new Date(c.at).toLocaleDateString(uiLocale())}): ${c.text}`; li.appendChild(a); } } + else if (x.status === 'reviewing') { const a = document.createElement('div'); a.className = 'muted small'; a.textContent = 'AI 검토를 마치고 운영팀이 확인하고 있습니다.'; li.appendChild(a); } + ul.appendChild(li); + } + if (!r.items.length) { const li = document.createElement('li'); li.className = 'muted'; li.textContent = '보낸 글이 없습니다'; ul.appendChild(li); } +} + +// ---------- 사이트 현황 분석 (v1.2.0, 사이트 관리하기·복사하기) ---------- +/** + * site.analysis 를 "사이트 현황 분석" 카드에 그린다. + * - stats : 파일 수·크기·페이지 수·확장자별 분포·감지된 기술(markers) + * - server : (사이트 관리하기) 웹서버 종류·가상 호스트(도메인/설정 파일)·문서 루트 + * - copy : (사이트 복사하기) 복사한 페이지·파일 수, 건너뛴 수, 서버 실행 페이지 여부 + * - ai : AI 요약 {summary, structure[], tech[], editability, issues[], suggestions[]} 또는 {error}/{skipped} + * 가져오거나 복사한 사이트가 아니면 카드를 숨긴다. + */ +function renderAnalysis(site) { + const card = $('analysisCard'); const existing = site && (site.mode === 'import' || site.mode === 'copy'); + if (!existing) { card.classList.add('hidden'); return; } + card.classList.remove('hidden'); const body = $('anBody'); body.innerHTML = ''; + const a = site.analysis; const busy = site.status === 'importing'; + $('anAt').textContent = a && a.at ? `(${new Date(a.at).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })})` : ''; + $('btnReanalyze').disabled = busy || !!site.running; + const line = (label, text) => { if (!text) return; const d = document.createElement('div'); d.className = 'an-line'; const b = document.createElement('b'); b.textContent = label; d.appendChild(b); d.appendChild(document.createTextNode(' ' + text)); body.appendChild(d); }; + const list = (label, items) => { if (!items || !items.length) return; const h = document.createElement('div'); h.className = 'field-label'; h.textContent = label; body.appendChild(h); const ul = document.createElement('ul'); ul.className = 'an-list'; for (const it of items) { const li = document.createElement('li'); li.textContent = String(it); ul.appendChild(li); } body.appendChild(ul); }; + if (!a) { line('', busy ? (site.mode === 'copy' ? '사이트를 복사하는 중입니다. 끝나면 현황을 분석합니다.' : '사이트를 가져오는 중입니다. 끝나면 현황을 분석합니다.') : '아직 분석하지 않았습니다. "다시 분석"을 누르세요.'); return; } + const st = a.stats || {}; + line('파일', [TF('{1}개', fmt(st.files)), fmtBytes(st.bytes), TF('HTML 페이지 {1}개', fmt(st.pageCount)), st.newestAt ? TF('최근 수정 {1}', new Date(st.newestAt).toLocaleDateString(uiLocale())) : ''].filter(Boolean).join(' · ')); + if ((st.byExt || []).length) line('종류', st.byExt.slice(0, 8).map((x) => `${x.ext} ${x.n}`).join(', ')); + if ((st.markers || []).length) line('감지된 기술', st.markers.join(', ')); + if (a.server) line('서버', [a.server.webServers && a.server.webServers.length ? '웹서버 ' + a.server.webServers.join(', ') : '', a.server.vhost && a.server.vhost.names && a.server.vhost.names.length ? '도메인 ' + a.server.vhost.names.join(', ') : '', a.server.vhost && a.server.vhost.conf ? '설정 ' + a.server.vhost.conf : '', a.server.docroot ? '경로 ' + a.server.docroot : ''].filter(Boolean).join(' · ')); + if (a.copy) line('복사', [TF('{1} 에서 페이지 {2}개', a.copy.from, a.copy.pages), TF('파일 {1}개', a.copy.assets), a.copy.skipped ? TF('상한으로 {1}개 건너뜀', a.copy.skipped) : '', a.copy.errors ? TF('가져오지 못한 주소 {1}개', a.copy.errors) : '', a.copy.dynamic ? T('서버 실행 페이지는 결과 화면만 복사됨') : ''].filter(Boolean).join(' · ')); + const ai = a.ai; + if (!ai) { const d = document.createElement('div'); d.className = 'muted an-wait'; d.textContent = 'AI 가 사이트 구성을 요약하는 중입니다…'; body.appendChild(d); return; } + if (ai.error || ai.skipped) { const d = document.createElement('div'); d.className = 'muted'; d.textContent = ai.skipped || ('AI 요약을 만들지 못했습니다: ' + ai.error); body.appendChild(d); return; } + if (ai.summary) { const p = document.createElement('p'); p.className = 'an-summary'; p.textContent = ai.summary; body.appendChild(p); } + line('수정 난이도', ai.editability); + list('구성', ai.structure); list('사용 기술', ai.tech); list('눈에 띄는 문제', ai.issues); list('개선 제안', ai.suggestions); +} +$('btnReanalyze').onclick = async () => { + if (!current) return; $('btnReanalyze').disabled = true; + try { const r = await api(`/api/sites/${current.id}/analyze`, { method: 'POST', body: '{}' }); if (r && r.site) { current = r.site; } renderAnalysis(current); } + catch (e) { alert('분석 실패: ' + e.message); $('btnReanalyze').disabled = false; } +}; + +// ---------- 사이트 정보: 접속 통계 · 네트워크 · 저장 공간 ---------- +/** 바이트를 읽기 쉬운 단위로 */ +function fmtBytes(n) { n = n || 0; if (n < 1024) return `${n} B`; if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`; if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`; return `${(n / 1073741824).toFixed(2)} GB`; } +let statsData = null; +/** + * GET /api/sites/:id/stats 결과. v1.7.2 (요구 2): 자세한 표·그래프는 오른쪽 캔버스의 사이트 정보 페이지(public/site-info.js)가 그리고, + * 가운데 칸의 카드에는 요약 타일(오늘·최근 7일·이번 달 송신·디스크)만 남긴다. 통계 자료는 statsData 에 두고 ManageUI.onStats 로 페이지를 갱신한다. + */ +function renderStats(st) { + if (!current) return; statsData = st; $('siteInfoCard').classList.remove('hidden'); + const useLog = st.sources.log; const pick = (a) => (useLog ? { v: a.visitors, pv: a.pv } : { v: a.beaconVisitors, pv: a.beaconPv }); + $('statsUpdated').textContent = st.updatedAt ? `(집계 ${new Date(st.updatedAt).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })})` : '(아직 집계된 접속이 없습니다)'; + const grid = $('statVisits'); grid.innerHTML = ''; + const t = (label, v, d) => { const x = document.createElement('div'); x.className = 'tot'; const k = document.createElement('span'); k.className = 'k'; k.textContent = label; const vv = document.createElement('span'); vv.className = 'v'; vv.textContent = v; const dd = document.createElement('span'); dd.className = 'd'; dd.textContent = d || ''; x.appendChild(k); x.appendChild(vv); x.appendChild(dd); grid.appendChild(x); }; + const today = pick(st.today || {}); const l7 = pick(st.last7 || {}); const mo = st.month || {}; + t(T('오늘'), `${fmt(today.v)}${T('명')}`, `${T('페이지뷰')} ${fmt(today.pv)}`); t(T('최근 7일'), `${fmt(l7.v)}${T('명')}`, `${T('페이지뷰')} ${fmt(l7.pv)}`); + t(T('이번 달 송신'), useLog ? fmtBytes(mo.bytesOut) : (mo.beaconBytesOut ? fmtBytes(mo.beaconBytesOut) + ' ' + T('(추정)') : '-'), useLog ? `${T('수신')} ${fmtBytes(mo.bytesIn)}` : ''); + const d = st.disk || {}; t(T('디스크'), fmtBytes((d.web != null ? d.web : d.total || 0) + ((st.db || {}).total || 0) + (d.mail || 0)), `${T('웹')} ${fmtBytes(d.web != null ? d.web : d.total || 0)} · DB ${fmtBytes((st.db || {}).total || 0)}${d.mail != null ? ' · ' + T('메일') + ' ' + fmtBytes(d.mail) : ''}`); +} +async function loadStats() { if (!current) return; try { renderStats(await api(`/api/sites/${current.id}/stats`)); if (window.ManageUI) ManageUI.onStats(statsData); } catch (e) { $('statsUpdated').textContent = '(통계를 불러오지 못했습니다)'; } } +$('btnStatsReload').onclick = loadStats; +// v1.7.2: 가운데 요약 카드 → 운영 화면의 오른쪽 사이트 정보 페이지 +{ const b = $('btnSiteInfoOpen'); if (b) b.onclick = () => { showTab('manage').then(() => { if (window.ManageUI) ManageUI.openInfo(); }); }; } + +// ---------- 문의 접수 내역 ---------- +async function renderInquiries() { + if (!current) return; $('inquiryCard').classList.remove('hidden'); + try { + const r = await api(`/api/sites/${current.id}/inquiries`); const ul = $('inquiryList'); ul.innerHTML = ''; + $('inquiryCount').textContent = r.inquiries.length ? `${r.inquiries.length}건` : '없음'; + if (window.ManageUI) ManageUI.onInquiries(r); // v1.4.14 관리 창 현황(문의 건수·미발송) + $('inquiryNote').textContent = r.contactEmail ? `수신 이메일: ${r.contactEmail} · 사이트의 문의 폼에서 보낸 내용이 이 주소로 메일 발송됩니다.` : '문의 수신 이메일이 없습니다. "홈페이지 기본 사항"에서 설정해야 문의 메일이 발송됩니다(접수 자체는 여기에 기록됩니다).'; + for (const q of r.inquiries.slice(0, 50)) { const li = document.createElement('li'); const k = document.createElement('span'); k.className = 'kind ' + (q.sent ? 'create' : 'manual-edit'); k.textContent = q.sent ? '발송됨' : (q.error ? '미발송' : '접수'); const b = document.createElement('span'); b.className = 'bname'; b.style.fontFamily = 'inherit'; b.style.whiteSpace = 'normal'; b.textContent = `${new Date(q.at).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} · ${q.name} (${q.email}${q.phone ? ', ' + q.phone : ''}) · ${q.subject || (q.kind === 'booking' ? '예약 문의' : '문의')}: ${q.message.slice(0, 80)}${q.message.length > 80 ? '…' : ''}`; b.title = q.message + (q.error ? '\n[미발송] ' + q.error : ''); li.appendChild(k); li.appendChild(b); if (!q.sent) { const idx = r.inquiries.length - 1 - r.inquiries.indexOf(q); const rb = document.createElement('button'); rb.className = 'ghost small-btn'; rb.textContent = '재발송'; rb.title = q.error || '메일이 발송되지 않았습니다'; rb.onclick = async () => { rb.disabled = true; try { await api(`/api/sites/${current.id}/inquiries/${idx}/resend`, { method: 'POST', body: '{}' }); renderInquiries(); } catch (e) { alert(e.message); rb.disabled = false; } }; li.appendChild(rb); } ul.appendChild(li); } + } catch (e) { $('inquiryNote').textContent = '접수 내역을 불러오지 못했습니다: ' + e.message; } +} +$('btnInquiryReload').onclick = renderInquiries; + +// ---------- 버전(되돌리기) ---------- +const VER_KIND = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원', deploy: '배포본', import: '가져옴' }; +const CMD_STATUS = { running: '실행 중', done: '완료', error: '오류', cancelled: '중단', interrupted: '끊김' }; +function renderGit(site) { + const list = site.versions || []; $('versionsCard').classList.remove('hidden'); + $('versionsCount').textContent = list.length ? `${list.length}개` : '없음'; + const ul = $('versionsList'); ul.innerHTML = ''; + for (const v of [...list].reverse()) { + const li = document.createElement('li'); + const k = document.createElement('span'); k.className = 'kind ' + v.kind; k.textContent = VER_KIND[v.kind] || v.kind; + const b = document.createElement('span'); b.className = 'bname'; b.style.fontFamily = 'inherit'; b.textContent = `#${v.no} · ${new Date(v.at).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} · ${v.message}`; b.title = v.message; + const r = document.createElement('button'); r.className = 'ghost small-btn'; r.textContent = '되돌리기'; r.disabled = v.no === list[list.length - 1].no; + r.onclick = async () => { + if (!confirm(`버전 #${v.no} (${v.message}) 시점으로 되돌릴까요? 현재 상태도 버전으로 남습니다.`)) return; + r.disabled = true; + try { const s = await api(`/api/sites/${current.id}/versions/${v.no}/restore`, { method: 'POST', body: '{}' }); Object.assign(current, s); renderGit(current); renderPreview(current, true); renderUsage(current); if (window.Wizard) Wizard.onSiteOpened(current); if (ed.open && ed.file) loadEditFile(ed.file); } + catch (e) { alert('되돌리기 실패: ' + e.message); r.disabled = false; } + }; + li.appendChild(k); li.appendChild(b); li.appendChild(r); ul.appendChild(li); + } +} + +// ---------- 미리보기 ---------- +function renderPreview(site, reload) { + const url = site.url; const live = site.liveUrl && site.liveUrl !== url ? site.liveUrl : null; + // 배포가 끝난 사이트는 32자리 미리보기 주소 대신 운영 서버 주소를 보여 준다 (미리보기 iframe 은 계속 내부 주소를 사용) + $('siteUrl').textContent = live ? `운영 주소 ${live}` : url; $('siteUrl').classList.toggle('live', !!live); $('siteUrl').title = live ? `미리보기 주소: ${url}` : ''; + $('btnOpen').href = live || url; $('btnOpen').textContent = live ? '운영 주소 열기' : '새 탭에서 열기'; $('btnOpen').classList.remove('disabled'); + $('btnReload').disabled = false; $('btnCopy').disabled = false; $('btnMobile').disabled = false; + const sel = $('fileSelect'); const prev = sel.value; sel.innerHTML = ''; + const htmls = (site.files || []).filter((f) => f.endsWith('.html')); + for (const f of htmls) { const o = document.createElement('option'); o.value = f; o.textContent = f; sel.appendChild(o); } + if (htmls.includes(prev)) sel.value = prev; else if (htmls.includes('index.html')) sel.value = 'index.html'; + sel.classList.toggle('hidden', htmls.length < 2); + const hasIndex = htmls.length > 0; + renderEmptyReason(site, hasIndex); + $('emptyState').classList.toggle('hidden', hasIndex); + $('frame').classList.toggle('hidden', !hasIndex); + if (hasIndex && reload) loadFrame(); +} +/** + * 미리보기할 HTML 이 없을 때의 안내 (v1.3.3). 1.3.2 까지는 가져오기·복사가 실패했거나 진행 중이어도 "새 사이트를 만들면…" + * 기본 안내만 보여, 무엇이 문제인지 알 수 없었다. 가져온·복사한 사이트는 상태에 맞는 이유를 보여 준다. + */ +let emptyDefaultHtml = null; +/** + * 사용자 첫 화면 페이지 (v1.4.5, v1.4.15 정리). + * "첫 화면" 은 서비스 홈페이지(로그인 화면)가 아니라 **빌더 오른쪽 창의 빈 화면** 이다 — 로그인한 사용자가 아직 아무 사이트도 + * 고르지(만들지) 않아 왼쪽 작업 영역이 비어 있을 때, 오른쪽에 "미리보기 없음" 대신 관리자가 고른 페이지를 보인다. + * 사이트를 고르면 그 사이트 미리보기로 바뀌고, 관리 탭에서는 사이트 관리 창이 대신 보인다. 로그인·관리자 콘솔·생성 사이트에는 영향이 없다. + * 1.4.14 까지는 빈 화면 안내(#emptyState)의 내용을 iframe 으로 바꿔치기했는데, 이제 전용 상자 #welcomeBox 에 두고 켜고 끈다 + * (sandbox: allow-same-origin 없음 — 그 페이지의 스크립트가 빌더 세션·저장소를 쓰지 못한다, v1.3.17 S02 와 같은 원칙). + * @param {boolean} on 보일지 여부 + */ +function showWelcome(on) { + const box = $('welcomeBox'); const fr = $('welcomeFrame'); const wp = config && config.welcomePage; + if (!on || !wp || !wp.url) { box.classList.add('hidden'); if (fr.getAttribute('src')) fr.removeAttribute('src'); return; } + fr.title = wp.title || T('첫 화면'); if (fr.getAttribute('src') !== wp.url) fr.src = wp.url; box.classList.remove('hidden'); +} +function renderEmptyReason(site, hasIndex) { + const box = $('emptyState'); if (emptyDefaultHtml === null) emptyDefaultHtml = box.innerHTML; + // 사이트를 고르기 전(첫 접속·새 사이트 만들기 화면)에만 첫 화면 페이지. 사이트가 있으면 그 사이트의 빈 화면 이유를 보인다 + const welcome = !site && !hasIndex && !!(config && config.welcomePage && config.welcomePage.url); + showWelcome(welcome); + if (welcome) { box.innerHTML = ''; return; } + if (hasIndex || !site || !['import', 'copy'].includes(site.mode)) { box.innerHTML = emptyDefaultHtml; return; } + const what = site.mode === 'copy' ? '복사' : '가져오기'; + const files = site.files || []; const php = files.find((f) => /^index\.(php|jsp|aspx?)$/i.test(f)); + let title, msg; + if (site.status === 'importing' || site.running) { title = `사이트 ${what} 중`; msg = '끝나면 여기에 미리보기가 표시됩니다. 진행 상황은 왼쪽 위 진행 상태에서 볼 수 있습니다.'; } + else if (site.importError) { title = `사이트 ${what}에 실패했습니다`; msg = site.importError; } + else if (php) { title = '미리보기할 수 없는 첫 페이지'; msg = `첫 페이지가 ${php} (서버에서 실행되는 프로그램)라 이 미리보기에서는 실행되지 않습니다. 위쪽 "운영 주소 열기" 로 원래 사이트에서 확인하세요. 파일 수정과 원본 서버 반영은 그대로 할 수 있습니다.`; } + else { title = '미리보기할 HTML 파일이 없습니다'; msg = files.length ? `가져온 파일 ${files.length}개 중 HTML 이 없습니다. 사이트 경로(문서 루트)가 맞는지 확인하세요.` : `가져온 파일이 없습니다. 사이트 경로(문서 루트)가 맞는지 확인하고 다시 ${what} 해 보세요.`; } + box.innerHTML = ''; const inner = document.createElement('div'); inner.className = 'empty-inner'; const h = document.createElement('h3'); h.textContent = title; const p = document.createElement('p'); p.textContent = msg; inner.appendChild(h); inner.appendChild(p); box.appendChild(inner); +} +function loadFrame() { + if (!current) return; + const f = $('fileSelect').value || 'index.html'; + $('frame').removeAttribute('srcdoc'); + $('frame').src = current.url + f + '?t=' + Date.now(); +} + +// ---------- 소스 편집기 ---------- +const TEXT_EXT = ['html', 'htm', 'css', 'js', 'mjs', 'json', 'txt', 'md', 'svg', 'xml', 'csv', 'yaml', 'yml']; +const ed = { open: false, file: null, saved: '', dirty: false, timer: null }; +const isText = (f) => TEXT_EXT.includes((f.split('.').pop() || '').toLowerCase()); + +function setEditorOpen(open) { + ed.open = open; + $('editorPane').classList.toggle('hidden', !open); $('splitter').classList.toggle('hidden', !open); + $('btnToggleSource').classList.toggle('active', open); + $('btnToggleSource').textContent = open ? '소스 닫기' : '소스 보기'; + try { localStorage.setItem('sourceOpen', open ? '1' : '0'); } catch (_) {} + if (open && current) { fillEditFiles(); if (!ed.file) loadEditFile($('fileSelect').value || 'index.html'); } +} +function fillEditFiles() { + const sel = $('editFile'); const prev = ed.file || sel.value; sel.innerHTML = ''; + const files = (current.files || []).filter(isText); + for (const f of files) { const o = document.createElement('option'); o.value = f; o.textContent = f; sel.appendChild(o); } + if (files.includes(prev)) sel.value = prev; +} +async function loadEditFile(rel) { + if (!current || !rel) return; + if (ed.dirty && !confirm('저장하지 않은 변경이 있습니다. 버리고 다른 파일을 열까요?')) { $('editFile').value = ed.file; return; } + try { + const r = await api(`/api/sites/${current.id}/files/${encodeURIComponent(rel)}`); + ed.file = rel; ed.saved = r.content; ed.dirty = false; + $('editor').value = r.content; $('editFile').value = rel; updateLineNos(); setDirty(false); + } catch (e) { alert('파일 열기 실패: ' + e.message); } +} +function setDirty(d) { + ed.dirty = d; + const el = $('editState'); el.textContent = d ? '변경됨 (미저장)' : '저장됨'; el.classList.toggle('dirty', d); + $('btnSave').disabled = !d; $('btnRevert').disabled = !d; +} +function updateLineNos() { + const n = $('editor').value.split('\n').length; + let s = ''; for (let i = 1; i <= n; i++) s += i + '\n'; + $('lineNos').textContent = s; $('lineNos').scrollTop = $('editor').scrollTop; +} +/** 편집 중인 HTML 을 저장 없이 미리보기에 반영 (srcdoc + ) */ +function applyLivePreview() { + if (!current || !ed.file || !$('liveApply').checked) return; + const ext = (ed.file.split('.').pop() || '').toLowerCase(); + if (ext !== 'html' && ext !== 'htm') return; // css/js 는 저장 후 새로고침으로 반영 + if ($('fileSelect').value !== ed.file) { $('fileSelect').value = ed.file; } + // v1.3.17 (B23): 기준 주소는 편집 중인 파일이 있는 폴더 (en/about.html → //en/) — 상대 경로 자원·링크가 저장된 페이지와 같게 풀린다 + const dir = ed.file.includes('/') ? ed.file.slice(0, ed.file.lastIndexOf('/') + 1) : ''; + const base = ``; + let html = $('editor').value; + if (/]*>/i.test(html)) html = html.replace(/]*>/i, (m) => m + base); + else html = base + html; + $('frame').removeAttribute('src'); $('frame').srcdoc = html; +} +async function saveEditFile() { + if (!current || !ed.file || !ed.dirty) return; + $('btnSave').disabled = true; + // v1.3.17 (B22): 보낸 내용·사이트·파일을 붙잡아 두고, 응답 뒤에는 그 내용만 "저장됨" 으로 본다 (저장 중에 더 친 글자는 저장 안 됨으로 남김) + const sent = { siteId: current.id, file: ed.file, content: $('editor').value }; + try { + const r = await api(`/api/sites/${sent.siteId}/files/${encodeURIComponent(sent.file)}`, { method: 'PUT', body: JSON.stringify({ content: sent.content }) }); + if (!current || current.id !== sent.siteId || ed.file !== sent.file) return; // 그 사이 다른 사이트·파일로 바뀜 + ed.saved = sent.content; setDirty($('editor').value !== sent.content); if (ed.dirty) $('btnSave').disabled = false; + if (r.files) { current.files = r.files; fillEditFiles(); } + if (r.git) { current.git = r.git; renderGit(current); } + $('frame').removeAttribute('srcdoc'); loadFrame(); + } catch (e) { alert('저장 실패: ' + e.message); $('btnSave').disabled = false; } +} +$('editor').addEventListener('input', () => { + setDirty($('editor').value !== ed.saved); updateLineNos(); + clearTimeout(ed.timer); ed.timer = setTimeout(applyLivePreview, 400); +}); +$('editor').addEventListener('scroll', () => { $('lineNos').scrollTop = $('editor').scrollTop; }); +$('editor').addEventListener('keydown', (e) => { + if (e.key === 'Tab') { e.preventDefault(); const t = e.target, st = t.selectionStart, en = t.selectionEnd; t.value = t.value.slice(0, st) + ' ' + t.value.slice(en); t.selectionStart = t.selectionEnd = st + 2; t.dispatchEvent(new Event('input')); } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveEditFile(); } +}); +$('btnSave').onclick = saveEditFile; +$('btnRevert').onclick = () => { if (!ed.dirty || confirm('변경 내용을 버리고 저장된 내용으로 되돌릴까요?')) { $('editor').value = ed.saved; setDirty(false); updateLineNos(); $('frame').removeAttribute('srcdoc'); loadFrame(); } }; +$('editFile').onchange = (e) => loadEditFile(e.target.value); +$('btnToggleSource').onclick = () => setEditorOpen(!ed.open); +$('liveApply').onchange = () => { if ($('liveApply').checked) applyLivePreview(); else { $('frame').removeAttribute('srcdoc'); loadFrame(); } }; +// 분할 크기 드래그 +(() => { + const sp = $('splitter'), pane = $('editorPane'); + let dragging = false; + sp.addEventListener('mousedown', (e) => { dragging = true; e.preventDefault(); document.body.style.cursor = 'row-resize'; $('frame').style.pointerEvents = 'none'; }); + window.addEventListener('mousemove', (e) => { if (!dragging) return; const box = $('frame').parentElement.getBoundingClientRect(); const h = Math.min(Math.max(box.bottom - e.clientY, 120), box.height - 120); pane.style.height = h + 'px'; }); + window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; document.body.style.cursor = ''; $('frame').style.pointerEvents = ''; }); +})(); +window.addEventListener('beforeunload', (e) => { if (ed.dirty) { e.preventDefault(); e.returnValue = ''; } }); + +// ---------- 사이트 로드 / 이벤트 ---------- +function connectEvents(id) { + if (es) es.close(); + es = new EventSource(`/api/sites/${id}/events`); + es.onmessage = (m) => { + const ev = JSON.parse(m.data); + if (ev.type === 'log') { logLine(ev); if (/문의 접수|문의가 접수/.test(ev.text || '')) renderInquiries(); } + else if (ev.type === 'progress') setProgressMsg(ev.text); + else if (ev.type === 'usage') renderRunTokens(ev.usage, false, ev.krwEstimate, ev.creditLeft); + else if (ev.type === 'status') setStatus(ev.status, ev.label, ev.startedAt, ev.deploy); + else if (ev.type === 'deploy') { prog.deploy = true; prog.deployResult = ev; renderProgress(); pushDeployMsg(ev); } // v1.5.31: 결과 상자 대신 메시지창 + else if (ev.type === 'plan') { if (window.Wizard) Wizard.onPlanEvent(ev); } + else if (ev.type === 'done') { + current = ev.site; + if (ev.deploy) setStatus(ev.deploy.ok ? 'done' : 'error', prog.label, null, true); else setStatus(!ev.command || ev.command.status === 'done' ? 'done' : (ev.command.status === 'cancelled' ? 'cancelled' : 'error')); + if (ev.command && ev.command.usage) renderRunTokens(ev.command.usage, true, ev.command.chargeKrw, ev.site && ev.site.credit ? ev.site.credit.balance : undefined); + if (ev.site && ev.site.credit) renderCredit(ev.site.credit); + if (ev.site && ev.site.status === 'waiting-credit') setStatus('waiting-credit', prog.label); + if (ev.command && ev.command.status === 'done') setStep(4); + fillEditTier(current); if (ev.command && ev.command.kind === 'update') renderDoneBanner(current, ev.command); + renderUsage(current); renderAttachments(current); renderGit(current); renderPreview(current, true); refreshSiteList(); loadStats(); loadLedger(); renderSiteMeta(current); renderEditLimit(current); renderAnalysis(current); renderMailLine(current); + if (!basicsDirty()) renderBasics(current); else updateBasicsState(); + if (window.Wizard) Wizard.onSiteOpened(current); + if (window.Importer) Importer.onSiteOpened(current); + if (window.OptimizeUI) OptimizeUI.onSiteOpened(current); + if (window.ManageUI) ManageUI.onSiteOpened(current); // v1.4.14 관리 창(현황·관리자 페이지) 갱신 + if (window.FlowUI) FlowUI.onSite(current); // v1.6.40 작업 흐름·배지 갱신 + if (ed.open) { fillEditFiles(); if (ed.file && !ed.dirty) loadEditFile(ed.file); else if (ed.file && ed.dirty) logLine({ level: 'warn', text: `AI 가 파일을 수정했습니다. 편집기의 ${ed.file} 은 저장되지 않은 이전 내용입니다. 되돌리기로 최신 내용을 불러올 수 있습니다.` }); } + } + }; + es.onerror = () => { /* 브라우저가 자동 재접속 */ }; +} +async function openSite(id, opts = {}) { + const site = await api(`/api/sites/${id}`); + current = site; + const prog_ = inProgress(site); + if (prog_) { createId = id; try { localStorage.setItem('createSiteId', id); } catch (_) {} } + else if (opts.tab !== 'create') { manageId = id; try { localStorage.setItem('currentSiteId', id); } catch (_) {} } + if (window.Wizard) Wizard.hideStart(); + $('siteTitle').textContent = site.title; $('siteId').textContent = site.id; + renderSiteMeta(site); renderEditLimit(site); renderAnalysis(site); renderMailLine(site); + // 열 탭: 지정 > 생성 중이면 생성 > 관리 탭을 보고 있었으면 관리 > 그 밖은 수정 (v1.4.14: 완성 사이트의 기본 탭은 "수정") + // v1.6.40: 완성 사이트는 보고 있던 사이트 화면(홈·수정·배포·운영)에 머무르고, 그 밖의 화면에서 골랐으면 "수정" 으로 + const wantTab = opts.tab || (prog_ ? 'create' : (['manage', 'deploy', 'home'].includes(activeTab) ? activeTab : 'edit')); + if (activeTab !== wantTab) { applyTabView(wantTab); try { localStorage.setItem('activeTab', wantTab); } catch (_) {} } + for (const id of ['attachCard', 'versionsCard', 'usageCard', 'siteInfoCard', 'inquiryCard', 'moduleCard']) $(id).classList.remove('hidden'); + $('updateCard').classList.toggle('hidden', !!prog_); $('btnDeleteSite').classList.remove('hidden'); $('siteDangerCard').classList.remove('hidden'); + fillEditTier(site); + prog.state = 'idle'; prog.label = ''; prog.deploy = false; prog.deployResult = null; clearMsgs(); /* v1.5.27: 사이트가 바뀌면 메시지창 비움 */ + if (site.deploying) setStatus('running', site.deploying.label, site.deploying.startedAt, true); else setStatus(site.running ? 'running' : (site.status === 'waiting-credit' ? 'waiting-credit' : 'idle')); + if (site.credit) renderCredit(site.credit); + if (!site.running && $('btnCancel')) $('btnCancel').classList.add('hidden'); + const lastCmd = [...(site.commands || [])].reverse().find((c) => c.usage); renderRunTokens(lastCmd ? lastCmd.usage : null, true, lastCmd ? lastCmd.chargeKrw : null, site.credit ? site.credit.balance : undefined); + renderUsage(site); renderAttachments(site); renderGit(site); renderBasics(site); renderPreview(site, true); connectEvents(id); fillTiers(); renderInquiries(); loadStats(); + { const lastUpd = [...(site.commands || [])].reverse().find((c) => c.kind === 'update'); if (lastUpd && !site.running && Date.now() - new Date(lastUpd.finishedAt || 0).getTime() < 6 * 3600000) renderDoneBanner(site, lastUpd); else $('doneBanner').classList.add('hidden'); } + if (window.Wizard) Wizard.onSiteOpened(site); + if (window.Importer) Importer.onSiteOpened(site); + syncPicker(); + if (window.ModulesUI) ModulesUI.onSiteOpened(site); + if (window.OptimizeUI) OptimizeUI.onSiteOpened(site); // v1.3.5 검색·AI 최적화·검색사이트 등록 + if (window.TemplatesUI) TemplatesUI.onSiteOpened(site); // v1.3.10 관리자: 템플릿으로 등록 + if (window.ManageUI) ManageUI.onSiteOpened(site); // v1.4.14 관리 탭 오른쪽 사이트 관리 창 + if (window.FlowUI) FlowUI.onSite(site); // v1.6.40 레일 상태 배지·홈 작업 흐름·배포 점검표 + if ($('paneSite')) $('paneSite').textContent = SITE_TABS.includes(activeTab) || activeTab === 'home' ? site.title : ''; + $('btnToggleSource').disabled = false; clearPending('update'); + ed.file = null; ed.dirty = false; $('editor').value = ''; setDirty(false); updateLineNos(); + let wantOpen = false; try { wantOpen = localStorage.getItem('sourceOpen') === '1'; } catch (_) {} + setEditorOpen(wantOpen); +} +/** 위쪽 사이트 선택 아래: 사이트 상태 배지와 운영 주소 (모든 탭 공통, 높이 고정) */ +function renderSiteMeta(site) { + $('ssSite').textContent = site ? `${site.title}` : ''; + const pill = $('siteStatePill'); const p = site.plan; + let txt = '생성 완료', cls = 'done'; + if (site.mode === 'import') { txt = site.status === 'importing' ? '가져오는 중' : (site.source && site.source.lastPushAt ? '원본 반영됨' : '가져옴'); if (site.status === 'importing') cls = 'running'; } + else if (site.mode === 'copy') { txt = site.status === 'importing' ? '복사 중' : (site.importError && !site.copyInfo ? '복사 실패' : (p && p.stage === 'published' ? '발행됨' : '복사함')); if (site.status === 'importing') cls = 'running'; } + else if (p && !['generated', 'published'].includes(p.stage)) { txt = '생성 중'; cls = 'running'; } + else if (p && p.stage === 'published') { txt = '발행됨'; } + else if (site.editLimit && site.editLimit.locked) { txt = '발행 대기'; cls = 'waiting'; } + pill.textContent = txt; pill.className = 'pill small ' + cls; + $('ssSite').textContent = `${site.title} · ${T(txt)}`; + $('siteLiveLine').textContent = (site.liveUrl ? TF('운영 주소: {1}', site.liveUrl) : T('아직 배포되지 않았습니다 (미리보기 주소만 사용 가능)')) + (site.mode === 'copy' && site.copyFrom ? ' · ' + TF('복사 원본: {1}', site.copyFrom) : ''); + $('siteMetaBox').classList.remove('hidden', 'no-site'); + renderManageHint(); +} +window.showTab = showTab; // v1.6.40: flow-ui.js·credits-ui.js 가 화면을 옮길 때 쓴다 (wizard.js 는 이미 전역으로 호출) +/** + * 메일 시스템 상태 줄 (v1.3.2, 관리 탭 공통 기능 카드). 운영자가 이 사이트의 메일 시스템을 켜지 않았으면 숨긴다. + * setting-up 은 "메일시스템 설정중"(DNS 전파·인증 대기), ready/send-only 는 "사용 가능" 과 웹메일 열기. + */ +function renderMailLine(site) { + const m = site && site.mail; const box = $('mailLine'); if (!box) return; + if (!m || m.level === 'off') { box.classList.add('hidden'); return; } + box.classList.remove('hidden'); $('mailLabel').textContent = m.label; $('mailLabel').className = 'pill small ' + (m.level === 'setting-up' ? 'waiting' : 'done'); + $('mailMsg').textContent = m.level === 'setting-up' ? `${m.domain} — 도메인 연결(DNS) 확인을 기다리는 중입니다` : `${m.domain} — 아이디@${m.domain}`; + $('mailOpen').classList.toggle('hidden', !m.webmailUrl || m.level === 'setting-up'); if (m.webmailUrl) $('mailOpen').href = m.webmailUrl; +} +/** AI에게 맡기기 수정 횟수 한도 표시·잠금 */ +function renderEditLimit(site) { + const lim = site.editLimit; const note = $('editLimitNote'); const box = $('editLockBox'); + if (!lim || !lim.max) { note.textContent = ''; box.classList.add('hidden'); $('btnUpdate').disabled = false; $('fInstruction').disabled = false; return; } + note.textContent = `수정 ${lim.used}/${lim.max}회`; + box.classList.toggle('hidden', !lim.locked); $('fInstruction').disabled = !!lim.locked; if (lim.locked) $('btnUpdate').disabled = true; + box.textContent = lim.locked ? `수정 횟수 한도(${lim.max}회)에 도달해 발행 대기 상태입니다. 아래 "발행 · 배포"에서 발행한 뒤 다시 수정할 수 있습니다.` : ''; +} +function resetToNew(clearManage = true) { + current = null; if (es) es.close(); + if (clearManage) { manageId = null; localStorage.removeItem('currentSiteId'); } + if (window.Wizard) Wizard.showModeChooser(); $('updateCard').classList.add('hidden'); $('planCard').classList.add('hidden'); $('importCard').classList.add('hidden'); $('publishCard').classList.add('hidden'); $('moduleCard').classList.add('hidden'); $('usageCard').classList.add('hidden'); $('optimizeCard').classList.add('hidden'); $('searchCard').classList.add('hidden'); $('tplRegisterCard').classList.add('hidden'); + $('siteTitle').textContent = ''; $('siteId').textContent = ''; $('ssSite').textContent = ''; $('btnDeleteSite').classList.add('hidden'); $('siteDangerCard').classList.add('hidden'); $('analysisCard').classList.add('hidden'); renderSiteMetaEmpty(); renderManageHint(); $('btnMobile').disabled = true; + $('langCard').classList.add('hidden'); if (window.ManageUI) ManageUI.onSiteOpened(null); // v1.4.14 관리 창 비우기 + if (window.FlowUI) FlowUI.onSite(null); if ($('paneSite')) $('paneSite').textContent = ''; // v1.6.40 + prog.state = 'idle'; prog.label = ''; prog.deploy = false; prog.deployResult = null; clearMsgs(); /* v1.5.27: 사이트가 바뀌면 메시지창 비움 */ setStatus('idle'); renderRunTokens(null, true, null, credit ? credit.balance : undefined); fillTiers(); + renderUsage({ totals: {}, commands: [] }); $('attachCard').classList.add('hidden'); $('versionsCard').classList.add('hidden'); $('inquiryCard').classList.add('hidden'); $('siteInfoCard').classList.add('hidden'); + $('siteUrl').classList.remove('live'); $('btnOpen').textContent = '새 탭에서 열기'; + ed.file = null; ed.dirty = false; ed.open = false; $('editorPane').classList.add('hidden'); $('splitter').classList.add('hidden'); $('btnToggleSource').disabled = true; $('btnToggleSource').classList.remove('active'); $('btnToggleSource').textContent = '소스 보기'; + $('siteUrl').textContent = '아직 생성된 페이지가 없습니다'; $('btnOpen').classList.add('disabled'); $('btnReload').disabled = true; $('btnCopy').disabled = true; + $('fileSelect').classList.add('hidden'); renderEmptyReason(null, false); $('emptyState').classList.remove('hidden'); $('frame').classList.add('hidden'); $('frame').src = 'about:blank'; + $('siteSelect').value = ''; +} +/** + * 상단 사이트 선택 상자 채우기 (v1.2.0). + * 맨 위는 "+ 새 사이트 만들기"(값 ''), 그 아래로 "생성 중 / 완성된 사이트 / 기존 사이트(가져옴·복사)" 묶음. + * 생성 중 여부는 목록 응답의 plan 단계(stage)로 판단한다 (inProgress 와 같은 기준). + */ +async function refreshSiteList() { + const list = await api('/api/sites').catch(() => []); + const sel = $('siteSelect'); + sel.innerHTML = ''; + const groups = [['생성 중', []], ['완성된 사이트', []], ['기존 사이트 (가져옴·복사)', []]]; + for (const s of list) { + const existing = s.mode === 'import' || s.mode === 'copy'; + const making = !existing && s.stage && !['generated', 'published'].includes(s.stage); + const state = existing ? (s.status === 'importing' ? (s.mode === 'copy' ? '복사 중' : '가져오는 중') : (s.mode === 'copy' ? '복사함' : '가져옴')) : (making ? '생성 중' : (s.stage === 'published' ? '발행됨' : '완성')); + const o = document.createElement('option'); o.value = s.id; + o.textContent = `${s.title} · ${T(state)} (${new Date(s.createdAt).toLocaleDateString(uiLocale())})`; // v1.3.10: 사이트 이름(사용자 데이터)과 섞이므로 상태 문구를 먼저 번역 + groups[existing ? 2 : (making ? 0 : 1)][1].push(o); + } + for (const [label, opts] of groups) { if (!opts.length) continue; const g = document.createElement('optgroup'); g.label = label; opts.forEach((o) => g.appendChild(o)); sel.appendChild(g); } + syncPicker(); +} + +// ---------- 버튼 ---------- +$('btnUpdate').onclick = async () => { + const instruction = $('fInstruction').value.trim(); + if (!instruction) { alert('수정 지시를 입력하세요.'); return; } + $('btnUpdate').disabled = true; + try { + const fd = new FormData(); fd.append('instruction', instruction); fd.append('tier', $('editTierSelect').value || ''); appendFiles(fd, 'update'); + const r = await api(`/api/sites/${current.id}/commands`, { method: 'POST', body: fd }); + $('fInstruction').value = ''; clearPending('update'); setStatus('running', '수정 반영'); setProgressMsg('수정 지시를 보냈습니다. AI 가 파일을 확인하고 있습니다'); setStep(1); + if (current) current.tier = $('editTierSelect').value || current.tier; + if (r.attachments) { current.attachments = r.attachments; renderAttachments(current); } + } catch (e) { alert('수정 요청 실패: ' + e.message); $('btnUpdate').disabled = false; } +}; +$('pfSave').onclick = async () => { + const name = $('pfName').value.trim(); if (!name) return ($('pfMsg').textContent = '이름(회사명)을 입력하세요'); + try { const r = await api('/api/auth/me', { method: 'PUT', body: JSON.stringify({ name }) }); config.user = r.user; $('userName').textContent = r.user.name; $('pfMsg').textContent = '저장되었습니다'; } catch (e) { $('pfMsg').textContent = e.message; } +}; +$('pwSave').onclick = async () => { + const cur = $('pwCur').value, n1 = $('pwNew').value, n2 = $('pwNew2').value; + // v1.3.17: 길이 규칙은 관리자 콘솔의 비밀번호 규칙을 서버가 검사한다(화면에 6자를 박아 두지 않음) — 비어 있을 때만 먼저 알린다 + if (!n1) return ($('pwMsg').textContent = T('새 비밀번호를 입력하세요')); + if (n1 !== n2) return ($('pwMsg').textContent = '새 비밀번호 확인이 일치하지 않습니다'); + try { await api('/api/auth/password', { method: 'POST', body: JSON.stringify({ current: cur, next: n1 }) }); $('pwMsg').textContent = '비밀번호가 변경되었습니다'; $('pwCur').value = $('pwNew').value = $('pwNew2').value = ''; } + catch (e) { $('pwMsg').textContent = e.message; } +}; +$('btnCancel').onclick = async () => { + if (!current || !confirm('실행 중인 작업을 중단할까요? 지금까지 만들어진 파일은 유지되고, 사용한 토큰은 추정치로 기록됩니다.')) return; + $('btnCancel').disabled = true; + try { await api(`/api/sites/${current.id}/cancel`, { method: 'POST', body: '{}' }); } + catch (e) { if (/실행 중인 작업이 없습니다/.test(e.message)) { prog.state = 'idle'; setStatus('idle'); try { await openSite(current.id); } catch (_) {} } else alert('중단 실패: ' + e.message); } + $('btnCancel').disabled = false; +}; +$('fAttachNow').onchange = (e) => { uploadNow([...e.target.files]); e.target.value = ''; }; +$('btnReload').onclick = loadFrame; +$('fileSelect').onchange = loadFrame; +/** + * v1.4.17 (요구 1) 미리보기 안에서 페이지를 옮겨 다닐 때 위 도구 줄의 페이지 선택(#fileSelect)과 소스 편집기를 따라가게 한다. + * 미리보기 iframe 은 sandbox(allow-same-origin 없음)라 빌더가 iframe 의 주소를 읽을 수 없다. 대신 모든 생성 페이지에 들어 있는 + * 접속 통계 런타임(/wm-stats.js)이 페이지를 열 때 부모 창에 {type:'wm-page', site, path, title} 을 보낸다. 여기서 + * ① 보낸 창이 지금의 미리보기 iframe 인지(e.source), ② 사이트 ID 와 경로가 현재 사이트인지 확인한 뒤 + * ③ 경로를 사이트 기준 상대 파일명으로 바꿔(//ko/contact.html → ko/contact.html, 폴더 주소는 index.html, 확장자 없는 주소는 .html 보충) + * ④ 페이지 선택 상자 값을 바꾼다(값만 바꾸므로 onchange 가 다시 불리지 않아 iframe 을 또 읽지 않는다). + * ⑤ 소스 편집기가 열려 있고 저장하지 않은 변경이 없으면 그 파일을 편집기에도 연다(저장 안 한 변경이 있으면 편집기는 그대로 둔다). + * 운영 주소에서 열린 페이지나 첫 화면 페이지(welcomeFrame)가 보내는 메시지는 ①에서 걸러진다. 직접 편집 미리보기(srcdoc)는 경로가 사이트가 아니라 무시된다. + */ +window.addEventListener('message', (e) => { + const d = e.data; if (!d || d.type !== 'wm-page' || !current) return; + const fr = $('frame'); if (!fr || e.source !== fr.contentWindow) return; + if (d.site && d.site !== current.id) return; + let base; try { base = new URL(current.url, location.href).pathname; } catch (_) { return; } + const path = String(d.path || ''); if (!path.startsWith(base)) return; + let rel = decodeURIComponent(path.slice(base.length)); if (!rel || rel.endsWith('/')) rel += 'index.html'; + const htmls = (current.files || []).filter((f) => f.endsWith('.html')); + if (!htmls.includes(rel)) { if (htmls.includes(rel + '.html')) rel += '.html'; else if (htmls.includes(rel + '/index.html')) rel += '/index.html'; else return; } + const sel = $('fileSelect'); if (sel.value !== rel) sel.value = rel; + if (ed.open && ed.file !== rel && !ed.dirty) loadEditFile(rel); +}); +$('btnCopy').onclick = async () => { try { await navigator.clipboard.writeText(current.liveUrl || current.url); $('btnCopy').textContent = '복사됨'; setTimeout(() => ($('btnCopy').textContent = 'URL 복사'), 1500); } catch (_) { prompt('URL', current.url); } }; +/** + * 상단 사이트 선택 (v1.2.0): 고른 사이트를 열면 아래 작업 영역과 오른쪽 미리보기가 함께 바뀐다. + * 생성 중인 사이트는 생성 탭, 완성·가져온·복사한 사이트는 수정 탭(관리 탭을 보고 있었으면 관리 탭)으로 자동 이동한다 (openSite 가 판단). + * "+ 새 사이트 만들기"는 진행 중인 생성을 지우지 않고(목록에 남음) 생성 탭의 방식 선택 화면만 연다. + */ +$('siteSelect').onchange = (e) => { + const id = e.target.value; + if (!discardOk(id || null)) { syncPicker(); return; } + if (id) { openSite(id).catch((err) => { alert(err.message); syncPicker(); }); return; } + createId = null; try { localStorage.removeItem('createSiteId'); } catch (_) {} + resetToNew(false); manageId = null; try { localStorage.removeItem('currentSiteId'); } catch (_) {} + showTab('create'); +}; +// ---- 사이트 폐기 (v1.1.0) ---- +$('btnDeleteSite').onclick = async () => { + if (!current) return; const title = current.title || ''; + const typed = prompt(`"${title}" 사이트를 완전히 폐기합니다. 배포된 파일·도메인 설정·버전·통계·문의 데이터가 모두 지워지며 되돌릴 수 없습니다.\n\n계속하려면 사이트 제목을 그대로 입력하세요:`); + if (typed === null) return; if (typed.trim() !== title.trim()) return alert('입력한 제목이 다릅니다. 폐기를 취소했습니다.'); + try { await api(`/api/sites/${current.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: typed }) }); if (createId === current.id) { createId = null; localStorage.removeItem('createSiteId'); } resetToNew(); await refreshSiteList(); alert('사이트를 폐기했습니다.'); } + catch (e) { alert('폐기 실패: ' + e.message); } +}; +// ---- 모바일 미리보기 (v1.1.0) ---- +$('btnMobile').onclick = () => { const on = !$('previewArea').classList.contains('mobile'); $('previewArea').classList.toggle('mobile', on); $('btnMobile').classList.toggle('active', on); $('btnMobile').textContent = on ? 'PC 미리보기' : '모바일 미리보기'; }; + +// ---------- 초기화 ---------- +(async () => { + config = await api('/api/config').catch(() => config); + if (!current) renderEmptyReason(null, false); // v1.4.5 설정을 받은 뒤 첫 화면 페이지 표시 + fillTiers(); fillLangOpts(); if (config.credit) renderCredit(config.credit); $('verBadge').textContent = config.version ? `v${config.version}` : ''; document.title = (config.product || 'AI Webmaker') + (config.version ? ` v${config.version}` : ''); fillEditTier(null); + loadMyFeedback(); + if (config.user) { $('userName').textContent = config.user.name; $('userId').textContent = `(${config.user.id})`; if (config.user.role === 'admin') $('adminLink').classList.remove('hidden'); $('pfId').value = config.user.id; $('pfName').value = config.user.name || ''; } + loadLedger(); + $('btnLogout').onclick = async () => { await fetch('/api/auth/logout', { method: 'POST' }); localStorage.removeItem('currentSiteId'); location.href = '/login'; }; + if (config.upload) { + const accept = config.upload.extensions.map((x) => '.' + x).join(','); + for (const id of ['fFiles', 'fUpdFiles', 'fAttachNow']) $(id).accept = accept; + $('attachExts').textContent = config.upload.extensions.join(', '); + } + await refreshSiteList(); + // v1.6.40: 처음 열면 홈(작업 흐름 지도). 저장된 화면이 옛 이름이면 홈으로 + let tab = localStorage.getItem('activeTab') || 'home'; if (!TABS.includes(tab)) tab = 'home'; + if ((SITE_TABS.includes(tab) || tab === 'home') && manageId) openSite(manageId, { tab }).then(() => showTab(tab)).catch(() => { resetToNew(); showTab('home'); }); + else if (tab === 'create' && createId) openSite(createId, { tab: 'create' }).catch(() => { createId = null; showCreateChooser(); }); + else { renderManageHint(); showTab(tab); } +})(); + +/** + * 번역 사전이 준비되면(처음 받거나 바뀌면) 조각 번역(T/TF)으로 만든 목록·문장을 다시 그린다 (v1.3.10). + * 사이트 이름 같은 사용자 데이터와 섞인 문장은 화면 런타임이 통째로 맞출 수 없어, 그릴 때 조각을 번역하기 때문이다. + */ +window.addEventListener('wm:i18n', () => { + try { refreshSiteList(); fillTiers(); fillLangOpts(); renderManageHint(); if (current) { renderSiteMeta(current); renderAnalysis(current); renderEditLimit(current); fillEditTier(current); if (window.OptimizeUI) OptimizeUI.onSiteOpened(current); if (window.ModulesUI) ModulesUI.onSiteOpened(current); if (window.ManageUI) ManageUI.onSiteOpened(current); } else renderSiteMetaEmpty(); applyTabView(activeTab); if (window.FlowUI) FlowUI.onSite(current); } catch (_) {} +}); diff --git a/modules/webmaker/public/credits-ui.js b/modules/webmaker/public/credits-ui.js new file mode 100644 index 0000000..cbf7785 --- /dev/null +++ b/modules/webmaker/public/credits-ui.js @@ -0,0 +1,129 @@ +'use strict'; +/* ============================================================================ + * 캐시 · 충전 화면 (v1.6.40, 요구 1) + * + * 비용은 지금처럼 선불 적립 캐시(원 단위, lib/credits.js)에서 차감된다. 이 화면은 + * ① 내 캐시: 잔액(크게) · 누적 사용 · 누적 지급 · 캐시 충전을 기다리는 작업 · "상세 내역"(app.js showUsageDetail — 일/주/월 사용량·전체 원장) + * ② 선불 충전: 관리자 콘솔에서 만든 충전 상품(금액 + 인센티브)을 고르면 결제 금액·인센티브·적립 예정 캐시·충전 뒤 예상 잔액을 보여 주고 + * "결제 진행" 으로 주문을 접수한다. **PG(결제 대행)가 아직 연동되지 않아 실제 결제는 일어나지 않는다** — 주문은 "결제 대기" 로 남고 + * 운영팀이 입금(결제)을 확인하면 적립된다(관리자 콘솔 › 회원·청구 › 선불 충전 주문). 이 사실을 화면에 그대로 적어 둔다. + * ③ 내 충전 주문: 상태(결제 대기 · 적립 완료 · 취소)와 결제 대기 주문의 취소. + * API: GET /api/credits/packages · GET/POST /api/credits/orders · POST /api/credits/orders//cancel · GET /api/me/credit + * 전역 의존(app.js): $, api, T, TF, krwFmt, config, showUsageDetail, uiLocale, loadLedger + * ========================================================================== */ +(() => { + const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; }; + const btn = (text, cls, onclick) => { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = onclick; return b; }; + const dtm = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'); + const STATUS = { pending: '결제 대기', paid: '적립 완료', cancelled: '취소' }; + let packages = null; // GET /api/credits/packages 응답의 packages + let orders = null; // GET /api/credits/orders 응답의 orders (최신순) + let info = null; // GET /api/me/credit (잔액·누적·대기 작업) + let sel = null; // 고른 상품 id + let lastOrder = null; // 방금 접수한 주문 (접수 안내 상자) + let busy = false; + + const visible = () => { const p = $('pane-credits'); return p && !p.classList.contains('hidden'); }; + + async function load() { + const box = $('creditsBody'); if (!box) return; + if (!packages) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', '불러오는 중…')); } + try { + const [pk, od, cr] = await Promise.all([api('/api/credits/packages'), api('/api/credits/orders'), api('/api/me/credit')]); + packages = pk.packages || []; orders = od.orders || []; info = cr; + if (sel && !packages.some((p) => p.id === sel)) sel = null; + } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', '캐시 정보를 불러오지 못했습니다: ' + e.message)); return; } + render(); + } + /** app.js renderCredit 가 잔액을 바꿀 때마다 호출 — 화면이 보이면 잔액 숫자만 갱신 */ + function onCredit(i) { if (!i) return; info = { ...(info || {}), ...i }; const b = $('crBalance'); if (b) { b.textContent = krwFmt(info.balance); b.classList.toggle('neg', info.balance <= 0); } } + + /** 인센티브 표시 문구: 정률 "10% 추가" · 정액 "+1,000원 추가" */ + const bonusText = (p) => (!p.bonus ? '' : (p.bonusType === 'percent' ? TF('{1}% 추가 적립', p.bonusValue) : TF('{1} 추가 적립', krwFmt(p.bonusValue)))); + + function render() { + const box = $('creditsBody'); if (!box) return; box.innerHTML = ''; + const isAdmin = config && config.user && config.user.role === 'admin'; + // ① 내 캐시 + { + const c = el('section', 'card'); c.appendChild(el('h2', null, '내 캐시')); + const hero = el('div', 'cr-hero'); const left = el('div'); + const bal = el('div', 'cr-balance' + (info && info.balance <= 0 ? ' neg' : '')); bal.id = 'crBalance'; bal.textContent = info ? krwFmt(info.balance) : '-'; left.appendChild(bal); + left.appendChild(el('div', 'cr-sub', info ? `${T('누적 사용')} ${krwFmt(info.used)} · ${T('누적 지급')} ${krwFmt(info.granted)}${isAdmin ? ' · ' + T('관리자 계정은 캐시 한도를 받지 않습니다') : ''}` : '')); + hero.appendChild(left); + const act = el('div', 'fs-act'); act.appendChild(btn('상세 내역', 'ghost small-btn', () => { if (window.showUsageDetail) showUsageDetail(); })); act.appendChild(btn('새로고침', 'ghost small-btn', () => load())); hero.appendChild(act); + c.appendChild(hero); + if (info && info.balance <= 0 && !isAdmin) c.appendChild(el('p', 'small cr-notice', '캐시가 없어 새 AI 작업을 시작할 수 없습니다. 아래에서 충전 상품을 골라 신청하세요.')); + const waiting = (info && info.waiting) || []; + if (waiting.length) { const w = el('div', 'small'); w.appendChild(el('div', 'field-label', TF('캐시 충전을 기다리는 작업 {1}건 — 충전되면 자동으로 이어서 진행됩니다', waiting.length))); const ul = el('ul', 'git-list'); for (const x of waiting) { const li = el('li'); li.appendChild(el('span', 'kind', '대기')); li.appendChild(el('span', 'bname', `${x.title} — ${(x.instruction || '').slice(0, 60)}`)); li.appendChild(el('span', 'st', dtm(x.at))); ul.appendChild(li); } w.appendChild(ul); c.appendChild(w); } + box.appendChild(c); + } + // ② 선불 충전 + { + const c = el('section', 'card'); c.appendChild(el('h2', null, '선불 충전')); + if (lastOrder) { + const d = el('div', 'cr-done'); + d.appendChild(el('div', null, `✓ ${TF('주문 #{1} 을 접수했습니다', lastOrder.id)} — ${lastOrder.name} · ${T('결제 금액')} ${krwFmt(lastOrder.amount)} → ${T('적립 예정')} ${krwFmt(lastOrder.credit)}`)); + d.appendChild(el('div', 'muted small', '결제 수단(PG)이 아직 연동되지 않아 실제 결제는 진행되지 않았습니다. 운영팀이 입금(결제)을 확인하면 적립 예정 캐시가 잔액에 더해집니다. 상태는 아래 "내 충전 주문" 에서 볼 수 있습니다.')); + const r = el('div', 'row'); r.appendChild(btn('다른 상품 고르기', 'ghost small-btn', () => { lastOrder = null; render(); })); d.appendChild(r); + c.appendChild(d); box.appendChild(c); + } else { + c.appendChild(el('p', 'muted small', '상품을 고르면 결제 금액과 적립될 캐시(인센티브 포함)를 확인할 수 있습니다. 적립 캐시 = 결제 금액 + 관리자가 정한 추가 인센티브.')); + if (!packages.length) c.appendChild(el('p', 'small cr-notice', '아직 판매 중인 충전 상품이 없습니다. 운영팀(관리자)에게 캐시 충전을 요청하세요.')); + else { + const grid = el('div', 'pk-grid'); + for (const p of packages) { + const b = el('button', 'pk' + (sel === p.id ? ' on' : '')); b.type = 'button'; + b.appendChild(el('span', 'pk-name', p.name)); b.appendChild(el('span', 'pk-amt', krwFmt(p.amount))); + if (p.bonus) b.appendChild(el('span', 'pk-bonus', bonusText(p))); + b.appendChild(el('span', 'pk-credit', TF('적립 {1}', krwFmt(p.credit)))); + if (p.note) b.appendChild(el('span', 'muted small', p.note)); + b.onclick = () => { sel = p.id; render(); }; + grid.appendChild(b); + } + c.appendChild(grid); + const p = packages.find((x) => x.id === sel); + if (p) { + const sm = el('div', 'cr-summary'); + const row = (k, v, cls) => { const r = el('div', 'cs-row' + (cls ? ' ' + cls : '')); r.appendChild(el('span', null, T(k))); r.appendChild(el('span', null, v)); sm.appendChild(r); }; + row('결제 금액', krwFmt(p.amount)); row('추가 인센티브', p.bonus ? `+${krwFmt(p.bonus)}${p.bonusType === 'percent' ? ` (${p.bonusValue}%)` : ''}` : T('없음')); + row('적립 예정 캐시', krwFmt(p.credit), 'total'); if (info) row('충전 뒤 예상 잔액', krwFmt((info.balance || 0) + p.credit)); + c.appendChild(sm); + c.appendChild(el('p', 'cr-notice', '결제 수단(PG) 연동 전입니다 — "결제 진행" 을 누르면 주문만 접수되고 실제 결제는 일어나지 않습니다. 운영팀이 입금(결제)을 확인하면 적립 예정 캐시가 잔액에 더해집니다.')); + const r = el('div', 'row'); const msg = el('span', 'muted small'); + const goBtn = btn(TF('결제 진행 — {1}', krwFmt(p.amount)), 'primary small-btn', async () => { + if (busy) return; if (!confirm(TF('{1} ({2}) 충전 주문을 접수할까요?\n결제 수단 연동 전이라 지금은 결제가 진행되지 않고 주문만 남습니다.', p.name, krwFmt(p.amount)))) return; + busy = true; goBtn.disabled = true; msg.textContent = T('접수 중…'); + try { const r2 = await api('/api/credits/orders', { method: 'POST', body: JSON.stringify({ packageId: p.id }) }); lastOrder = r2.order; orders = r2.orders || orders; sel = null; render(); } + catch (e) { msg.textContent = e.message; msg.style.color = '#b91c1c'; goBtn.disabled = false; } + busy = false; + }); + r.appendChild(goBtn); r.appendChild(msg); c.appendChild(r); + } + } + box.appendChild(c); + } + } + // ③ 내 충전 주문 + { + const c = el('section', 'card'); c.appendChild(el('h2', null, '내 충전 주문')); + if (!orders || !orders.length) c.appendChild(el('p', 'muted small', '아직 충전 주문이 없습니다.')); + else { + const list = el('div', 'order-list'); + for (const o of orders) { + const row = el('div', 'ol-row'); + const left = el('div'); const head = el('div'); head.appendChild(el('b', null, o.name)); head.appendChild(document.createTextNode(` · ${krwFmt(o.amount)} → ${T('적립')} ${krwFmt(o.credit)}`)); left.appendChild(head); + left.appendChild(el('div', 'ol-meta', `${TF('주문 #{1}', o.id)} · ${dtm(o.at)}${o.status === 'paid' ? ` · ${T('적립')} ${dtm(o.paidAt)}` : ''}${o.status === 'cancelled' && o.cancelledAt ? ` · ${T('취소')} ${dtm(o.cancelledAt)}` : ''}`)); + row.appendChild(left); + const act = el('div', 'ol-act'); act.appendChild(el('span', 'pill small ' + o.status, T(STATUS[o.status] || o.status))); + if (o.status === 'pending') act.appendChild(btn('취소', 'ghost small-btn', async () => { if (!confirm(TF('주문 #{1} 을 취소할까요?', o.id))) return; try { const r = await api(`/api/credits/orders/${o.id}/cancel`, { method: 'POST', body: '{}' }); orders = r.orders || orders; if (lastOrder && lastOrder.id === o.id) lastOrder = null; render(); } catch (e) { alert(e.message); } })); + row.appendChild(act); list.appendChild(row); + } + c.appendChild(list); + } + box.appendChild(c); + } + } + + window.CreditsUI = { load, onCredit, visible }; +})(); diff --git a/modules/webmaker/public/domains-ui.js b/modules/webmaker/public/domains-ui.js new file mode 100644 index 0000000..fb7e839 --- /dev/null +++ b/modules/webmaker/public/domains-ui.js @@ -0,0 +1,742 @@ +'use strict'; +/* ============================================================================ + * 빌더 "도메인" 탭 (v1.4.0 → v1.6.35 프로세스 개편 → v1.6.40 세 화면 → v1.7.2 역할 분리: 도메인 = 구매·갱신·네임서버 이전, DNS = 사이트↔도메인 연결 + 레코드, 메일 = 계정·접속 주소·초기 관리자 + MX) + * + * 원칙(v1.6.35 요구 1): 자기 홈페이지 주소는 사용자가 스스로 마련하고 관리한다. 관리자는 접속 정보·플랫폼 도메인·서브 도메인 허용 여부만 정한다. + * + * 계정 종류(관리자가 정함)에 따라 보이는 기능이 다르다. + * - 서브 도메인 계정: 플랫폼 도메인(예: ibit.kr) 아래 이름(mycompany.ibit.kr)을 검색·등록·해제하고 사이트에 연결한다. (관리자가 서브 도메인 등록을 금지하면 이유가 보인다) + * - 자체 도메인 계정: "도메인 준비" 카드에서 세 가지 길 중 하나를 고른다. + * ① 구매 : 검색(가격 포함) → 장바구니에 담기 → 장바구니에서 최종 선택해 구매(금액을 한 번 더 확인). 가격은 장바구니를 열 때마다 다시 받아 바뀐 값을 표시한다. + * ② 네임서버 이전 : 다른 곳에서 산 도메인. 쓰고 있는 하위 이름을 적으면 지금 DNS 레코드를 읽어 이 서비스의 DNS 영역에 옮기고, 바꿀 네임서버를 보여 준다. + * 사용자가 "네임서버 변경했습니다" 를 누르면 실제로 바뀌었는지 확인하고, 확인될 때까지 "DNS 변경 진행 중" 으로 표시해 레코드 수정을 잠근다(이중 수정 방지). + * ③ 직접 DNS 관리 : 다른 곳에서 산 도메인을 자기 DNS 에서 관리. 배포·메일에 필요한 레코드를 보여 주고, "설정 완료 — 확인" 으로 실제 반영 여부를 확인한다. + * 확인되지 않으면 "DNS 변경이 완료되지 않았거나 반영되지 않아 배포가 되지 않는다" 고 알린다. + * ④ 자동 설정 : 사이트에 연결 → 발행 때 웹서버 설정·SSL 적용, 메일(MX) 자동 설정. "도메인 상태" 가 어느 단계에서 막혔는지(배포가 안 되는 이유·메일을 못 쓰는 이유)를 보인다. + * - 두 종류 모두: "메일(MX) 자동 설정" 으로 받는 메일 MX·SPF·DMARC 와 Resend 보내기 레코드(DKIM 등)를 자동으로 만든다(직접 DNS 관리 도메인은 넣을 레코드를 안내). + * 변경이 있는 모든 입력(연결·레코드·등록자 정보 등)은 그 자리마다 실행 버튼을 둔다. + * 전역 의존: $, api, T, current, openSite, krwFmt (app.js) + * ========================================================================== */ +(() => { + let data = null; // GET /api/domains 응답 + let way = 'buy'; // 도메인 준비 카드에서 고른 길: buy | ns | manual + /** + * v1.6.40 (요구 1): 도메인 탭이 세 화면으로 나뉘었다 — view 에 따라 그리는 상자와 내용이 다르다. + * - 'domains' (#domainsBody): 도메인 준비(서브 도메인 등록 / 검색·구매 / 이미 가진 도메인 등록) + 내 도메인(연결·확인·메일) — 1.6.39 까지의 도메인 탭 + * - 'dns' (#dnsBody) : 도메인별 DNS 레코드·전파·직접 관리 도메인의 필요한 설정 확인 — 배포·메일이 의존하는 중간 단계 + * - 'mail' (#mailBody) : 도메인별 메일(MX) 자동 설정·상태, 현재 사이트의 메일 시스템·웹메일 + */ + let view = 'domains'; + const boxOf = () => $(view === 'dns' ? 'dnsBody' : view === 'mail' ? 'mailBody' : 'domainsBody'); + const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; }; + const btn = (text, cls, onclick) => { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = onclick; return b; }; + const won = (n) => krwFmt(Number(n) || 0); // v1.4.1 캐시 표시 단위(관리자 설정)를 따른다 — app.js krwFmt + const dt = (s) => (s ? new Date(s).toLocaleDateString() : '-'); + const dtm = (s) => (s ? new Date(s).toLocaleString() : '-'); + /** 결과·오류 한 줄 표시 (ok=false 면 빨간 글씨) */ + const say = (node, text, ok = true) => { node.textContent = text || ''; node.style.color = ok ? '' : '#b91c1c'; }; + /** 노란 안내 상자(전파 대기·진행 중) 표시/숨김 */ + const wait = (node, text) => { if (text) { node.textContent = text; node.classList.remove('hidden'); } else { node.textContent = ''; node.classList.add('hidden'); } }; + + /** 캐시 잔액 (응답의 credit.balance) */ + const balance = () => (data && data.credit ? Number(data.credit.balance) || 0 : 0); + /** 도메인 종류 표시 이름 */ + const kindLabel = (d) => d.type === 'subdomain' ? '서브 도메인' : d.type === 'registered' ? '구입 도메인' : d.mode === 'ns' ? '네임서버 이전' : '직접 DNS 관리'; + + /** + * 화면을 연다. @param {'domains'|'dns'|'mail'} [v] 그릴 화면 (기본 도메인) + */ + async function load(v) { + view = ['domains', 'dns', 'mail'].includes(v) ? v : 'domains'; + const box = boxOf(); if (!box) return; + box.innerHTML = ''; box.appendChild(el('p', 'muted small', '불러오는 중…')); + try { data = await api('/api/domains'); } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', '도메인 정보를 불러오지 못했습니다: ' + e.message)); return; } + render(); + } + async function refreshList() { try { data = await api('/api/domains'); } catch (_) { return; } render(); if (window.FlowUI) FlowUI.invalidateDomains(); } // v1.6.40: 레일 배지·홈 지도도 새 자료로 + /** "내 도메인" 카드(마지막 카드)만 새로 그린다 — 위쪽 폼의 결과 안내(옮긴 레코드·네임서버)를 지우지 않기 위해 (DNS·메일 화면은 전체를 다시 그린다) */ + async function refreshMyDomains() { try { data = await api('/api/domains'); } catch (_) { return; } if (view !== 'domains') { render(); return; } const cards = boxOf().querySelectorAll('section.card'); const last = cards[cards.length - 1]; if (last) last.replaceWith(myDomainsCard()); if (window.FlowUI) FlowUI.invalidateDomains(); } + + function render() { + const box = boxOf(); if (!box) return; box.innerHTML = ''; + if (view === 'dns') return renderDns(box); + if (view === 'mail') return renderMail(box); + const sub = data.accountType === 'subdomain'; + // v1.7.2 (요구 1): 도메인 화면은 "구매·갱신·네임서버 이전" 만 다룬다. 사이트 연결·레코드·메일은 DNS·메일 화면으로 옮겼다 + const head = el('section', 'card'); + head.appendChild(el('h2', null, '도메인')); + head.appendChild(el('p', 'muted small', sub + ? `서브 도메인 계정입니다. ${data.platform.domain || '플랫폼 도메인'} 아래 이름(예: mycompany.${data.platform.domain || 'ibit.kr'})을 무료로 등록합니다. 등록한 이름을 사이트에 연결하는 일은 "DNS" 화면에서 합니다. 직접 도메인을 사려면 관리자에게 계정 종류 변경을 요청하세요.` + : '여기서는 도메인을 사고(구매·갱신) 다른 곳에서 산 도메인을 네임서버 이전으로 가져오는 일만 다룹니다. 등록 업체(기관) 이전은 지원하지 않습니다. 사이트를 어느 도메인에 어떤 이름으로 올릴지는 "DNS" 화면에서, 메일은 "메일" 화면에서 정합니다.')); + head.appendChild(el('p', 'muted small', `캐시 잔액 ${won(balance())}`)); + box.appendChild(head); + if (sub) box.appendChild(subdomainCard()); else box.appendChild(prepareCard()); + box.appendChild(myDomainsCard()); + } + + // ---------- 서브 도메인 등록 ---------- + function subdomainCard() { + const c = el('section', 'card'); c.appendChild(el('h2', null, '서브 도메인 등록')); + if (!data.platform.domain) { c.appendChild(el('p', 'muted small', '플랫폼 도메인이 아직 설정되지 않았습니다. 관리자에게 문의하세요.')); return c; } + // v1.6.35 (요구 1-1): 관리자가 서브 도메인 등록을 금지하면 이유를 보이고 입력을 막는다 (이미 등록된 것은 아래 "내 도메인" 에 그대로) + if (data.platform.allowSubdomain === false) { c.appendChild(el('p', 'small dom-wait', '이 서비스는 서브 도메인 등록을 받지 않습니다 (관리자 설정). 이미 등록한 서브 도메인은 그대로 쓸 수 있습니다. 자기 도메인을 쓰려면 관리자에게 계정 종류 변경을 요청하세요.')); return c; } + const row = el('div', 'row dom-sub-row'); const inp = el('input'); inp.placeholder = 'mycompany'; inp.maxLength = 32; inp.autocomplete = 'off'; + row.appendChild(inp); row.appendChild(el('span', 'dom-suffix', '.' + data.platform.domain)); + const msg = el('p', 'muted small'); + const bCheck = btn('중복 확인', 'ghost small-btn', async () => { + say(msg, '확인 중…'); try { const r = await api('/api/domains/subdomain/check', { method: 'POST', body: JSON.stringify({ name: inp.value.trim() }) }); say(msg, r.available ? `${r.fqdn} — 등록할 수 있습니다` : `${r.fqdn} — ${r.reason}`, r.available); } catch (e) { say(msg, e.message, false); } + }); + const bReg = btn('등록', 'primary small-btn', async () => { + say(msg, '등록 중…'); try { const r = await api('/api/domains/subdomain', { method: 'POST', body: JSON.stringify({ name: inp.value.trim() }) }); say(msg, `${r.name} 를 등록했습니다. 아래 "내 도메인" 에서 사이트에 연결하세요`); inp.value = ''; await refreshList(); } catch (e) { say(msg, e.message, false); } + }); + row.appendChild(bCheck); row.appendChild(bReg); c.appendChild(row); c.appendChild(msg); + c.appendChild(el('p', 'muted small', '영문 소문자·숫자·하이픈 1~32자. 등록은 무료이며 계정당 10개까지입니다.')); + return c; + } + + // ---------- ① 도메인 준비 (구매 / 네임서버 이전 / 직접 DNS 관리) ---------- + /** 세 가지 길을 탭처럼 고르는 카드 — 고른 길에 따라 아래 내용이 바뀐다 */ + function prepareCard() { + const c = el('section', 'card'); c.appendChild(el('h2', null, '① 도메인 준비')); + const tabs = el('div', 'row dom-ways'); + const WAYS = [['buy', '도메인 검색·구매', '새 도메인을 검색해 장바구니에 담고 구매합니다'], ['ns', '이미 가진 도메인 — 네임서버 이전', '다른 곳에서 산 도메인의 네임서버를 이 서비스로 바꿔 DNS 를 여기서 관리합니다'], ['manual', '이미 가진 도메인 — 직접 DNS 관리', '다른 곳에서 산 도메인의 DNS 를 그곳에서 직접 관리합니다 (필요한 레코드를 안내)']]; + const body = el('div'); + const draw = () => { + tabs.innerHTML = ''; for (const [id, label, title] of WAYS) { const b = btn(label, (way === id ? 'primary' : 'ghost') + ' small-btn', () => { way = id; draw(); }); b.title = title; tabs.appendChild(b); } + body.innerHTML = ''; body.appendChild(el('p', 'muted small', WAYS.find((w) => w[0] === way)[2])); + if (way === 'buy') { body.appendChild(searchBlock()); body.appendChild(cartBlock()); if (data.providerReady) body.appendChild(contactCard()); } // 도메인 서비스 준비 전에는 등록자 정보를 저장할 곳(공급사)이 없어 숨김 + else body.appendChild(externalForm(way)); + }; + c.appendChild(tabs); c.appendChild(body); draw(); + return c; + } + + // ---------- ①-구매: 검색 → 장바구니 ---------- + function searchBlock() { + const c = el('div', 'dom-block'); c.appendChild(el('h3', null, '도메인 검색')); + if (!data.providerReady) { c.appendChild(el('p', 'muted small', '도메인 서비스가 아직 준비되지 않았습니다(등록 업체 접속 정보 없음). 관리자에게 문의하세요. 이미 가진 도메인은 "네임서버 이전" 또는 "직접 DNS 관리" 로 등록할 수 있습니다.')); return c; } + const row = el('div', 'row dom-search'); const inp = el('input'); inp.placeholder = 'mycompany 또는 mycompany.com'; inp.autocomplete = 'off'; row.appendChild(inp); + const tl = el('div', 'dom-tlds'); const checks = []; + for (const t of data.tlds) { const lb = el('label', 'inline'); const cb = el('input'); cb.type = 'checkbox'; cb.value = t; cb.checked = ['com', 'net', 'co'].includes(t) || data.tlds.length <= 3; checks.push(cb); lb.appendChild(cb); lb.appendChild(document.createTextNode(' .' + t)); tl.appendChild(lb); } + const msg = el('p', 'muted small'); const out = el('div', 'dom-results'); + const go = async () => { + say(msg, '검색 중…'); out.innerHTML = ''; + try { + const r = await api('/api/domains/search', { method: 'POST', body: JSON.stringify({ name: inp.value.trim(), tlds: checks.filter((x) => x.checked).map((x) => x.value) }) }); + say(msg, '가격은 1년 기준이며 구매 때 캐시에서 차감됩니다. 쓸 도메인을 장바구니에 담고, 장바구니에서 최종 선택해 구매하세요.'); + const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['도메인', '상태', '가격 (1년)', '']) hr.appendChild(el('th', null, h)); tb.appendChild(hr); + for (const x of r.results) { + const tr = el('tr'); tr.appendChild(el('td', null, x.domain)); + tr.appendChild(el('td', null, x.available ? (x.premium ? '구매 가능 (프리미엄)' : '구매 가능') : '사용 중')); + tr.appendChild(el('td', null, x.quote ? won(x.quote.costKrw) : '-')); + const td = el('td'); + if (x.available && x.quote) { + const inCart = (data.cart || []).some((i) => i.domain === x.domain); + const b = btn(inCart ? '장바구니에 있음' : '장바구니에 담기', 'primary small-btn', async () => { b.disabled = true; try { const rr = await api('/api/domains/cart', { method: 'POST', body: JSON.stringify({ domain: x.domain }) }); data.cart = rr.items; b.textContent = '장바구니에 있음'; const cb = $('domCartBody'); if (cb) drawCart(cb, data.cart); } catch (e) { b.disabled = false; say(msg, e.message, false); } }); + if (inCart) b.disabled = true; td.appendChild(b); + } + tr.appendChild(td); tb.appendChild(tr); + } + out.appendChild(tb); + } catch (e) { say(msg, e.message, false); } + }; + inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); }); + row.appendChild(btn('검색', 'primary small-btn', go)); + c.appendChild(row); c.appendChild(tl); c.appendChild(msg); c.appendChild(out); + return c; + } + /** 장바구니(보관 목록): 열 때 가격·등록 가능 여부를 다시 받아 바뀐 값을 표시. 여기서 최종 선택해 구매한다 */ + function cartBlock() { + const c = el('div', 'dom-block'); const h = el('h3', null, `장바구니 (${(data.cart || []).length})`); c.appendChild(h); + if (!data.providerReady) return c; + const body = el('div'); body.id = 'domCartBody'; c.appendChild(body); + drawCart(body, data.cart || []); + if ((data.cart || []).length) { body.classList.add('dom-refreshing'); api('/api/domains/cart').then((r) => { data.cart = r.items; drawCart(body, data.cart); h.textContent = `장바구니 (${data.cart.length})`; }).catch(() => {}).finally(() => body.classList.remove('dom-refreshing')); } + return c; + } + function drawCart(body, items) { + body.innerHTML = ''; + if (!items.length) { body.appendChild(el('p', 'muted small', '장바구니가 비어 있습니다. 위에서 검색해 쓸 도메인을 담으세요.')); return; } + const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const hh of ['도메인', '상태', '가격 (1년)', '']) hr.appendChild(el('th', null, hh)); tb.appendChild(hr); + for (const i of items) { + const tr = el('tr'); tr.appendChild(el('td', null, i.domain)); + tr.appendChild(el('td', null, i.refreshError ? '가격 확인 실패' : i.available ? (i.premium ? '구매 가능 (프리미엄)' : '구매 가능') : '사용 중 (다른 사람이 등록)')); + const price = el('td', null, i.quote ? won(i.quote.costKrw) : '-'); if (i.priceChanged && i.prevKrw != null) { price.appendChild(el('span', 'muted small', ` (담을 때 ${won(i.prevKrw)})`)); price.style.fontWeight = '600'; } tr.appendChild(price); + const td = el('td'); if (i.available && i.quote) td.appendChild(btn('구매', 'primary small-btn', () => buyDialog(i.domain, i.quote))); + td.appendChild(btn('빼기', 'ghost small-btn', async () => { try { const r = await api('/api/domains/cart', { method: 'DELETE', body: JSON.stringify({ domain: i.domain }) }); data.cart = r.items; drawCart(body, data.cart); } catch (e) { alert(e.message); } })); + tr.appendChild(td); tb.appendChild(tr); + } + const wrap = el('div', 'dom-table-wrap'); wrap.appendChild(tb); body.appendChild(wrap); + body.appendChild(el('p', 'muted small', `가격은 장바구니를 열 때마다 다시 확인합니다 (마지막 확인 ${dtm(items[0].checkedAt)}). 구매 버튼을 누르면 금액을 한 번 더 확인합니다.`)); + } + + /** + * 구매 확인 창: 기간(년)을 바꾸면 견적을 다시 받는다. 확인을 누를 때 본 금액(expectedKrw)을 함께 보내 + * 그 사이 가격이 오르면 서버가 막는다(409 + 새 견적). + */ + function buyDialog(domain, firstQuote) { + const dlg = $('domDialog'); const body = $('domDialogBody'); body.innerHTML = ''; $('domDialogTitle').textContent = '도메인 구매 확인'; + let q = firstQuote; let period = 1; + const info = el('div'); const msg = el('p', 'muted small'); + const sel = el('select'); for (let y = 1; y <= 5; y++) { const o = el('option', null, `${y}년`); o.value = y; sel.appendChild(o); } + const draw = () => { + info.innerHTML = ''; + info.appendChild(el('p', null, domain)); + const after = balance() - q.costKrw; + info.appendChild(el('p', 'dom-cost', `결제 금액 ${won(q.costKrw)} (${period}년)`)); + info.appendChild(el('p', 'muted small', `공급사 가격 ${q.base.amount} ${q.base.currency} × 요율 ${q.marginPct}% · 캐시 잔액 ${won(balance())} → 구매 후 ${won(after)}`)); + if (after < 0) info.appendChild(el('p', 'small', '캐시가 부족합니다. 캐시를 충전한 뒤 구매하세요.')); + if (!data.contact) info.appendChild(el('p', 'small', '먼저 "등록자 정보" 를 저장해야 구매할 수 있습니다 (도메인 검색·구매 아래).')); + }; + sel.onchange = async () => { + period = Number(sel.value); say(msg, '가격 확인 중…'); + try { q = await api('/api/domains/quote', { method: 'POST', body: JSON.stringify({ domain, operation: 'create', period }) }); say(msg, ''); draw(); } catch (e) { say(msg, e.message, false); } + }; + const lb = el('label', null, '등록 기간'); lb.appendChild(sel); + body.appendChild(lb); body.appendChild(info); body.appendChild(msg); + const row = el('div', 'row'); + row.appendChild(btn('구매', 'primary small-btn', async () => { + say(msg, '등록 중… (최대 1분)'); + try { + const r = await api('/api/domains/purchase', { method: 'POST', body: JSON.stringify({ domain, period, expectedKrw: q.costKrw }) }); + say(msg, `${r.name} 를 등록했습니다.`); dlg.close(); await load(); + } catch (e) { + if (e.body && e.body.quote) { q = e.body.quote; draw(); } // 가격이 바뀜 — 새 금액을 보여 주고 다시 확인받는다 + say(msg, e.message, false); + } + })); + row.appendChild(btn('취소', 'ghost small-btn', () => dlg.close())); + body.appendChild(row); draw(); dlg.showModal(); + } + + // ---------- ①-이미 가진 도메인: 네임서버 이전(ns) / 직접 DNS 관리(manual) ---------- + /** + * 등록 폼. 공통: 도메인 이름 + "이미 구매(소유)했습니다" 확인(필수 — 서버도 owned 없이는 진행하지 않음). + * ns: 쓰고 있는 하위 이름을 받아 레코드를 옮긴다 → 결과로 옮긴 레코드와 바꿀 네임서버, "네임서버 변경했습니다" 버튼. + * manual: 결과로 필요한 DNS 설정 표와 "설정 완료 — 확인" 버튼. + */ + function externalForm(mode) { + const c = el('div', 'dom-block'); + if (mode === 'ns') { + c.appendChild(el('p', 'muted small', '도메인의 등록 업체(기관) 이전은 지원하지 않습니다. 대신 등록 업체에서 네임서버만 이 서비스로 바꾸면 DNS 레코드를 여기서 관리하고 사이트·메일을 자동으로 설정할 수 있습니다. 진행 순서: 지금 쓰는 DNS 레코드를 읽어 새 영역에 옮김 → 바꿀 네임서버 안내 → 등록 업체에서 네임서버 변경 → "네임서버 변경했습니다" 로 확인 → 전파 확인(최대 48시간) → 사용. 전파 확인 전까지는 "DNS 변경 진행 중" 으로 표시되고 레코드 수정이 잠깁니다(이중 수정 방지).')); + if (!data.dnsReady) c.appendChild(el('p', 'small dom-wait', '이 서비스의 DNS 공급자가 아직 준비되지 않아 네임서버 이전은 지금 쓸 수 없습니다. "직접 DNS 관리" 로 등록하거나 관리자에게 문의하세요.')); + } else c.appendChild(el('p', 'muted small', '도메인을 이미 구매했고 DNS 레코드를 그 업체(또는 다른 DNS 서비스)에서 직접 관리하는 경우입니다. 등록하면 배포와 메일 서비스에 필요한 DNS 레코드를 보여 드립니다. 그곳에 넣은 뒤 "설정 완료 — 확인" 을 누르면 실제로 반영됐는지 확인해 사용 가능 여부를 보여 줍니다.')); + const grid = el('div', 'dom-grid'); + const lbD = el('label', null, '도메인'); const inp = el('input'); inp.placeholder = 'mycompany.com'; inp.autocomplete = 'off'; lbD.appendChild(inp); grid.appendChild(lbD); + let subs = null; + if (mode === 'ns') { const lbS = el('label', null, '쓰고 있는 하위 이름 (쉼표로 구분, 선택)'); subs = el('input'); subs.placeholder = 'www, blog, shop'; lbS.appendChild(subs); grid.appendChild(lbS); } + c.appendChild(grid); + const own = el('label', 'inline'); const cb = el('input'); cb.type = 'checkbox'; own.appendChild(cb); own.appendChild(document.createTextNode(' 이 도메인을 이미 구매(소유)했습니다 — 아직 없으면 "도메인 검색·구매" 로 구매하세요')); c.appendChild(own); + const msg = el('p', 'muted small'); const out = el('div'); + const row = el('div', 'row'); + const b = btn(mode === 'ns' ? '레코드 옮기고 네임서버 받기' : '등록하고 필요한 DNS 설정 보기', 'primary small-btn', async () => { + if (!inp.value.trim()) return say(msg, '도메인을 입력하세요', false); + if (!cb.checked) return say(msg, '도메인을 이미 구매(소유)했는지 확인해 주세요', false); + say(msg, mode === 'ns' ? '공용 DNS 에서 지금 레코드를 읽고 영역을 만드는 중… (최대 1분)' : '도메인을 확인하는 중…'); out.innerHTML = ''; b.disabled = true; + try { + const r = await api('/api/domains/external', { method: 'POST', body: JSON.stringify({ domain: inp.value.trim(), mode, subdomains: subs ? subs.value : '', owned: true }) }); + say(msg, r.message); + if (mode === 'ns') { + if (r.imported && r.imported.length) { out.appendChild(el('p', 'small', `옮긴 레코드 ${r.imported.length}개:`)); out.appendChild(recordTable(r.imported.map((x) => ({ type: x.type, name: x.name, value: x.value, prio: x.prio })), null)); } + else out.appendChild(el('p', 'small', '공용 DNS 에서 읽은 레코드가 없습니다(zone 파일 구성을 찾을 수 없음) — 빈 영역을 만들었습니다. 사이트에 연결하면 사이트 레코드가 만들어지고, 필요한 레코드는 "DNS 레코드" 에서 직접 넣을 수 있습니다.')); + if (r.next === 'ns-change') out.appendChild(nsChangeBox(r.domain, r.nameServers, r.currentNs)); + } else out.appendChild(el('p', 'small', '아래 "내 도메인" 에서 "필요한 DNS 설정" 을 열어 레코드를 넣고 확인하세요.')); + inp.value = ''; cb.checked = false; b.disabled = false; + await refreshMyDomains(); // 결과 안내는 그대로 두고 아래 "내 도메인" 만 새로 그린다 + } catch (e) { say(msg, e.message, false); b.disabled = false; } + }); + row.appendChild(b); c.appendChild(row); c.appendChild(msg); c.appendChild(out); + return c; + } + /** + * 바꿀 네임서버 안내 상자 — v1.6.38 (요구 3): 네임서버 목록은 "네임서버 정보" 팝업(복사 가능)으로 보이고, 본문에는 요약과 "네임서버 변경했습니다" 버튼만 둔다. + * 네임서버가 여러 개라 화면이 복잡해지는 것을 막고, 팝업 안에서는 값마다 Ctrl-C·복사 버튼으로 가져갈 수 있다. + */ + function nsChangeBox(d, nameServers, currentNs) { + const box = el('div', 'dom-ns'); + box.appendChild(el('p', 'small', `등록 업체(${d.name} 을 구매한 곳)의 도메인 설정에서 네임서버를 이 서비스의 네임서버 ${nameServers.length}개로 바꿔야 합니다${currentNs && currentNs.length ? ` (지금: ${currentNs.join(', ')})` : ''}. 어디서 어떻게 바꾸는지와 복사할 값은 "네임서버 정보" 에 있습니다.`)); + const row = el('div', 'row'); const msg = el('p', 'muted small'); + row.appendChild(btn('네임서버 정보', 'ghost small-btn', () => nsInfoDialog(d, nameServers, currentNs))); + row.appendChild(btn('네임서버 변경했습니다', 'primary small-btn', async () => { + if (!confirm(`등록 업체에서 ${d.name} 의 네임서버를 "네임서버 정보" 의 목록으로 바꾸어 저장하셨나요?\n확인을 누르면 공용 DNS 에서 실제로 바뀌었는지 확인합니다.`)) return; + say(msg, '확인 중…'); try { const r = await api(`/api/domains/${d.id}/transfer-check`, { method: 'POST', body: JSON.stringify({ confirmed: true }) }); say(msg, r.message, r.done || r.step === 'propagating'); if (r.done) await refreshList(); } catch (e) { say(msg, e.message, false); } + })); + box.appendChild(row); box.appendChild(msg); + return box; + } + + // ---------- v1.6.38 (요구 3): 네임서버 정보 · DNS 레코드 정보 팝업 (복사 가능) ---------- + /** 클립보드 복사 — https 가 아니거나 막히면 false (그때는 값을 직접 선택해 Ctrl-C) */ + async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (_) { return false; } } + /** 복사 버튼: 누르면 복사하고 잠시 "복사됨" 으로 바뀐다 */ + function copyBtn(text, label) { + const b = btn(label || '복사', 'ghost small-btn dom-copy-btn', async () => { const ok = await copyText(text); const old = b.textContent; b.textContent = ok ? '복사됨' : '직접 선택해 Ctrl-C'; setTimeout(() => { b.textContent = old; }, 1500); }); + b.title = '클립보드에 복사'; return b; + } + /** 값 칸: 클릭하면 값 전체가 선택되어 Ctrl-C 로 복사되고(user-select: all), 옆의 작은 버튼으로도 복사된다 */ + function copyCell(value) { + const td = el('td', 'dom-val'); const v = String(value == null ? '' : value); + const code = el('code', 'dom-copy', v); code.title = '클릭하면 전체 선택 — Ctrl-C 로 복사'; code.onclick = () => { try { const r = document.createRange(); r.selectNodeContents(code); const sl = window.getSelection(); sl.removeAllRanges(); sl.addRange(r); } catch (_) {} }; + td.appendChild(code); if (v) td.appendChild(copyBtn(v)); return td; + } + /** + * 안내 팝업 열기 (index.html 의 #domDialog 재사용). build(body) 가 내용을 채운다. + * 팝업 안의 글은 모두 선택·복사할 수 있다. + */ + function infoDialog(title, build) { + const dlg = $('domDialog'); const body = $('domDialogBody'); if (!dlg || !body) return; + body.innerHTML = ''; $('domDialogTitle').textContent = title; dlg.classList.add('wide'); + const inner = el('div', 'dom-dialog-body'); build(inner); body.appendChild(inner); + const row = el('div', 'row dom-dialog-foot'); row.appendChild(btn('닫기', 'ghost', () => { dlg.classList.remove('wide'); dlg.close(); })); body.appendChild(row); + dlg.showModal(); + } + /** 단계 안내 목록 (ol) */ + function steps(items) { const ol = el('ol', 'dom-guide'); for (const t of items) ol.appendChild(el('li', null, t)); return ol; } + /** + * "네임서버 정보" 팝업 — 어디를 눌러 네임서버를 바꾸는지 + 바꿀 네임서버 목록(값마다 복사, 전체 복사). + * 네임서버 이전 도메인(mode ns)이 아니면(직접 DNS 관리) 네임서버 이전으로 바꾸는 방법만 안내한다 — 이 서비스의 네임서버는 영역을 만들 때 정해지기 때문. + */ + function nsInfoDialog(d, nameServers, currentNs) { + infoDialog(`네임서버 정보 — ${d.name}`, (box) => { + const prov = data.dnsProviderLabel ? `이 서비스의 DNS 공급자: ${data.dnsProviderLabel}` : ''; + if (!nameServers || !nameServers.length) { + box.appendChild(el('p', 'small', `${d.name} 은 "직접 DNS 관리" 로 등록되어 있어 레코드를 도메인 업체(또는 쓰고 있는 DNS 서비스)에서 직접 관리합니다. 이 서비스가 DNS 를 맡게 하려면(네임서버 이전) 아래 순서로 바꾸세요. 레코드만 넣으려면 "DNS 레코드 정보" 를 누르세요.`)); + box.appendChild(steps(['"내 도메인" 에서 이 도메인을 "해제" 합니다 (사이트 연결은 다시 하면 됩니다).', '"도메인 준비" 카드에서 "네임서버 이전" 을 고르고 도메인과 쓰고 있는 하위 이름을 적어 "레코드 옮기고 네임서버 받기" 를 누릅니다.', '그때 만들어지는 네임서버 목록을 등록 업체의 네임서버(DNS 서버) 설정에 넣고 "네임서버 변경했습니다" 를 누릅니다.'])); + if (!data.dnsReady) box.appendChild(el('p', 'small dom-wait', '지금은 이 서비스의 DNS 공급자가 준비되지 않아 네임서버 이전을 쓸 수 없습니다. 관리자에게 문의하세요.')); + if (prov) box.appendChild(el('p', 'muted small', prov)); + return; + } + box.appendChild(el('p', 'small', `도메인을 구매한 등록 업체(가비아·후이즈·GoDaddy·Namecheap 등)에서 ${d.name} 의 네임서버를 아래 ${nameServers.length}개로 바꾸면 이 서비스가 DNS 를 맡아 사이트·메일 레코드를 자동으로 넣습니다.${currentNs && currentNs.length ? ` 지금 네임서버: ${currentNs.join(', ')}` : ''}`)); + box.appendChild(el('p', 'small', '어디를 누르나요?')); + box.appendChild(steps(['등록 업체에 로그인 → "도메인 관리"(My 도메인·Domain List) → ' + d.name + ' 선택', '"네임서버 설정"(네임서버 변경 · DNS 서버 · Nameservers) 메뉴를 엽니다 — "DNS 레코드 관리" 가 아닙니다', '"사용자 네임서버(Custom nameservers)" 를 고르고 기존 값을 지운 뒤 아래 목록을 1차·2차·3차·4차 순서로 붙여 넣습니다 (값 옆 "복사" 또는 값을 클릭해 Ctrl-C)', '저장한 뒤 이 화면으로 돌아와 "네임서버 변경했습니다" 를 누릅니다. 전파에는 몇 분~최대 48시간이 걸리고, 그동안 "DNS 변경 진행 중" 으로 표시됩니다.'])); + const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['순서', '네임서버']) hr.appendChild(el('th', null, h)); tb.appendChild(hr); + nameServers.forEach((ns, i) => { const tr = el('tr'); tr.appendChild(el('td', null, `${i + 1}차`)); tr.appendChild(copyCell(ns)); tb.appendChild(tr); }); + const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); box.appendChild(tw); + const row = el('div', 'row'); row.appendChild(copyBtn(nameServers.join('\n'), '네임서버 전체 복사 (줄바꿈)')); row.appendChild(copyBtn(nameServers.join(', '), '전체 복사 (쉼표)')); box.appendChild(row); + if (prov) box.appendChild(el('p', 'muted small', prov)); + }); + } + /** 레코드 목록 → 텍스트 (복사용). "이름 종류 [우선순위] 값" 한 줄씩 */ + const recordsText = (recs) => recs.map((r) => [r.host || r.name, r.type, r.priority != null ? String(r.priority) : '', r.value].filter((x) => x !== '').join('\t')).join('\n'); + /** + * "DNS 레코드 정보" 팝업 — 도메인 업체(또는 쓰는 DNS 서비스)의 DNS 레코드 관리에서 넣을 값. 사이트용(A)·메일용(MX·SPF·DMARC)·보내기 인증(DKIM 등)으로 나누고 + * 값마다 복사·Ctrl-C 가 되게 한다. MX 는 우선순위까지 따로 강조한다. + */ + function dnsRecordsDialog(d, siteId) { + infoDialog(`DNS 레코드 정보 — ${d.name}`, async (box) => { + box.appendChild(el('p', 'muted small', '필요한 레코드를 계산하는 중…')); + let r; try { r = await api(`/api/domains/${d.id}/required?${siteId ? 'siteId=' + encodeURIComponent(siteId) + '&' : ''}mail=1`); } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'small', e.message)); return; } + box.innerHTML = ''; + const recs = r.records || []; const siteRecs = recs.filter((x) => x.group === 'site'); const mailRecs = recs.filter((x) => x.group !== 'site' && x.purpose !== 'send'); const sendRecs = recs.filter((x) => x.purpose === 'send'); + box.appendChild(el('p', 'small', `${d.name} 의 DNS 레코드를 직접 관리하는 경우(도메인 업체 또는 쓰고 있는 DNS 서비스) 아래 값을 그곳에 넣습니다. 사이트 IP: ${r.ip}`)); + box.appendChild(el('p', 'small', '어디를 누르나요?')); + box.appendChild(steps(['도메인 업체(또는 DNS 서비스)에 로그인 → "도메인 관리" → ' + d.name + ' → "DNS 레코드 관리"(DNS 설정 · DNS 관리 · Manage DNS) 를 엽니다 — "네임서버 설정" 이 아닙니다', '레코드마다 "추가" 를 눌러 종류(A·MX·TXT…)·이름(호스트: @ 는 도메인 자체, www 등은 하위 이름)·값·우선순위(MX 만)를 아래 표대로 넣습니다. 값은 클릭하면 전체 선택되고 "복사" 버튼으로도 복사됩니다', 'TXT 값(SPF·DMARC·DKIM)은 따옴표 없이 그대로 붙여 넣습니다. 이미 v=spf1 레코드가 있으면 새로 만들지 말고 기존 값에 mx 를 더합니다', '저장 뒤 이 화면의 "필요한 DNS 설정" → "설정 완료 — 확인" 을 누르면 실제 반영을 확인합니다 (반영까지 몇 분~최대 48시간)'])); + const table = (list, title, note) => { + if (!list.length) return; + box.appendChild(el('h3', 'dom-dialog-h', title)); if (note) box.appendChild(el('p', 'muted small', note)); + const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['종류', '이름(호스트)', '값', '우선순위']) hr.appendChild(el('th', null, h)); tb.appendChild(hr); + for (const x of list) { const tr = el('tr'); const t = el('td', null, x.type); t.title = x.why || ''; tr.appendChild(t); tr.appendChild(copyCell(x.host || x.name)); tr.appendChild(copyCell(x.value)); tr.appendChild(x.priority != null ? copyCell(String(x.priority)) : el('td', null, '')); tb.appendChild(tr); } + const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); box.appendChild(tw); + const row = el('div', 'row'); row.appendChild(copyBtn(recordsText(list), `${title} 전체 복사`)); box.appendChild(row); + }; + table(siteRecs, '사이트용 (A 레코드)', '홈페이지가 이 서비스의 서버로 열리게 하는 레코드. @ 와 www 둘 다 넣습니다.'); + const mx = mailRecs.filter((x) => x.type === 'MX'); const txt = mailRecs.filter((x) => x.type !== 'MX'); + table(mx, '메일용 — MX (받는 메일)', '받는 메일을 이 서비스의 메일 서버로 보내는 레코드. "우선순위(priority)" 칸이 따로 있는 업체가 많고, 없으면 값 앞에 "10 " 을 붙여 넣습니다. 값 끝의 점(.)을 요구하는 업체도 있습니다.'); + table(txt, '메일용 — TXT (SPF · DMARC)', 'SPF 는 이름 @ 에, DMARC 는 이름 _dmarc 에 TXT 로 넣습니다.'); + table(sendRecs, '보내기 인증 (DKIM 등)', r.mailNote || '보내는 메일이 스팸으로 분류되지 않게 하는 레코드. 메일 발송 서비스가 확인합니다.'); + if (!mailRecs.length && !sendRecs.length) box.appendChild(el('p', 'muted small', r.mailNote || '메일용 레코드는 메일 서버가 준비되면 표시됩니다.')); + const all = el('div', 'row'); all.appendChild(copyBtn(recordsText(recs), '모든 레코드 복사 (이름·종류·우선순위·값, 탭 구분)')); box.appendChild(all); + }); + } + /** + * 직접 구매한(또는 다른 곳에서 관리하는) 도메인의 사전 안내 줄 — 어떤 경우에 어느 버튼을 누르는지. v1.6.38 (요구 3) + * @param {object} d 도메인 + * @param {() => string} siteIdOf 선택된 사이트 id (레코드 계산용) + */ + function externalGuide(d, siteIdOf) { + const g = el('div', 'dom-guide-box'); + const ns = d.mode === 'ns'; + g.appendChild(el('p', 'small', ns + ? '이 도메인은 다른 곳에서 구매해 네임서버를 이 서비스로 옮기는 중이거나 옮긴 도메인입니다. 등록 업체에서 네임서버를 바꿔야 하면 "네임서버 정보" 를, 네임서버를 옮기지 않고 그 업체의 DNS 에 레코드를 직접 넣으려면 "DNS 레코드 정보" 를 누르세요. 표시되는 값은 모두 클릭해 Ctrl-C 로 복사할 수 있습니다.' + : '이 도메인은 직접 구매해 DNS 를 다른 곳에서 관리하는 도메인입니다. 그 업체의 DNS 레코드 관리에 넣을 값(사이트 A · 메일 MX/SPF/DMARC · 보내기 인증)은 "DNS 레코드 정보" 에, 네임서버를 이 서비스로 옮기는 방법은 "네임서버 정보" 에 있습니다. 표시되는 값은 모두 클릭해 Ctrl-C 로 복사할 수 있습니다.')); + const row = el('div', 'row'); + row.appendChild(btn('네임서버 정보', 'ghost small-btn', () => nsInfoDialog(d, ns ? (d.nameServers || []) : [], d.currentNs))); + row.appendChild(btn('DNS 레코드 정보', 'ghost small-btn', () => dnsRecordsDialog(d, siteIdOf()))); + g.appendChild(row); + return g; + } + + // ---------- 등록자 정보 ---------- + const CONTACT = [['firstName', '이름 (영문)', 'Gildong'], ['lastName', '성 (영문)', 'Hong'], ['company', '회사명 (선택)', ''], ['email', '이메일', 'you@example.com'], ['phoneCountry', '국가 번호', '+82'], ['phoneArea', '지역 번호 (선택)', '2'], ['phoneNumber', '전화번호', '12345678'], ['street', '도로명 (영문)', 'Teheran-ro'], ['number', '건물 번호', '123'], ['zipcode', '우편번호', '06123'], ['city', '도시 (영문)', 'Seoul'], ['state', '시·도 (선택)', ''], ['country', '국가 코드', 'KR']]; + function contactCard() { + const c = el('details', 'dom-block dom-contact'); const sm = el('summary', null, data.contact ? '등록자 정보 (저장됨)' : '등록자 정보 (도메인 구매 전에 필요)'); c.appendChild(sm); + if (!data.contact) c.open = true; + c.appendChild(el('p', 'muted small', '도메인 소유자로 등록되는 정보입니다. 국제 등록 기관 규칙에 따라 영문으로 입력하세요. 공개 WHOIS 에는 가려집니다.')); + const grid = el('div', 'dom-grid'); const ins = {}; + for (const [k, label, ph] of CONTACT) { const lb = el('label', null, label); const i = el('input'); i.placeholder = ph; i.value = (data.contact && data.contact[k]) || (k === 'phoneCountry' ? '+82' : k === 'country' ? 'KR' : ''); ins[k] = i; lb.appendChild(i); grid.appendChild(lb); } + const msg = el('span', 'muted small'); const row = el('div', 'row'); + row.appendChild(btn('등록자 정보 저장', 'primary small-btn', async () => { + say(msg, '저장 중…'); const b = {}; for (const k of Object.keys(ins)) b[k] = ins[k].value.trim(); + try { data.contact = await api('/api/domains/contact', { method: 'PUT', body: JSON.stringify(b) }); say(msg, '저장했습니다'); sm.textContent = '등록자 정보 (저장됨)'; } catch (e) { say(msg, e.message, false); } + })); + row.appendChild(msg); c.appendChild(grid); c.appendChild(row); + return c; + } + + // ---------- ②~④ 내 도메인 ---------- + // ---------- ② 내 도메인 — 구매·갱신·네임서버 (v1.7.2) ---------- + /** 도메인 종류별 짧은 상태 배지 */ + function domainStatePill(d) { + if (d.type === 'registered') { if (d.status === 'PENDING') return el('span', 'pill small waiting', '등록 처리 중'); if (d.status === 'FAILED') return el('span', 'pill small error', '등록 실패'); if (d.pending) return el('span', 'pill small waiting', 'DNS 전파 대기'); return el('span', 'pill small done', '사용 중'); } + if (d.type === 'external' && d.mode === 'ns') { const st = d.transfer ? d.transfer.step : 'ns-change'; return el('span', 'pill small ' + (st === 'done' ? 'done' : 'waiting'), st === 'done' ? '네임서버 이전 완료' : st === 'propagating' ? '네임서버 전파 중' : '네임서버 변경 필요'); } + if (d.type === 'external') { const m = d.manual || {}; return el('span', 'pill small ' + (m.siteOk ? 'done' : 'idle'), m.siteOk ? 'DNS 확인됨' : 'DNS 직접 관리'); } + return el('span', 'pill small done', '등록됨'); + } + function myDomainsCard() { + const c = el('section', 'card'); c.appendChild(el('h2', null, data.accountType === 'subdomain' ? '내 서브 도메인' : '② 내 도메인 — 구매 · 갱신 · 네임서버')); + if (!data.domains.length) { c.appendChild(el('p', 'muted small', '아직 등록한 도메인이 없습니다.')); return c; } + c.appendChild(el('p', 'muted small', '사이트 연결·DNS 레코드는 "DNS" 화면, 메일(MX)은 "메일" 화면에서 다룹니다.')); + for (const d of data.domains) c.appendChild(domainRow(d)); + return c; + } + /** + * 도메인 한 줄: 이름 · 종류 · 상태 + 종류별 관리 항목 — 구입 도메인은 만료·갱신, 네임서버 이전 도메인은 단계 표시(nsStepper), 직접 관리 도메인은 DNS 화면 안내. + * 사이트 연결 정보는 요약 한 줄로만 보이고 바꾸는 일은 DNS 화면에서 한다. + */ + function domainRow(d) { + const sub = d.type === 'subdomain'; const ext = d.type === 'external'; + const it = el('div', 'dom-item'); + const top = el('div', 'dom-top'); top.appendChild(el('b', null, d.name)); top.appendChild(el('span', 'pill idle small', kindLabel(d))); top.appendChild(domainStatePill(d)); + if (d.type === 'registered' && d.status !== 'PENDING') top.appendChild(el('span', 'muted small', `만료 ${dt(d.expiresAt)}`)); + it.appendChild(top); + const site = data.sites.find((s0) => s0.id === d.siteId); + const link = el('p', 'muted small'); link.appendChild(document.createTextNode(site ? `연결된 사이트: ${site.title} → ${(d.siteNames || []).join(', ') || d.name} · ` : '아직 사이트에 연결되지 않았습니다 · ')); + const go = el('a', null, 'DNS 화면에서 연결·레코드 관리'); go.href = '#'; go.onclick = (e) => { e.preventDefault(); window.showTab('dns'); }; link.appendChild(go); it.appendChild(link); + const waitBox = el('p', 'small dom-wait hidden'); it.appendChild(waitBox); if (d.pending && !(ext && d.mode === 'ns')) checkPropagation(d, waitBox); + const msg = el('p', 'muted small'); + if (ext && d.mode === 'ns') it.appendChild(nsStepper(d, msg)); + if (ext && d.mode === 'manual') it.appendChild(el('p', 'small', 'DNS 를 직접 관리하는 도메인입니다. 사이트·메일에 필요한 레코드와 "설정 완료 — 확인" 은 DNS 화면의 이 도메인 항목에 있습니다.')); + if (d.type === 'registered' && d.status === 'PENDING') it.appendChild(el('p', 'small dom-wait', '등록 업체가 아직 등록을 처리하고 있습니다. 끝나면 DNS 영역이 자동으로 만들어집니다 (이 화면을 다시 열면 확인합니다).')); + const row = el('div', 'row dom-row'); + if (d.type === 'registered') row.appendChild(btn('갱신 (1년)', 'primary small-btn', () => renewFlow(d, msg))); + if (sub || ext) row.appendChild(btn('해제', 'ghost small-btn danger', async () => { + if (!confirm(`${d.name} 를 이 서비스에서 해제할까요? 연결된 사이트는 이 주소로 열리지 않게 됩니다.${ext && d.mode === 'ns' ? '\n(DNS 영역은 공급자에 남습니다 — 네임서버를 다른 곳으로 옮긴 뒤 관리자에게 정리를 요청하세요)' : ''}`)) return; + try { const r = await api(`/api/domains/${d.id}`, { method: 'DELETE' }); if (r.note) alert(r.note); await refreshList(); } catch (e) { say(msg, e.message, false); } + })); + if (row.children.length) it.appendChild(row); it.appendChild(msg); + return it; + } + /** + * v1.7.2 (요구 1): 네임서버 이전 진행 단계 — ① 영역 준비·레코드 옮김 → ② 등록 업체에서 네임서버 변경 → ③ 전파 확인 → ④ 완료. + * 지금 단계는 transfer.step(ns-change | propagating | done | error)과 userConfirmedAt 으로 정한다. ③ 에서는 기대 네임서버와 지금 공용 DNS 에 보이는 네임서버를 나란히 ✓/✗ 로 보인다. + */ + function nsStepper(d, msg) { + const t = d.transfer || { step: 'ns-change' }; const step = t.step === 'done' ? 4 : (t.step === 'propagating' || t.userConfirmedAt ? 3 : 2); + const wrap = el('div', 'ns-stepper'); + const STEPS = [['영역 준비', `레코드 ${d.imported || 0}개를 옮겨 둠`], ['네임서버 변경', '등록 업체에서 바꾸기'], ['전파 확인', '최대 48시간'], ['완료', 'DNS 를 이 서비스가 관리']]; + const line = el('div', 'ns-steps'); + STEPS.forEach(([title, sub], i) => { const n = i + 1; const s0 = el('div', 'ns-step ' + (n < step ? 'done' : n === step ? (step === 4 ? 'done' : 'now') : 'todo')); s0.appendChild(el('span', 'ns-dot', n < step || step === 4 ? '✓' : String(n))); const tx = el('div'); tx.appendChild(el('div', 'ns-t', title)); tx.appendChild(el('div', 'ns-s', sub)); s0.appendChild(tx); line.appendChild(s0); }); + wrap.appendChild(line); + const detail = el('div', 'ns-detail'); + if (step === 2) { + detail.appendChild(el('p', 'small', `등록 업체(${d.name} 을 구매한 곳)의 네임서버 설정을 이 서비스의 네임서버 ${(d.nameServers || []).length}개로 바꾼 뒤 "네임서버 변경했습니다" 를 누르세요.${d.currentNs && d.currentNs.length ? ` 지금 네임서버: ${d.currentNs.join(', ')}` : ''}`)); + const row = el('div', 'row'); row.appendChild(btn('네임서버 정보 (복사)', 'ghost small-btn', () => nsInfoDialog(d, d.nameServers || [], d.currentNs))); + row.appendChild(btn('네임서버 변경했습니다', 'primary small-btn', async () => { if (!confirm(`등록 업체에서 ${d.name} 의 네임서버를 "네임서버 정보" 의 목록으로 바꾸어 저장하셨나요?\n확인을 누르면 공용 DNS 에서 실제로 바뀌었는지 확인합니다.`)) return; say(msg, '확인 중…'); try { const r = await api(`/api/domains/${d.id}/transfer-check`, { method: 'POST', body: JSON.stringify({ confirmed: true }) }); say(msg, r.message, r.done || r.step === 'propagating'); await refreshList(); } catch (e) { say(msg, e.message, false); } })); + detail.appendChild(row); + } else if (step === 3) { + detail.appendChild(el('p', 'small', `네임서버 변경을 ${dtm(t.userConfirmedAt)} 에 확인했습니다 — 공용 DNS 에 새 네임서버가 보일 때까지 기다리는 중입니다 (보통 수 분~수 시간, 최대 48시간). 그동안 이 도메인의 레코드 수정은 잠깁니다.`)); + const expect = d.nameServers || []; const got = (d.currentNs || []).map((x) => String(x).toLowerCase()); + const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['바꿀 네임서버', '공용 DNS 에 보이는 값']) hr.appendChild(el('th', null, h)); tb.appendChild(hr); + for (const ns of expect) { const tr = el('tr'); tr.appendChild(copyCell(ns)); const okCell = el('td', null, got.includes(String(ns).toLowerCase()) ? '✓ 보임' : '✕ 아직'); okCell.style.color = got.includes(String(ns).toLowerCase()) ? '#15803d' : '#b91c1c'; tr.appendChild(okCell); tb.appendChild(tr); } + for (const ns of got.filter((x) => !expect.map((e) => String(e).toLowerCase()).includes(x))) { const tr = el('tr'); tr.appendChild(el('td', 'muted small', '(옛 네임서버)')); tr.appendChild(el('td', 'muted small', ns)); tb.appendChild(tr); } + const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); detail.appendChild(tw); + const row = el('div', 'row'); row.appendChild(btn('지금 다시 확인', 'primary small-btn', async () => { say(msg, '확인 중…'); try { const r = await api(`/api/domains/${d.id}/transfer-check`, { method: 'POST', body: JSON.stringify({}) }); say(msg, r.message, r.done || r.step === 'propagating'); await refreshList(); } catch (e) { say(msg, e.message, false); } })); + row.appendChild(btn('네임서버 정보', 'ghost small-btn', () => nsInfoDialog(d, expect, d.currentNs))); detail.appendChild(row); + } else detail.appendChild(el('p', 'small', `${t.completedAt ? dtm(t.completedAt) + ' 에 ' : ''}네임서버 이전이 확인되었습니다. 이 도메인의 DNS 레코드를 이 서비스에서 관리합니다 — 사이트 연결·레코드는 DNS 화면에서.`)); + wrap.appendChild(detail); + return wrap; + } + /** 전파 상태 확인 → 진행 중이면 "약 N분 후에 다시 이용"·"DNS 변경 진행 중" 안내 */ + async function checkPropagation(d, node) { + try { const p = await api(`/api/domains/${d.id}/propagation`); wait(node, p.pending ? p.message : ''); } catch (_) {} + } + /** 갱신: 견적을 먼저 받아 금액을 보여 주고 확인받는다 */ + async function renewFlow(d, msg) { + say(msg, '갱신 가격 확인 중…'); + try { + let q = await api('/api/domains/quote', { method: 'POST', body: JSON.stringify({ domain: d.name, operation: 'renew', period: 1 }) }); say(msg, ''); + for (let i = 0; i < 2; i++) { + if (!confirm(`${d.name} 를 1년 갱신합니다.\n결제 금액 ${won(q.costKrw)} (캐시 잔액 ${won(balance())} → ${won(balance() - q.costKrw)})\n진행할까요?`)) return; + try { await api(`/api/domains/${d.id}/renew`, { method: 'POST', body: JSON.stringify({ period: 1, expectedKrw: q.costKrw }) }); say(msg, '갱신했습니다'); await refreshList(); return; } + catch (e) { if (e.body && e.body.quote) { q = e.body.quote; say(msg, e.message, false); continue; } throw e; } + } + } catch (e) { say(msg, e.message, false); } + } + + // ---------- ③ 직접 DNS 관리: 필요한 DNS 설정 · 설정 완료 확인 ---------- + /** 필요한 레코드 표(사이트용 + 선택적으로 메일용)와 확인 줄 */ + async function loadRequired(d, sel, box, withMail) { + box.innerHTML = ''; box.appendChild(el('p', 'muted small', '필요한 레코드를 계산하는 중…')); + try { + const r = await api(`/api/domains/${d.id}/required?${sel.value ? 'siteId=' + encodeURIComponent(sel.value) + '&' : ''}${withMail ? 'mail=1' : ''}`); + box.innerHTML = ''; + box.appendChild(el('p', 'small', `${d.name} 의 DNS 서비스(도메인을 구매한 곳 또는 쓰고 있는 DNS)에 아래 레코드를 넣으세요. 사이트 IP: ${r.ip}`)); + box.appendChild(requiredTable(r.records)); + if (r.mailNote) box.appendChild(el('p', 'muted small', r.mailNote)); + if (r.verified) box.appendChild(el('p', r.verified.siteOk ? 'muted small' : 'small dom-wait', r.verified.siteOk ? `사이트용 레코드 확인됨 (${dtm(r.verified.at)})${r.verified.mailOk === true ? ' · 메일용 레코드 확인됨' : r.verified.mailOk === false ? ' · 메일용 레코드는 아직 확인되지 않음' : ''}` : r.verified.lastCheck ? `마지막 확인 ${dtm(r.verified.lastCheck)}: DNS 변경이 완료되지 않았거나 아직 반영되지 않았습니다 — 이 상태로는 배포가 정상적으로 되지 않습니다` : '아직 확인하지 않았습니다 — 레코드를 넣은 뒤 "설정 완료 — 확인" 을 누르세요')); + box.appendChild(verifyRow(d, sel, box, !!r.wantMail)); + } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'small', e.message)); } + } + /** "설정 완료 — 확인" 줄: 사용자 확인(confirmed) → 공용 DNS 조회 → 레코드별 결과 표와 사용 가능 여부 */ + function verifyRow(d, sel, box, withMail) { + const row = el('div', 'row dom-row'); const msg = el('p', 'small'); + const mailCb = el('input'); mailCb.type = 'checkbox'; mailCb.checked = !!withMail; const lb = el('label', 'inline'); lb.appendChild(mailCb); lb.appendChild(document.createTextNode(' 메일용 레코드 포함')); + row.appendChild(lb); + row.appendChild(btn('설정 완료 — 확인', 'primary small-btn', async () => { + if (!confirm(`${d.name} 의 DNS 서비스에 위 레코드를 모두 넣고 저장하셨나요?\n확인을 누르면 공용 DNS 에서 실제로 반영됐는지 확인합니다 (반영까지 몇 분~최대 48시간).`)) return; + say(msg, '공용 DNS 에서 확인 중…'); + try { + const r = await api(`/api/domains/${d.id}/verify`, { method: 'POST', body: JSON.stringify({ siteId: sel.value || undefined, confirmed: true, mail: mailCb.checked }) }); + say(msg, r.message, r.ok); const old = box.querySelector('.dom-verify-result'); if (old) old.remove(); + const res = el('div', 'dom-verify-result'); res.appendChild(requiredTable(r.records, true)); box.appendChild(res); + if (r.ok) await refreshList(); + } catch (e) { say(msg, e.message, false); } + })); + row.appendChild(btn('필요한 설정 다시 보기', 'ghost small-btn', () => loadRequired(d, sel, box, mailCb.checked))); + const wrap = el('div'); wrap.appendChild(row); wrap.appendChild(msg); return wrap; + } + /** 필요한 레코드 표. withResult 면 확인 결과(지금 값·확인) 열을 더한다 */ + function requiredTable(recs, withResult) { + const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['용도', '종류', '이름', '값', '우선순위', ...(withResult ? ['지금 값', '확인'] : [])]) hr.appendChild(el('th', null, h)); tb.appendChild(hr); + for (const r of recs) { + const tr = el('tr'); const g = el('td', null, r.group === 'site' ? '사이트' : (r.purpose === 'send' ? '메일(보내기)' : '메일')); g.title = r.why || ''; tr.appendChild(g); + tr.appendChild(el('td', null, r.type)); tr.appendChild(el('td', null, r.host || r.name)); tr.appendChild(copyCell(r.value)); tr.appendChild(el('td', null, r.priority != null ? String(r.priority) : '')); // v1.6.38: 값은 클릭 선택·복사 버튼 + if (withResult) { tr.appendChild(el('td', 'dom-val', r.providerChecked ? '(공급자가 확인)' : (r.found || []).join(', ') || '없음')); const ok = el('td', null, r.providerChecked ? '-' : r.ok ? '✓ 확인됨' : (r.required ? '✕ 아직' : '– 선택')); ok.style.color = r.providerChecked ? '' : r.ok ? '#15803d' : (r.required ? '#b91c1c' : ''); tr.appendChild(ok); } + tb.appendChild(tr); + } + const wrap = el('div', 'dom-table-wrap'); wrap.appendChild(tb); return wrap; + } + /** ④ 도메인 상태 표 — 단계별 ✓/✕/⏳ 와 설명, 요약 */ + function statusTable(s) { + const wrap = el('div', 'dom-status'); + wrap.appendChild(el('p', s.ok ? 'small' : 'small dom-wait', s.summary)); + const ICON = { ok: '✓', fail: '✕', wait: '⏳', warn: '△', na: '–' }; const COLOR = { ok: '#15803d', fail: '#b91c1c', wait: '#a16207', warn: '#a16207', na: '' }; + const tb = el('table', 'q-table dom-table'); + for (const st of s.steps) { const tr = el('tr'); const ic = el('td', 'dom-st-icon', ICON[st.state] || ''); ic.style.color = COLOR[st.state] || ''; tr.appendChild(ic); tr.appendChild(el('td', 'dom-st-label', st.label)); tr.appendChild(el('td', 'dom-st-detail', st.detail)); tb.appendChild(tr); } + const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); wrap.appendChild(tw); + return wrap; + } + + + /** + * "메일(MX) 자동 설정" 버튼 (v1.6.40: 도메인 화면의 도메인 항목과 메일 화면이 같이 쓴다). + * 받는 메일 MX·SPF·DMARC 와 Resend 보내기 레코드(DKIM 등)를 자동으로 만든다. 직접 DNS 관리 도메인은 넣을 레코드를 안내한다. + * @param {object} d 도메인 @param {HTMLSelectElement|{value:string}} sel 메일을 쓸 사이트 선택 @param msg 결과 줄 @param detail 레코드 표가 열리는 상자 @param waitBox 전파 안내 상자 @param {boolean} manual 직접 DNS 관리 도메인인지 + */ + function mailSetupBtn(d, sel, msg, detail, waitBox, manual, cls) { + return btn('메일(MX) 자동 설정', cls || 'primary small-btn', async () => { + if (!sel.value) return say(msg, '메일을 쓸 사이트를 고르세요', false); + say(msg, '메일 레코드를 만드는 중…'); + try { + const r = await api(`/api/domains/${d.id}/mail`, { method: 'POST', body: JSON.stringify({ siteId: sel.value }) }); + if (r.mode === 'manual') { say(msg, manual ? '이 도메인의 DNS 는 직접 관리하므로 아래 레코드를 도메인의 DNS 서비스에 넣은 뒤 "설정 완료 — 확인" 을 누르세요' : '이 도메인은 DNS 를 자동으로 바꿀 수 없어 아래 레코드를 직접 넣어야 합니다'); detail.innerHTML = ''; detail.appendChild(recordTable(r.records.map((x) => ({ name: x.host || x.name, type: x.type, value: x.value, prio: x.priority })), null)); detail.appendChild(btn('DNS 레코드 정보 (복사 가능)', 'ghost small-btn', () => dnsRecordsDialog(d, sel.value))); if (manual) detail.appendChild(verifyRow(d, sel, detail, true)); } + else say(msg, `메일 레코드 ${r.records.length}개를 만들었습니다${r.mailNote ? ' · ' + r.mailNote : ''}`); + if (r.propagation && r.propagation.pending) wait(waitBox, r.propagation.message); + } catch (e) { say(msg, e.message, false); } + }); + } + + // ---------- DNS 레코드 (구입 도메인 · 네임서버 이전 도메인) ---------- + /** DNS 레코드 표 (readonly 면 지우기·추가 없음 — 서브 도메인, 또는 네임서버 이전 전파 중 잠금) */ + async function loadRecords(d, box, readonly) { + box.innerHTML = ''; box.appendChild(el('p', 'muted small', '레코드 불러오는 중…')); + let recs; try { recs = (await api(`/api/domains/${d.id}/records`)).records; } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', e.message)); return; } + box.innerHTML = ''; + if (d.dnsLocked) box.appendChild(el('p', 'small dom-wait', 'DNS 변경 진행 중 — 네임서버 이전이 확인될 때까지 레코드를 수정할 수 없습니다 (이중 수정 방지). 아래는 새 영역에 옮겨 둔 레코드입니다.')); + box.appendChild(recordTable(recs, readonly ? null : d, box)); + if (!readonly) box.appendChild(addRecordRow(d, box)); + } + function recordTable(recs, d, box) { + const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['종류', '이름', '값', '우선순위', '']) hr.appendChild(el('th', null, h)); tb.appendChild(hr); + if (!recs.length) { const tr = el('tr'); const td = el('td', 'muted small', '레코드가 없습니다'); td.colSpan = 5; tr.appendChild(td); tb.appendChild(tr); } + for (const r of recs) { + const tr = el('tr'); tr.appendChild(el('td', null, r.type)); tr.appendChild(el('td', null, r.name || '@')); const v = el('td', 'dom-val', r.value); tr.appendChild(v); tr.appendChild(el('td', null, r.prio !== undefined && r.prio !== null ? String(r.prio) : '')); + const td = el('td'); + if (d && !['NS', 'SOA'].includes(r.type)) td.appendChild(btn('삭제', 'ghost small-btn danger', async () => { + if (!confirm(`${r.type} ${r.name || '@'} → ${r.value} 레코드를 지울까요?`)) return; + try { await api(`/api/domains/${d.id}/records`, { method: 'DELETE', body: JSON.stringify(r) }); await loadRecords(d, box); } catch (e) { alert(e.message); } + })); + tr.appendChild(td); tb.appendChild(tr); + } + const wrap = el('div', 'dom-table-wrap'); wrap.appendChild(tb); return wrap; + } + function addRecordRow(d, box) { + const row = el('div', 'row dom-add'); const type = el('select'); for (const t of ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'CAA', 'SRV']) { const o = el('option', null, t); o.value = t; type.appendChild(o); } + const name = el('input'); name.placeholder = '이름 (비우면 @)'; const value = el('input'); value.placeholder = '값'; const prio = el('input'); prio.placeholder = '우선순위'; prio.type = 'number'; prio.className = 'dom-prio'; + const msg = el('span', 'muted small'); + row.appendChild(type); row.appendChild(name); row.appendChild(value); row.appendChild(prio); + row.appendChild(btn('레코드 추가', 'primary small-btn', async () => { + say(msg, '추가 중…'); + try { await api(`/api/domains/${d.id}/records`, { method: 'POST', body: JSON.stringify({ type: type.value, name: name.value.trim(), value: value.value.trim(), prio: prio.value }) }); await loadRecords(d, box); } catch (e) { say(msg, e.message, false); } + })); + row.appendChild(msg); return row; + } + + // ---------- v1.7.2 DNS 화면: 사이트 ↔ 도메인 연결이 먼저, 레코드 관리는 그 아래 ---------- + /** 도메인의 짧은 준비 상태(연결 표용) */ + function readyPill(d) { + if (d.dnsLocked || (d.transfer && d.transfer.step && d.transfer.step !== 'done')) return el('span', 'pill small waiting', '네임서버 전파 대기'); + if (d.pending) return el('span', 'pill small waiting', 'DNS 전파 대기'); + if (d.type === 'external' && d.mode === 'manual') return el('span', 'pill small ' + (d.manual && d.manual.siteOk ? 'done' : 'error'), d.manual && d.manual.siteOk ? 'DNS 확인됨' : 'DNS 확인 필요'); + if (d.type === 'registered' && d.status === 'PENDING') return el('span', 'pill small waiting', '등록 처리 중'); + return el('span', 'pill small done', '준비됨'); + } + function renderDns(box) { + const head = el('section', 'card'); head.appendChild(el('h2', null, 'DNS — 사이트를 어느 주소로 열까요?')); + head.appendChild(el('p', 'muted small', '먼저 사이트와 도메인의 연결 관계를 정합니다: 어떤 사이트를 어느 도메인의 어떤 이름(기본 @·www, 또는 shop 같은 하위 이름)으로 열지. 연결하면 그 이름의 A 레코드가 자동으로 만들어지고(직접 관리 도메인은 안내), 다음 발행 때 웹서버 설정·SSL 이 적용됩니다. 도메인별 DNS 레코드 관리는 아래 "도메인별 DNS 레코드" 에서 그대로 할 수 있습니다.')); + if (data.dnsProviderLabel) head.appendChild(el('p', 'muted small', `DNS 공급자: ${data.dnsProviderLabel}${data.targetIp ? ` · 사이트 IP: ${data.targetIp}` : ''}`)); + box.appendChild(head); + if (!data.domains.length) { const c = el('section', 'card'); c.appendChild(el('p', 'muted small', '아직 등록한 도메인이 없습니다. 먼저 "도메인" 화면에서 도메인을 준비하세요.')); const r = el('div', 'row'); r.appendChild(btn('도메인 화면으로', 'primary small-btn', () => window.showTab('domains'))); c.appendChild(r); box.appendChild(c); return; } + box.appendChild(connectionsCard()); + const h = el('section', 'card'); h.appendChild(el('h2', null, '도메인별 DNS 레코드')); h.appendChild(el('p', 'muted small', '구매한 도메인과 네임서버를 이전한 도메인은 레코드를 직접 추가·삭제할 수 있고, 서브 도메인은 보기만, 직접 DNS 관리 도메인은 필요한 레코드를 안내하고 반영 여부를 확인합니다.')); box.appendChild(h); + for (const d of data.domains) box.appendChild(dnsItem(d)); + } + /** + * 사이트 ↔ 도메인 연결 표 (v1.7.2 요구 1의 핵심). 사이트마다 한 줄: 열리는 주소(연결된 도메인의 이름들) · 준비 상태 · 연결 바꾸기(도메인 선택 + 하위 이름) · 연결 끊기. + * 도메인 하나에는 사이트 하나만 연결된다(다른 사이트에 연결하면 이전 연결은 풀린다). 연결되지 않은 도메인은 표 아래에 따로 보인다. + */ + function connectionsCard() { + const c = el('section', 'card'); c.appendChild(el('h2', null, '사이트 ↔ 도메인 연결')); + if (!data.sites.length) { c.appendChild(el('p', 'muted small', '아직 사이트가 없습니다. "만들기" 에서 사이트를 만든 뒤 여기서 도메인에 연결하세요.')); return c; } + const msg = el('p', 'muted small'); + const cur = typeof current !== 'undefined' ? current : null; + // 사이트마다 한 블록(좁은 작업 칸에서도 읽히게 표 대신 세로 블록): 제목 → 열리는 주소 + 상태 → 연결 바꾸기 → 연결 끊기 + for (const s0 of data.sites) { + const mine = data.domains.filter((d) => d.siteId === s0.id); + const blk = el('div', 'conn-site' + (cur && cur.id === s0.id ? ' on' : '')); + const head = el('div', 'conn-head'); head.appendChild(el('b', null, s0.title)); if (cur && cur.id === s0.id) head.appendChild(el('span', 'pill small running', '지금 보는 사이트')); blk.appendChild(head); + const addr = el('div', 'conn-addr'); + if (!mine.length) { addr.appendChild(el('span', 'pill small idle', '미연결')); addr.appendChild(el('span', 'muted small', '연결된 도메인 없음 — 서버 주소로만 열립니다')); } + else for (const d of mine) { const line = el('div', 'conn-line'); line.appendChild(el('span', 'conn-arrow', '→')); (d.siteNames || [d.name]).forEach((n, i) => { if (i) line.appendChild(document.createTextNode(', ')); const a = el('a', null, n); a.href = 'https://' + n + '/'; a.target = '_blank'; a.rel = 'noopener'; line.appendChild(a); }); line.appendChild(readyPill(d)); line.appendChild(btn('연결 끊기', 'ghost small-btn danger', async () => { if (!confirm(`${s0.title} 에서 ${d.name} 연결을 끊을까요? DNS 레코드는 남고, 다음 발행 때 웹서버 설정에서 빠집니다.`)) return; try { await api(`/api/domains/${d.id}/disconnect`, { method: 'POST', body: JSON.stringify({ siteId: s0.id }) }); say(msg, `${d.name} 연결을 끊었습니다`); if (cur && cur.id === s0.id) openSite(s0.id, { tab: 'dns' }).catch(() => {}); await refreshList(); } catch (e) { say(msg, e.message, false); } })); addr.appendChild(line); } + blk.appendChild(addr); + const sel = el('select'); const o0 = el('option', null, mine.length ? '다른 도메인으로 바꾸기…' : '연결할 도메인 선택'); o0.value = ''; sel.appendChild(o0); + for (const d of data.domains) { if (d.siteId === s0.id) continue; const o = el('option', null, d.name + (d.siteId ? ` (다른 사이트: ${(data.sites.find((x) => x.id === d.siteId) || {}).title || ''})` : '')); o.value = d.id; sel.appendChild(o); } + const host = el('input', 'dom-host hidden'); host.placeholder = '하위 이름 (비우면 @ · www)'; host.maxLength = 32; host.autocomplete = 'off'; + const bConn = btn('연결', 'primary small-btn', async () => { + if (!sel.value) return say(msg, `${s0.title}: 연결할 도메인을 고르세요`, false); + const d = data.domains.find((x) => x.id === sel.value); const h = host.classList.contains('hidden') ? '' : host.value.trim(); + if (d.siteId && d.siteId !== s0.id && !confirm(`${d.name} 은 이미 다른 사이트에 연결되어 있습니다. ${s0.title} 로 바꿀까요? (이전 사이트의 주소 목록에서 빠집니다)`)) return; + say(msg, '연결 중…'); bConn.disabled = true; + try { const r = await api(`/api/domains/${d.id}/connect`, { method: 'POST', body: JSON.stringify({ siteId: s0.id, host: h }) }); say(msg, `연결했습니다: ${s0.title} → ${r.domains.join(', ')} — 다음 발행·재배포 때 웹서버 설정과 SSL 이 적용됩니다${r.guide ? ' · ' + r.guide : ''}`); if (cur && cur.id === s0.id) openSite(s0.id, { tab: 'dns' }).catch(() => {}); await refreshList(); } catch (e) { say(msg, e.message, false); bConn.disabled = false; } + }); + bConn.disabled = true; + sel.onchange = () => { const d = data.domains.find((x) => x.id === sel.value); host.classList.toggle('hidden', !d || d.type === 'subdomain'); bConn.disabled = !sel.value; }; + const row = el('div', 'row dom-conn-row'); row.appendChild(sel); row.appendChild(host); row.appendChild(bConn); blk.appendChild(row); + c.appendChild(blk); + } + c.appendChild(msg); + const free = data.domains.filter((d) => !d.siteId); + c.appendChild(el('p', 'muted small', free.length ? `연결되지 않은 도메인: ${free.map((d) => d.name).join(', ')} — 위에서 사이트를 골라 연결하세요.` : '모든 도메인이 사이트에 연결되어 있습니다.')); + c.appendChild(el('p', 'muted small', '하위 이름을 적으면 그 이름 하나(예: shop.example.com)로, 비우면 도메인 자신과 www 로 엽니다. 도메인 하나에는 사이트 하나만 연결됩니다.')); + return c; + } + function dnsItem(d) { + const sub = d.type === 'subdomain'; const ext = d.type === 'external'; const manual = ext && d.mode === 'manual'; + const c = el('section', 'card'); + const top = el('div', 'dom-head-row'); top.appendChild(el('h2', null, d.name)); top.appendChild(el('span', 'pill idle small', kindLabel(d))); top.appendChild(readyPill(d)); + const site = data.sites.find((s0) => s0.id === d.siteId); top.appendChild(el('span', 'muted small', site ? `→ ${site.title} (${(d.siteNames || []).join(', ') || d.name})` : '사이트에 연결되지 않음')); + c.appendChild(top); + const waitBox = el('p', 'small dom-wait hidden'); c.appendChild(waitBox); if (d.pending) checkPropagation(d, waitBox); + const msg = el('p', 'muted small'); const detail = el('div'); + if (ext && d.mode === 'ns' && d.transfer && d.transfer.step !== 'done') c.appendChild(el('p', 'small dom-wait', '네임서버 이전이 아직 끝나지 않았습니다 — 진행 단계와 "네임서버 변경했습니다" 는 도메인 화면에 있습니다. 전파가 확인될 때까지 레코드 수정은 잠깁니다.')); + if (ext) c.appendChild(externalGuide(d, () => d.siteId || '')); + const row = el('div', 'row dom-row'); + row.appendChild(btn('도메인 상태', 'ghost small-btn', async () => { detail.innerHTML = ''; detail.appendChild(el('p', 'muted small', '확인 중…')); try { const s0 = await api(`/api/domains/${d.id}/status${d.siteId ? '?siteId=' + encodeURIComponent(d.siteId) : ''}`); detail.innerHTML = ''; detail.appendChild(statusTable(s0)); } catch (e) { detail.innerHTML = ''; detail.appendChild(el('p', 'small', e.message)); } })); + const recBox = el('div', 'dom-recs'); + if (manual) { const selStub = { value: d.siteId || '' }; row.appendChild(btn('필요한 DNS 설정 다시 보기', 'ghost small-btn', () => loadRequired(d, selStub, recBox))); loadRequired(d, selStub, recBox); } + else { const open = el('details', 'dom-rec-details'); const sm = el('summary', null, 'DNS 레코드 보기 · 관리'); open.appendChild(sm); open.appendChild(recBox); open.ontoggle = () => { if (open.open && !recBox.dataset.loaded) { recBox.dataset.loaded = '1'; loadRecords(d, recBox, sub || d.dnsLocked); } }; c.appendChild(row); c.appendChild(msg); c.appendChild(detail); c.appendChild(open); return c; } + c.appendChild(row); c.appendChild(msg); c.appendChild(detail); c.appendChild(recBox); + return c; + } + + // ---------- v1.7.2 메일 화면: 계정 형식 · 접속 주소 · 초기 관리자를 한눈에 ---------- + function renderMail(box) { + const head = el('section', 'card'); head.appendChild(el('h2', null, '메일')); + head.appendChild(el('p', 'muted small', '순서: ① 도메인의 DNS 준비 → ② 아래에서 도메인에 "메일(MX) 자동 설정" → ③ 운영팀이 사이트 메일 시스템을 켬 → ④ 사이트 관리자 페이지 › 회원에서 "메일 사용 켜기" → ⑤ 웹메일 또는 메일 프로그램으로 접속. 메일은 사이트 제작·배포와 무관하게 준비할 수 있지만 DNS 가 준비된 도메인에서만 동작합니다.')); + box.appendChild(head); + const cur = typeof current !== 'undefined' ? current : null; + if (cur) box.appendChild(mailGlanceCard(cur)); + else box.appendChild(el('p', 'muted small', '왼쪽 레일에서 사이트를 고르면 그 사이트의 메일 계정 형식·접속 주소·초기 관리자 계정이 여기에 보입니다.')); + if (!data.domains.length) { const c = el('section', 'card'); c.appendChild(el('p', 'muted small', '아직 등록한 도메인이 없습니다. 메일을 쓰려면 먼저 "도메인" 화면에서 도메인을 준비하세요.')); const r = el('div', 'row'); r.appendChild(btn('도메인 화면으로', 'primary small-btn', () => window.showTab('domains'))); c.appendChild(r); box.appendChild(c); return; } + const h = el('section', 'card'); h.appendChild(el('h2', null, '도메인별 메일(MX) 설정')); h.appendChild(el('p', 'muted small', `받는 메일 MX·SPF·DMARC 와 보내는 메일 DKIM 레코드를 자동으로 만듭니다.${data.mailHost ? ` 받는 메일 서버(MX): ${data.mailHost}` : ''}`)); box.appendChild(h); + for (const d of data.domains) box.appendChild(mailItem(d)); + } + /** + * 한눈에 보기: 이 사이트의 메일 — 상태, 계정 형식(아이디@도메인), 접속 주소(웹메일 · 메일 서버 웹메일 · IMAP/POP/SMTP), 초기 관리자(사이트 관리자 계정과 메일 상태). + * 값은 app.js current.mail (publicView 의 mailAtAGlance) 에서 온다. 비밀번호는 보이지 않는다 — 메일 비밀번호는 사이트 관리자 비밀번호와 같다(1.6.39 동기화). + */ + function mailGlanceCard(site) { + const m = site.mail || {}; const c = el('section', 'card'); c.appendChild(el('h2', null, `이 사이트의 메일 — ${site.title}`)); + const line = el('div', 'dom-mailstat'); + if (!m || m.level === 'off') { line.appendChild(el('span', 'pill small idle', '꺼짐')); line.appendChild(el('span', 'muted small', '이 사이트의 메일 시스템은 아직 켜지지 않았습니다. 아래에서 도메인에 메일(MX)을 설정한 뒤 운영팀에 메일 시스템 사용을 요청하세요.')); c.appendChild(line); return c; } + line.appendChild(el('span', 'pill small ' + (m.level === 'setting-up' ? 'waiting' : 'done'), m.label)); line.appendChild(el('span', 'muted small', m.message || '')); c.appendChild(line); + const kv = el('div', 'mail-glance'); + const row = (k, node) => { const r = el('div', 'mg-row'); r.appendChild(el('div', 'mg-k', k)); const v = el('div', 'mg-v'); if (typeof node === 'string') v.textContent = node; else v.appendChild(node); r.appendChild(v); kv.appendChild(r); }; + row('메일 계정 형식', m.accountFormat ? m.accountFormat : `아이디@${m.domain || '(도메인 없음)'}`); + const acc = el('div'); acc.appendChild(el('div', null, '사이트 관리자 페이지 › 회원 에서 "메일 사용 켜기" 를 누른 회원의 로그인 ID 가 메일 아이디가 됩니다. 비밀번호는 그 회원의 사이트 관리자 비밀번호와 같습니다.')); row('계정 만들기', acc); + const links = el('div'); + if (m.webmailUrl) { const a = el('a', 'btn ghost small-btn', '웹메일 열기 (이 서비스)'); a.href = m.webmailUrl; a.target = '_blank'; a.rel = 'noopener'; links.appendChild(a); } + if (m.server && m.server.webmail) { const a = el('a', 'btn ghost small-btn', `메일 서버 웹메일 (${m.server.host || ''})`); a.href = m.server.webmail; a.target = '_blank'; a.rel = 'noopener'; links.appendChild(a); } + if (!links.children.length) links.appendChild(el('span', 'muted small', '메일 서버가 연결되면 접속 주소가 여기에 보입니다')); + row('접속 주소', links); + if (m.server && m.server.client) { const cl = m.server.client; const t = el('div', 'small'); t.appendChild(el('div', null, `받는 서버(IMAP) ${cl.imap.host} — 포트 ${cl.imap.ports.join(' / ')} (${cl.imap.note})`)); t.appendChild(el('div', null, `받는 서버(POP3) ${cl.pop.host} — 포트 ${cl.pop.ports.join(' / ')}`)); t.appendChild(el('div', null, `보내는 서버(SMTP) ${cl.smtp.host} — 포트 ${cl.smtp.ports.join(' / ')} (${cl.smtp.note})`)); t.appendChild(el('div', 'muted', `사용자 이름: ${cl.username}`)); row('메일 프로그램 설정', t); } + const adm = el('div'); + if (!(m.admins || []).length) adm.appendChild(el('span', 'muted small', '사이트 관리자 계정이 없습니다 — 사이트 관리자 페이지에서 먼저 만드세요')); + else for (const a of m.admins) { const d = el('div', 'mg-admin'); d.appendChild(el('b', null, a.loginId)); d.appendChild(document.createTextNode(a.name && a.name !== a.loginId ? ` (${a.name})` : '')); d.appendChild(el('span', 'pill small ' + (a.mail ? (a.mail.state === 'active' ? 'done' : 'waiting') : 'idle'), a.mail ? `${a.mail.address}${a.mail.state && a.mail.state !== 'active' ? ' · ' + a.mail.state : ''}` : '메일 미사용')); adm.appendChild(d); } + adm.appendChild(el('div', 'muted small', '처음 접속: 사이트 관리자 계정으로 사이트 관리자 페이지에 로그인 → 회원 › 자기 계정의 "메일 사용 켜기"(비밀번호는 사이트 관리자 비밀번호 그대로) → 위 웹메일 주소에서 "아이디@도메인" 과 같은 비밀번호로 로그인.')); + row('초기 관리자', adm); + // v1.7.3 (요구 2): 메일 사용량 — 메일 서버(Arimail) 사용량 API 에서 이 사이트 메일 도메인의 메일함 용량·메일 수·이달 발송/수신과 이 사이트 계정별 메일함 크기. 비동기로 채운다 + const us = el('div', 'small mg-usage'); us.appendChild(el('span', 'muted', '불러오는 중…')); row('메일 사용량', us); loadMailUsage(site, us, false); + const bt = el('div', 'row'); const a2 = el('a', 'btn ghost small-btn', '사이트 관리자 페이지 열기'); a2.href = `/site-admin/${site.id}/`; a2.target = '_blank'; a2.rel = 'noopener'; bt.appendChild(a2); bt.appendChild(btn('운영 화면에서 관리', 'ghost small-btn', () => window.showTab('manage'))); + row('관리', bt); + c.appendChild(kv); + return c; + } + /** + * v1.7.3 (요구 2): GET /api/sites//mail-usage → 메일함 용량(도메인 전체) · 메일 수 · 계정 수(이 사이트 것 따로) · 이달/오늘 발송·수신 · 이 사이트 계정별 메일함. + * 운영자가 사용량 API 키를 아직 연결하지 않았거나 Arimail 이 낮은 버전이면 그 이유를 한 줄로 보여 준다(비밀값은 오지 않는다). + */ + async function loadMailUsage(site, box, refresh) { + const fb = (n) => (typeof fmtBytes === 'function' ? fmtBytes(Number(n) || 0) : `${Number(n) || 0} B`); const fn = (n) => (typeof fmt === 'function' ? fmt(Number(n) || 0) : String(Number(n) || 0)); + let u; try { u = await api(`/api/sites/${site.id}/mailusage${refresh ? '?refresh=1' : ''}`); } catch (e) { box.innerHTML = ''; box.appendChild(el('span', 'muted', e.message)); return; } + box.innerHTML = ''; + if (!u.ok) { const soft = ['no_usage_key', 'usage_unsupported', 'usage_api_disabled', 'no_server'].includes(u.code); box.appendChild(el('span', 'muted', soft ? `메일 서버 사용량은 운영팀이 메일 서버의 사용량 API 를 연결하면 보입니다 (${u.message})` : u.message)); return; } + const t = u.traffic || {}; const mo = t.month || {}; const dy = t.day || {}; + box.appendChild(el('div', null, `메일함 용량 ${fb(u.storage.bytes)}${u.storage.limit ? ` / ${fb(u.storage.limit)}` : ''} · 메일 ${fn(u.messages.total)}통 · 계정 ${fn(u.accounts.total)}개${u.accounts.mine ? ` (이 사이트 ${u.accounts.mine}개, ${fb(u.myBytes || 0)})` : ''}`)); + box.appendChild(el('div', null, `이달 발송 ${fn(mo.sent)}통 · 수신 ${fn(mo.received)}통 (${fb(mo.sentBytes)} / ${fb(mo.receivedBytes)}) · 오늘 ${fn(dy.sent)} / ${fn(dy.received)}`)); + if ((u.accountList || []).length) { const ul = el('ul', 'git-list'); for (const a of u.accountList) { const li = el('li'); li.appendChild(el('span', 'bname', a.mailbox || a.login)); li.appendChild(el('span', 'st', `${fb(a.bytes)}${a.limit ? ` / ${fb(a.limit)}` : ''} · ${fn(a.messages)}통 · 발송 ${fn(a.sent)} / 수신 ${fn(a.received)}`)); ul.appendChild(li); } box.appendChild(ul); } + const meta = el('div', 'muted'); meta.appendChild(document.createTextNode(`메일 서버 ${u.server ? (u.server.nickname || u.server.host) : ''} · ${new Date(u.at).toLocaleString(typeof uiLocale === 'function' ? uiLocale() : undefined)}${u.cached ? ' (캐시)' : ''} `)); + meta.appendChild(btn('새로 받기', 'ghost small-btn', () => { box.innerHTML = ''; box.appendChild(el('span', 'muted', '불러오는 중…')); loadMailUsage(site, box, true); })); box.appendChild(meta); + } + function mailItem(d) { + const sub = d.type === 'subdomain'; const ext = d.type === 'external'; const manual = ext && d.mode === 'manual'; + const c = el('section', 'card'); + const top = el('div', 'dom-head-row'); top.appendChild(el('h2', null, d.name)); top.appendChild(el('span', 'pill idle small', kindLabel(d))); top.appendChild(readyPill(d)); + const site = data.sites.find((s0) => s0.id === d.siteId); top.appendChild(el('span', 'muted small', site ? `연결된 사이트: ${site.title}` : '사이트에 연결되지 않음')); + c.appendChild(top); + if (sub) c.appendChild(el('p', 'muted small', `메일 주소 예: admin@${d.name}`)); + const waitBox = el('p', 'small dom-wait hidden'); c.appendChild(waitBox); if (d.pending) checkPropagation(d, waitBox); + const msg = el('p', 'muted small'); const detail = el('div'); + const row = el('div', 'row dom-row'); const sel = el('select'); + const o0 = el('option', null, '메일을 쓸 사이트 선택'); o0.value = ''; sel.appendChild(o0); + for (const s0 of data.sites) { const o = el('option', null, s0.title); o.value = s0.id; if (s0.id === d.siteId || (!d.siteId && typeof current !== 'undefined' && current && current.id === s0.id)) o.selected = true; sel.appendChild(o); } + row.appendChild(sel); + row.appendChild(mailSetupBtn(d, sel, msg, detail, waitBox, manual, 'primary small-btn')); + row.appendChild(btn('도메인 상태', 'ghost small-btn', async () => { detail.innerHTML = ''; detail.appendChild(el('p', 'muted small', '확인 중…')); try { const s0 = await api(`/api/domains/${d.id}/status${sel.value ? '?siteId=' + encodeURIComponent(sel.value) : ''}`); detail.innerHTML = ''; detail.appendChild(statusTable(s0)); } catch (e) { detail.innerHTML = ''; detail.appendChild(el('p', 'small', e.message)); } })); + if (manual) row.appendChild(btn('필요한 DNS 설정 (메일 포함)', 'ghost small-btn', async () => { detail.innerHTML = ''; await loadRequired(d, sel, detail, true); })); + c.appendChild(row); c.appendChild(msg); c.appendChild(detail); + return c; + } + + window.DomainsUI = { load }; +})(); diff --git a/modules/webmaker/public/flow-ui.js b/modules/webmaker/public/flow-ui.js new file mode 100644 index 0000000..4de39c4 --- /dev/null +++ b/modules/webmaker/public/flow-ui.js @@ -0,0 +1,239 @@ +'use strict'; +/* ============================================================================ + * 작업 흐름 지도 · 레일 상태 배지 · 배포 점검표 (v1.6.40, 요구 1) + * + * 웹메이커의 일은 서로 이어져 있다 — 그러나 모두 한 줄로 늘어선 것은 아니다: + * 만들기 ──▶ 수정 ──▶ 배포 ──▶ 운영 (사이트 축: 배포는 사이트 내용이 있어야 한다) + * 도메인 ──▶ DNS ──▶ (배포의 도메인 연결) · (메일) (주소 축: 도메인은 다른 일과 무관하게 먼저 준비할 수 있고, DNS 는 배포·메일이 의존한다) + * 캐시 (모든 AI 작업·도메인 구매의 비용) + * 이 모듈은 현재 사이트(app.js 의 current)와 도메인 목록(/api/domains), 캐시 잔액을 읽어 각 단계의 상태를 계산하고 + * - 레일(#mainTabs) 버튼마다 상태 점(data-state)과 짧은 배지(.nb)를 붙이고, + * - 홈 화면(#homeBody)에 단계별 상태·다음 할 일·의존 관계를 그리고, + * - 배포 화면 맨 위(#deployChecklist)에 "배포 준비 점검표" 를 그린다. + * 상태 값: done(완료) · active(진행 중) · wait(기다리는 중: 전파·배포 단계) · warn(주의: 실패·확인 필요) · todo(아직) · na(이 사이트에 해당 없음) + * 전역 의존(app.js): $, api, T, TF, krwFmt, current, credit, config, showTab, inProgress, uiLocale + * ========================================================================== */ +(() => { + const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; }; + const btn = (text, cls, onclick) => { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = onclick; return b; }; + const go = (tab) => () => window.showTab(tab); + /** 상태 → 홈 단계 머리의 짧은 배지 문구 (배지가 따로 없을 때) */ + const STATE_LABEL = { done: '완료', active: '진행 중', wait: '대기', warn: '확인 필요', todo: '아직', na: '해당 없음' }; + /** 배포 단계 이름 (wizard.js 의 PHASE 와 같은 뜻, 짧게) */ + const PHASE = { 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패', failed: '배포 실패' }; + const MODE_LABEL = { ai: 'AI 에게 맡기기', manual: '내가 고르기', template: '템플릿', copy: '사이트 복사', import: '가져옴', quick: '빠른 생성' }; + + // ---------- 도메인 목록 (60초 캐시 — 레일 배지·홈·점검표가 같이 쓴다) ---------- + let domData = null, domAt = 0, domP = null; + async function domains(force) { + if (!force && domData && Date.now() - domAt < 60000) return domData; + if (!domP) domP = api('/api/domains').then((d) => { domData = d; domAt = Date.now(); return d; }).catch(() => domData).finally(() => { domP = null; }); + return domP; + } + /** 도메인 화면에서 연결·등록이 바뀌면 호출 — 다음 그리기 때 새로 받는다 */ + function invalidateDomains() { domAt = 0; } + + // ---------- 단계 계산 ---------- + /** + * 현재 사이트와 도메인 자료로 7 단계의 상태를 만든다. + * @param {object|null} site app.js current (publicView 응답) + * @param {object|null} dom /api/domains 응답 (아직 못 받았으면 null) + * @returns {Array<{id, title, state, badge, detail, dep, optional, actions}>} + */ + function stages(site, dom) { + const out = []; + const siteDone = !!site && !inProgress(site) && site.status !== 'importing'; + // 1. 만들기 + { + let st = 'todo', badge = '', detail = '', actions = []; + if (!site) { detail = '아직 고른 사이트가 없습니다. AI 에게 맡기기 · 내가 고르기 · 템플릿 · 기존 사이트 복사 중 하나로 시작하세요.'; actions = [btn('사이트 만들기', 'primary small-btn', go('create'))]; } + // 빠른 생성(plan 없음)도 생성 명령이 도는 동안은 "생성 중" + else if (site.status === 'importing' || (site.running && (!siteDone || (site.commands || []).some((c) => c.kind === 'create' && c.status === 'running')))) { st = 'active'; badge = site.mode === 'copy' ? '복사 중' : '생성 중'; detail = '작업이 진행 중입니다. 위 진행 상태에서 확인하세요.'; actions = [btn('만들기 화면', 'ghost small-btn', go('create'))]; } + else if (inProgress(site)) { st = 'active'; badge = '생성 중'; const p = site.plan || {}; detail = `기획 단계 "${p.stage || ''}" 에서 멈춰 있습니다. 만들기 화면에서 이어서 진행하세요.`; actions = [btn('이어서 만들기', 'primary small-btn', go('create'))]; } + else if (site.importError && !site.copyInfo) { st = 'warn'; badge = '실패'; detail = site.importError; actions = [btn('만들기 화면', 'ghost small-btn', go('create'))]; } + else { st = 'done'; badge = '완성'; detail = `${T(MODE_LABEL[site.mode] || site.mode || '')} · ${new Date(site.createdAt).toLocaleDateString(uiLocale())} 에 만든 사이트입니다.`; actions = [btn('새 사이트 만들기', 'ghost small-btn', () => { if (window.resetCreate) resetCreate(); window.showTab('create'); })]; } + out.push({ id: 'create', title: '만들기', state: st, badge, detail, dep: '다른 작업과 무관하게 바로 시작할 수 있습니다.', actions }); + } + // 2. 수정 + { + let st = 'na', badge = '', detail = '사이트가 완성되면 수정 지시를 보낼 수 있습니다.', actions = []; + if (siteDone) { + const ups = (site.commands || []).filter((c) => c.kind === 'update'); const last = ups[ups.length - 1]; const lim = site.editLimit; const v = site.plan && site.plan.verification; + const running = !!site.running && (site.commands || []).some((c) => c.kind === 'update' && c.status === 'running'); // 수정 명령이 도는 중일 때만 "수정 중" (생성 중에는 아님) + st = running ? 'active' : (lim && lim.locked ? 'warn' : (ups.length ? 'done' : 'todo')); + badge = running ? '수정 중' : (lim && lim.locked ? '발행 대기' : (lim && lim.max ? `수정 ${lim.used}/${lim.max}` : (ups.length ? `수정 ${ups.length}회` : ''))); + detail = lim && lim.locked ? `수정 횟수 한도(${lim.max}회)에 도달했습니다. 배포(발행)한 뒤 다시 수정할 수 있습니다.` : (last ? `마지막 수정 ${new Date(last.finishedAt || last.startedAt).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}${v && v.stale ? ' · 수정 뒤 재검증 필요' : ''}` : '아직 수정 지시가 없습니다. 미리보기를 보고 바꿀 점을 문장으로 적어 보내세요.'); + actions = [btn(lim && lim.locked ? '배포로 이동' : '수정하기', 'primary small-btn', go(lim && lim.locked ? 'deploy' : 'edit'))]; + } + out.push({ id: 'edit', title: '수정', state: st, badge, detail, dep: '배포와 무관하게 계속할 수 있습니다. 배포 뒤의 수정은 "재배포" 로 올립니다.', actions }); + } + // 3. 도메인 (선택) + { + let st = 'todo', badge = '', detail = '', actions = [btn('도메인 준비', 'ghost small-btn', go('domains'))]; + const mine = dom ? dom.domains || [] : null; const connected = site ? (site.domains || []) : []; + if (!dom) detail = '도메인 정보를 불러오는 중…'; + else if (connected.length) { st = 'done'; badge = TF('{1}개 연결', connected.length); detail = `연결된 도메인: ${connected.join(', ')}`; actions = [btn('도메인 관리', 'ghost small-btn', go('domains'))]; } + else if (mine.length) { badge = TF('{1}개 등록', mine.length); detail = site ? `등록된 도메인(${mine.map((d) => d.name).join(', ')})이 있습니다. 이 사이트에 연결하면 그 주소로 배포됩니다.` : `등록된 도메인: ${mine.map((d) => d.name).join(', ')}`; actions = [btn(site ? '사이트에 연결' : '도메인 관리', 'primary small-btn', go('domains'))]; } + else detail = dom.accountType === 'subdomain' ? `플랫폼 도메인(${dom.platform && dom.platform.domain ? dom.platform.domain : ''}) 아래 서브 도메인을 무료로 등록할 수 있습니다.` : '도메인을 사거나(장바구니) 이미 가진 도메인을 등록하세요. 도메인 없이도 서버 주소로 배포할 수 있습니다.'; + out.push({ id: 'domains', title: '도메인', state: st, badge, detail, dep: '사이트 제작과 무관하게 먼저 준비해 둘 수 있습니다. 도메인만 사 두고 사이트는 나중에 연결해도 됩니다.', optional: true, actions }); + } + // 4. DNS + { + let st = 'na', badge = '', detail = '사이트에 도메인을 연결하면 그 도메인의 DNS 준비 상태가 여기에 보입니다. 서브 도메인·여기서 구매한 도메인은 자동으로 준비됩니다.', actions = [btn('DNS 보기', 'ghost small-btn', go('dns'))]; + const connected = site ? (site.domains || []) : []; const mine = dom ? dom.domains || [] : []; + if (connected.length && dom) { + // v1.7.2: 하위 이름(shop.example.com)으로 연결한 도메인도 찾는다 — siteNames 또는 '.도메인' 으로 끝나는 이름 + const findDom = (n) => mine.find((d) => d.name === n || (d.siteNames || []).includes(n) || n.endsWith('.' + d.name)); + const recs = [...new Set(connected.map(findDom).filter(Boolean))]; + const waiting = recs.filter((d) => d.dnsLocked || d.pending || (d.transfer && d.transfer.step && d.transfer.step !== 'done')); + const manualUnverified = recs.filter((d) => d.type === 'external' && d.mode === 'manual' && !(d.verified && d.verified.siteOk)); + const unknown = connected.filter((n) => !findDom(n)); + if (waiting.length) { st = 'wait'; badge = '전파 대기'; detail = `${waiting.map((d) => d.name).join(', ')} — 네임서버 변경·DNS 전파를 확인하는 중입니다 (최대 48시간).`; } + else if (manualUnverified.length) { st = 'warn'; badge = '확인 필요'; detail = `${manualUnverified.map((d) => d.name).join(', ')} — 직접 관리하는 DNS 에 필요한 레코드를 넣고 "설정 완료 — 확인" 을 눌러야 배포가 됩니다.`; actions = [btn('필요한 설정 확인', 'primary small-btn', go('dns'))]; } + else if (unknown.length && !recs.length) { st = 'todo'; badge = '직접 입력'; detail = `${unknown.join(', ')} — 배포 카드에 직접 적은 도메인입니다. 그 도메인의 A 레코드가 배포 서버를 가리켜야 합니다.`; } + else { st = 'done'; badge = '준비됨'; detail = `${recs.map((d) => d.name).join(', ')} 의 DNS 가 준비되어 배포·메일에 쓸 수 있습니다.`; } + } else if (!site && mine.length) { st = mine.some((d) => d.dnsLocked || d.pending) ? 'wait' : 'done'; badge = st === 'wait' ? '전파 대기' : TF('{1}개', mine.length); detail = '등록된 도메인의 DNS 레코드·전파 상태를 봅니다.'; } + out.push({ id: 'dns', title: 'DNS', state: st, badge, detail, dep: '웹 제작과는 무관하지만 배포(웹서버 연결)와 메일(MX)이 이 설정에 달려 있습니다.', actions }); + } + // 5. 메일 (선택) + { + let st = 'todo', badge = '', detail = '', actions = [btn('메일 설정', 'ghost small-btn', go('mail'))]; + const m = site && site.mail; + if (!site) { st = 'na'; detail = '사이트를 고르면 그 사이트의 메일 시스템 상태가 보입니다. 도메인별 메일(MX) 자동 설정은 메일 화면에서 합니다.'; } + else if (!m || m.level === 'off') { badge = '꺼짐'; detail = '도메인에 메일(MX·SPF·DMARC)을 자동 설정하면 그 도메인으로 메일을 받을 준비가 되고, 운영팀이 사이트 메일 시스템을 켜면 사이트 관리자 페이지에서 메일 계정을 쓸 수 있습니다.'; } + else if (m.level === 'setting-up') { st = 'wait'; badge = m.label || '설정 중'; detail = `${m.domain} — 도메인 연결(DNS) 확인을 기다리는 중입니다.`; } + else { st = 'done'; badge = m.label || '사용 가능'; detail = `${m.domain} — 아이디@${m.domain} 로 메일을 쓸 수 있습니다.`; if (m.webmailUrl) { const a = el('a', 'btn ghost small-btn', '웹메일 열기'); a.href = m.webmailUrl; a.target = '_blank'; a.rel = 'noopener'; actions = [a, btn('메일 화면', 'ghost small-btn', go('mail'))]; } } + out.push({ id: 'mail', title: '메일', state: st, badge, detail, dep: 'DNS 가 준비된 도메인에서 동작합니다. 사이트 제작·배포와는 무관합니다.', optional: true, actions }); + } + // 6. 배포 + { + let st = 'na', badge = '', detail = '사이트가 완성되면 운영 주소로 올릴 수 있습니다.', actions = []; + if (site) { + const ds = site.deployState || {}; const stg = site.plan && site.plan.stage; + const changed = !!(ds.lastChangeAt && ds.lastDeployAt && ds.lastChangeAt > ds.lastDeployAt); + if (!siteDone) { st = 'na'; detail = '만들기가 끝나야 배포할 수 있습니다.'; } + else if (ds.state === 'empty') { st = 'todo'; badge = '내용 없음'; detail = '배포할 페이지가 아직 없습니다.'; } + else if (ds.state === 'working') { st = 'active'; badge = '작업 중'; detail = 'AI 작업이 끝나면 배포할 수 있습니다.'; } + else if (ds.state === 'deploying') { st = 'active'; badge = '배포 중'; detail = '운영 서버로 전송하고 있습니다.'; } + else if (site.unpublishedAt) { st = 'warn'; badge = '발행 취소됨'; detail = `운영 주소에는 "사이트 수정 중" 안내만 보입니다. 수정을 마친 뒤 다시 발행하세요.`; } + else if (ds.phase && PHASE[ds.phase] && ds.phase !== 'failed') { st = /failed/.test(ds.phase) ? 'warn' : 'wait'; badge = PHASE[ds.phase]; detail = ds.phaseDetail || PHASE[ds.phase]; } + else if (ds.phase === 'failed' || ds.state === 'failed') { st = 'warn'; badge = '배포 실패'; detail = ds.phaseDetail || '배포 서버 전송에 실패했습니다. 메시지창의 원인을 확인하세요.'; } + else if (stg === 'published' && ds.lastDeployAt && !(ds.state === 'pending' && changed)) { st = 'done'; badge = '발행됨'; detail = site.liveUrl ? `운영 주소 ${site.liveUrl}` : '배포되었습니다.'; } + else if (ds.lastDeployAt) { st = 'wait'; badge = '변경 있음'; detail = '마지막 배포 뒤 바뀐 내용이 있습니다. 재배포하면 운영 주소에 반영됩니다.'; } + else { st = 'todo'; badge = '발행 전'; detail = (site.domains || []).length ? `연결 도메인 ${site.domains.join(', ')} 으로 발행합니다.` : '도메인을 연결하지 않으면 서버 주소로 발행됩니다.'; } + actions = siteDone ? [btn(st === 'done' ? '배포 보기' : (st === 'wait' && badge === '변경 있음' ? '재배포' : '배포하기'), st === 'done' ? 'ghost small-btn' : 'primary small-btn', go('deploy'))] : []; + } + out.push({ id: 'deploy', title: '배포', state: st, badge, detail, dep: '사이트 내용이 있어야 하고, 도메인을 연결했다면 그 DNS 가 준비되어야 합니다. 한 번 배포된 뒤의 수정·운영은 도메인·DNS 와 직접 묶이지 않습니다.', actions }); + } + // 7. 운영 + { + let st = 'na', badge = '', detail = '배포한 사이트의 기능 모듈·사이트 관리자 페이지·문의·접속 통계를 여기서 다룹니다.', actions = []; + if (siteDone) { const live = !!site.liveUrl && site.plan && site.plan.stage === 'published'; st = live ? 'active' : 'todo'; badge = live ? '운영 중' : ''; detail = live ? '문의 접수·접속 통계·기능 모듈·사이트 관리자 페이지를 봅니다.' : '배포 전에도 기능 모듈을 켜고 사이트 관리자 페이지를 준비할 수 있습니다.'; actions = [btn('운영 보기', 'ghost small-btn', go('manage'))]; } + out.push({ id: 'manage', title: '운영', state: st, badge, detail, dep: '완성된 사이트의 기능·상태 관리는 "수정" 과 이어집니다.', actions }); + } + return out; + } + + // ---------- 레일 배지 ---------- + /** 레일 버튼에 상태 점·배지를 붙인다. 캐시 버튼은 잔액(부족하면 빨강) */ + function navBadges(list) { + const byId = Object.fromEntries(list.map((s) => [s.id, s])); + document.querySelectorAll('#mainTabs button').forEach((b) => { + const s = byId[b.dataset.tab]; const nb = b.querySelector('.nb'); if (!nb) return; + if (s) { if (s.state === 'na') { b.removeAttribute('data-state'); nb.textContent = ''; } else { b.dataset.state = s.state; nb.textContent = T(s.badge || ''); } } + else if (b.dataset.tab === 'credits') { const c = typeof credit !== 'undefined' && credit; const isAdmin = config && config.user && config.user.role === 'admin'; if (c && c.balance != null) { const low = c.balance <= 0 && !isAdmin; b.dataset.state = low ? 'warn' : 'done'; nb.textContent = low ? T('부족') : krwFmt(c.balance); } } + }); + } + + // ---------- 홈 ---------- + /** 단계 한 줄 (아이콘 번호 · 제목 · 배지 · 설명 · 의존 · 실행 버튼) */ + function stepRow(s, no) { + const row = el('div', 'flow-step' + (s.optional ? ' optional' : '')); row.dataset.state = s.state; + row.appendChild(el('div', 'fs-icon', s.state === 'done' ? '✓' : String(no))); + const mid = el('div'); + const t = el('div', 'fs-title'); t.appendChild(el('span', null, T(s.title))); + const pill = el('span', 'pill small ' + ({ done: 'done', active: 'running', wait: 'waiting', warn: 'error', todo: 'idle', na: 'idle' }[s.state] || 'idle'), T(s.badge || STATE_LABEL[s.state])); + t.appendChild(pill); if (s.optional) t.appendChild(el('span', 'muted small', '(선택)')); mid.appendChild(t); + if (s.detail) mid.appendChild(el('div', 'fs-detail', s.detail)); + if (s.dep) mid.appendChild(el('div', 'fs-dep', s.dep)); + row.appendChild(mid); + const act = el('div', 'fs-act'); for (const a of s.actions || []) act.appendChild(a); row.appendChild(act); + return row; + } + /** 의존 관계 지도 (글자로): 사이트 축 · 주소 축 · 캐시 */ + function flowMap() { + const c = el('div', 'flow-map'); + const line = (nodes, note) => { const r = el('div', 'fm-row'); nodes.forEach((n, i) => { if (i) r.appendChild(el('span', 'fm-arrow', '→')); r.appendChild(el('span', 'fm-node' + (n.dim ? ' dim' : ''), n.t)); }); c.appendChild(r); if (note) c.appendChild(el('div', 'fm-note', note)); }; + line([{ t: '만들기' }, { t: '수정' }, { t: '배포' }, { t: '운영' }], '사이트 축 — 배포는 사이트 내용이 있어야 하는 마지막 작업이고, 배포 뒤의 수정·운영은 도메인·DNS 와 직접 묶이지 않습니다.'); + line([{ t: '도메인', dim: true }, { t: 'DNS' }, { t: '배포의 도메인 연결 · 메일', dim: true }], '주소 축 — 도메인 구매·등록은 다른 일과 무관하게 먼저 할 수 있고, DNS 는 배포(웹서버 연결)와 메일(MX)이 의존합니다.'); + line([{ t: '캐시' }, { t: 'AI 작업 · 도메인 구매 비용', dim: true }], '비용 — AI 생성·수정·검증과 도메인 구매·갱신은 선불 캐시에서 차감됩니다.'); + return c; + } + async function renderHome() { + const box = $('homeBody'); if (!box) return; + const site = typeof current !== 'undefined' ? current : null; + const dom = await domains(); + if (!$('homeBody') || $('pane-home').classList.contains('hidden')) { navBadges(stages(site, dom)); return; } // 그 사이 다른 화면으로 옮겼으면 배지만 + const list = stages(site, dom); navBadges(list); + box.innerHTML = ''; const wrap = el('div', 'flow'); + const hero = el('div', 'flow-hero'); + if (!site) { + hero.appendChild(el('h3', null, '무엇부터 할까요?')); + hero.appendChild(el('p', null, '사이트 만들기, 도메인 준비, 캐시 충전은 서로 무관하게 시작할 수 있습니다. 만들어 둔 사이트가 있으면 왼쪽 레일에서 고르세요 — 그 사이트의 진행 상태가 여기에 보입니다.')); + wrap.appendChild(hero); + const entry = el('div', 'flow-entry'); + const ent = (t, d, tab, primary) => { const b = el('button', primary ? 'primary' : ''); b.type = 'button'; b.appendChild(el('span', 'fe-t', t)); b.appendChild(el('span', 'fe-d', d)); b.onclick = go(tab); entry.appendChild(b); }; + ent('사이트 만들기', 'AI 에게 맡기기 · 내가 고르기 · 템플릿 · 기존 사이트 복사', 'create'); + ent('도메인 준비', dom && dom.accountType === 'subdomain' ? '서브 도메인 무료 등록' : '검색·구매(장바구니) 또는 이미 가진 도메인 등록', 'domains'); + ent('캐시 충전', '선불 충전 상품을 골라 충전 신청', 'credits'); + wrap.appendChild(entry); + } else { + const pill = $('siteStatePill'); hero.appendChild(el('h3', null, site.title)); + const next = list.find((s) => !s.optional && ['todo', 'active', 'wait', 'warn'].includes(s.state)); + hero.appendChild(el('p', null, (pill && pill.textContent ? pill.textContent + ' · ' : '') + (site.liveUrl ? TF('운영 주소: {1}', site.liveUrl) : T('아직 배포되지 않았습니다 (미리보기 주소만 사용 가능)')))); + if (next) { const r = el('div', 'row'); r.appendChild(el('span', 'small', TF('다음 할 일: {1}', T(next.title) + (next.badge ? ` (${T(next.badge)})` : '')))); if (next.actions && next.actions[0]) { const a = next.actions[0].cloneNode(true); a.onclick = next.actions[0].onclick; r.appendChild(a); } hero.appendChild(r); } + wrap.appendChild(hero); + } + const steps = el('div', 'flow-steps'); list.forEach((s, i) => steps.appendChild(stepRow(s, i + 1))); wrap.appendChild(steps); + wrap.appendChild(flowMap()); + box.appendChild(wrap); + } + + // ---------- 배포 점검표 ---------- + async function renderDeployChecklist() { + const box = $('deployChecklist'); if (!box) return; + const site = typeof current !== 'undefined' ? current : null; const dom = await domains(); + if (!$('deployChecklist') || $('pane-deploy').classList.contains('hidden')) return; + box.innerHTML = ''; + const card = el('section', 'card'); card.appendChild(el('h2', null, '배포 준비 점검')); + if (!site) { card.appendChild(el('p', 'muted small', '왼쪽 레일에서 사이트를 고르면 배포에 필요한 항목을 점검하고 발행할 수 있습니다. 배포는 사이트 내용이 있어야 하고, 도메인을 연결했다면 그 DNS 가 준비되어야 합니다.')); box.appendChild(card); return; } + const list = stages(site, dom); const by = Object.fromEntries(list.map((s) => [s.id, s])); + const ul = el('ul', 'chk-list'); + const item = (state, label, detail, action) => { const li = el('li'); li.dataset.state = state; li.appendChild(el('span', 'ci', state === 'done' ? '✓' : state === 'warn' ? '!' : state === 'wait' ? '…' : '·')); const m = el('div'); m.appendChild(el('span', null, T(label))); if (detail) m.appendChild(el('span', 'cd', detail)); li.appendChild(m); li.appendChild(action || el('span')); ul.appendChild(li); }; + const c = by.create; item(c.state === 'done' ? 'done' : (c.state === 'active' ? 'wait' : 'warn'), '사이트 내용', c.state === 'done' ? '완성된 사이트입니다' : c.detail, c.state === 'done' ? null : btn('만들기', 'ghost small-btn', go('create'))); + const v = site.plan && site.plan.verification; const s = v && v.rules && v.rules.summary; const issues = s ? (s.pagesMissing + s.extraneous + s.regulated) : 0; + item(!v ? 'todo' : (v.stale ? 'wait' : (issues ? 'warn' : 'done')), '검증', !v ? '아직 검증하지 않았습니다 (발행 때 자동 점검)' : v.stale ? '수정 뒤 재검증이 필요합니다' : issues ? TF('점검 항목 {1}건 — 그대로 발행할 수도 있습니다', issues) : '이상 없음', null); + const d = by.domains; item(d.state === 'done' ? 'done' : 'todo', '도메인 연결 (선택)', d.state === 'done' ? d.detail : '연결하지 않으면 서버 주소로 발행됩니다', d.state === 'done' ? null : btn('도메인', 'ghost small-btn', go('domains'))); + const n = by.dns; if (n.state !== 'na') item(n.state, 'DNS 준비', n.detail, n.state === 'done' ? null : btn('DNS', 'ghost small-btn', go('dns'))); + const isAdmin = config && config.user && config.user.role === 'admin'; const bal = site.credit ? site.credit.balance : (typeof credit !== 'undefined' && credit ? credit.balance : null); + if (bal != null) item(bal > 0 || isAdmin ? 'done' : 'warn', '캐시', bal > 0 || isAdmin ? TF('잔액 {1}', krwFmt(bal)) : '캐시가 없습니다 — 발행은 가능하지만 AI 수정은 할 수 없습니다', bal > 0 || isAdmin ? null : btn('충전', 'ghost small-btn', go('credits'))); + card.appendChild(ul); + const dep = by.deploy; card.appendChild(el('p', 'muted small', dep.badge ? `${T('배포 상태')}: ${T(dep.badge)} — ${dep.detail}` : dep.detail)); + box.appendChild(card); + } + + // ---------- app.js 와의 연결점 ---------- + let timer = null; + /** 사이트가 열리거나 바뀌면(작업 완료 포함) 배지와 보이는 화면을 다시 그린다. 연달아 오는 이벤트는 150ms 로 모은다 */ + function onSite() { clearTimeout(timer); timer = setTimeout(refresh, 150); } + async function refresh() { + const site = typeof current !== 'undefined' ? current : null; const dom = await domains(); + navBadges(stages(site, dom)); + if (!$('pane-home').classList.contains('hidden')) renderHome(); + if (!$('pane-deploy').classList.contains('hidden')) renderDeployChecklist(); + } + function onTab(name) { if (name === 'home') renderHome(); else if (name === 'deploy') renderDeployChecklist(); else navBadges(stages(typeof current !== 'undefined' ? current : null, domData)); } + function onCredit() { navBadges(stages(typeof current !== 'undefined' ? current : null, domData)); } + + window.FlowUI = { renderHome, renderDeployChecklist, onSite, onTab, onCredit, invalidateDomains: () => { invalidateDomains(); onSite(); }, stages }; +})(); diff --git a/modules/webmaker/public/i18n.js b/modules/webmaker/public/i18n.js new file mode 100644 index 0000000..926c041 --- /dev/null +++ b/modules/webmaker/public/i18n.js @@ -0,0 +1,147 @@ +/* ============================================================================ + * 다국어 런타임 (v1.1.2) + * 서버가 준 "원문(한국어) → 번역" 사전으로 화면 텍스트를 치환한다. 코드에 번역 키가 없으므로 + * 텍스트 노드와 placeholder/title/data-tip 속성의 값이 사전에 있으면 그대로 바꾼다. + * 동적으로 추가·변경되는 내용은 MutationObserver 로 따라가며 치환한다. + * 언어 선택은 localStorage('wm_lang') 에 저장. 'ko' 는 원문이므로 치환하지 않는다. + * v1.3.2: 변수가 들어가는 문장(코드의 템플릿 문자열)은 카탈로그에 "현재 사이트: {1} · 화면: {2}" 같은 패턴으로 들어 있다. + * 사전에 정확히 같은 문장이 없으면 패턴과 맞춰 보고, 맞으면 번역문의 {1}·{2} 자리에 화면의 실제 값을 넣는다 + * (값 자체가 사전에 있으면 그것도 번역 — 예: "관리 탭" → "Manage tab"). + * v1.3.10 속도: ① 서버가 정적 HTML 을 미리 번역해 보낸다(쿠키 wm_lang) ② 사전을 localStorage 에 언어별로 캐시하고 + * 서버의 stamp(번역 기준 버전|올린 시각)가 같으면 다시 받지 않는다 ③ 패턴은 첫 글자별로 나눠 둔다. + * 언어를 바꾸면 쿠키를 바꾸고 페이지를 다시 연다(서버 번역본과 맞추기 위해). 오른쪽→왼쪽 언어는 dir=rtl. + * 전역: window.I18N = { lang, t(text), setLang(lang), languages } + * ========================================================================== */ +(function () { + var ATTRS = ['placeholder', 'title', 'data-tip', 'aria-label', 'alt']; + var dict = null; var lang = 'ko'; var languages = [{ lang: 'ko', name: '한국어' }]; + try { lang = localStorage.getItem('wm_lang') || (navigator.language || 'ko').slice(0, 2); } catch (e) {} + // v1.3.16: 서버가 정한 화면 언어(관리자 강제 기본값 · 사용자 선택 · 사이트 관리 언어)를 먼저 따른다 — + var srv = document.querySelector('meta[name="wm-ui-lang"]'); if (srv && srv.content) { lang = srv.content; try { localStorage.setItem('wm_lang', lang); } catch (e) {} } + var qs = new URLSearchParams(location.search); if (qs.get('lang')) { lang = qs.get('lang'); try { localStorage.setItem('wm_lang', lang); } catch (e) {} } + /** 서버 미리 번역용 쿠키 (1년). 화면 언어와 다르면 맞춘다 */ + /** 화면 코드가 사전 준비 후 다시 그리도록 알림 (조각 번역 T() 를 쓴 목록 등) */ + function ready() { try { window.dispatchEvent(new CustomEvent('wm:i18n')); } catch (e) {} } + function setCookie(l) { try { document.cookie = 'wm_lang=' + encodeURIComponent(l) + '; Path=/; Max-Age=31536000; SameSite=Lax'; } catch (e) {} } + if (!new RegExp('(?:^|; )wm_lang=' + lang.replace(/[^\w-]/g, '') + '(?:;|$)').test(document.cookie || '')) setCookie(lang); + var orig = new WeakMap(); // 노드 → 원문 (언어를 바꿀 때 원문으로 되돌리기 위해) + var patterns = []; // [{ prefix, suffix, re, tr }] — 사전 키 중 {숫자} 자리표시가 있는 것 + var buckets = {}; // v1.3.10: 고정 앞글자의 첫 글자 → 패턴 목록 (앞글자가 없는 패턴은 buckets['']) + var HANGUL = /[\uac00-\ud7a3]/; + /** 사전에서 패턴 항목만 골라 정규식으로 바꾼다. 앞뒤 고정 글자(prefix/suffix)로 먼저 걸러 비용을 줄인다 */ + function buildPatterns() { + patterns = []; if (!dict) return; + Object.keys(dict).forEach(function (k) { + if (!/\{\d+\}/.test(k)) return; + var pieces = k.split(/\{(\d+)\}/); var src = '^'; var order = []; + for (var i = 0; i < pieces.length; i++) { if (i % 2 === 0) src += pieces[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); else { src += '([\\s\\S]+?)'; order.push(pieces[i]); } } + var fixedText = k.replace(/\{\d+\}/g, ''); var hc = (fixedText.match(/[\uac00-\ud7a3]/g) || []).length; + // weak: 고정 한글이 2자 이하인 패턴("{1}개" 등)은 아무 문장에나 맞을 수 있어, 값에 한글이 없을 때만 쓴다 (v1.3.10) + patterns.push({ prefix: pieces[0], suffix: pieces[pieces.length - 1], re: new RegExp(src + '$'), order: order, tr: dict[k], fixed: fixedText.length, weak: hc < 3 }); + }); + patterns.sort(function (a, b) { return b.fixed - a.fixed; }); // 고정 글자가 많은(더 구체적인) 패턴 먼저 + buckets = {}; patterns.forEach(function (p) { var c = p.prefix.charAt(0); (buckets[c] = buckets[c] || []).push(p); }); + } + /** 문장 하나 번역: 정확히 같은 문장 → 패턴 순서로 찾고, 없으면 null */ + function lookup(k, depth) { + depth = depth || 0; + var v = dict && dict[k]; if (v) return v; + if (!patterns.length || !HANGUL.test(k)) return null; + var cand = (buckets[k.charAt(0)] || []).concat(buckets[''] || []); + for (var i = 0; i < cand.length; i++) { + var p = cand[i]; if (k.length < p.prefix.length + p.suffix.length || k.indexOf(p.prefix) !== 0 || k.slice(k.length - p.suffix.length) !== p.suffix) continue; + var m = p.re.exec(k); if (!m) continue; + if (p.weak) { var bad = false; for (var w = 1; w < m.length; w++) if (HANGUL.test(m[w])) bad = true; if (bad) continue; } + var vals = {}; for (var j = 0; j < p.order.length; j++) { var raw = m[j + 1]; var tv = depth < 2 && HANGUL.test(raw) ? lookup(raw.trim(), depth + 1) : null; vals[p.order[j]] = tv ? raw.replace(raw.trim(), tv) : raw; } // 값 안의 문장도 번역(중첩 템플릿) — 2단계까지 + return p.tr.replace(/\{(\d+)\}/g, function (all, n) { return vals[n] !== undefined ? vals[n] : all; }); + } + return null; + } + function t(text) { if (!dict || !text) return text; var k = String(text).trim(); var v = lookup(k); if (!v) return text; return text.replace(k, v); } + function translateNode(n) { + if (n.nodeType === 3) { var p = n.parentNode; if (!p || p.nodeName === 'SCRIPT' || p.nodeName === 'STYLE' || p.nodeName === 'TEXTAREA') return; var src = orig.has(n) ? orig.get(n) : n.nodeValue; var k = src.trim(); if (!k) return; if (!orig.has(n)) orig.set(n, src); var v = dict && lookup(k); var next = v ? src.replace(k, v) : src; if (n.nodeValue !== next) n.nodeValue = next; return; } + if (n.nodeType !== 1) return; + for (var i = 0; i < ATTRS.length; i++) { var a = ATTRS[i]; if (!n.hasAttribute(a)) continue; var key = 'wm-orig-' + a; var o = n.getAttribute('data-' + key) || n.getAttribute(a); if (!n.hasAttribute('data-' + key)) n.setAttribute('data-' + key, o); var tv = dict && lookup(o.trim()); var val = tv ? o.replace(o.trim(), tv) : o; if (n.getAttribute(a) !== val) n.setAttribute(a, val); } + if (n.nodeName === 'OPTION' && n.firstChild && n.firstChild.nodeType === 3) translateNode(n.firstChild); + } + function translateTree(root) { if (!root) return; if (root.nodeType === 3) return translateNode(root); if (root.nodeType !== 1 && root.nodeType !== 9) return; if (root.nodeType === 1) translateNode(root); var walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); var n; while ((n = walker.nextNode())) translateNode(n); if (document.title) document.title = t(document.title); } + var observer = null; var pending = false; + /** + * 화면 변화 따라가기. v1.3.10 수정: 1.3.9 까지는 한 프레임 안에 처음 들어온 변화 묶음만 처리하고 그 사이에 들어온 + * 다른 묶음을 버려(pending 동안 return), 동적으로 그린 문구(카드·표·상태 배지 등)가 한국어로 남았다. 또 처리 중 오류가 나면 + * 관찰이 다시 연결되지 않았다. 이제 프레임까지 들어온 변화를 모두 모아 처리하고, 오류가 나도 항상 다시 연결한다. + */ + var queue = []; + function flush() { + pending = false; var muts = queue; queue = []; observer.disconnect(); + try { + for (var i = 0; i < muts.length; i++) { + var m = muts[i]; + try { + if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); } + else if (m.type === 'attributes') { if (m.target.removeAttribute) m.target.removeAttribute('data-wm-orig-' + m.attributeName); translateNode(m.target); } + else { for (var j = 0; j < m.addedNodes.length; j++) translateTree(m.addedNodes[j]); } + } catch (e) { /* 한 노드의 오류가 나머지를 막지 않게 */ } + } + } finally { observe(); } + } + function startObserver() { + if (observer) return; + observer = new MutationObserver(function (muts) { + for (var i = 0; i < muts.length; i++) queue.push(muts[i]); + if (pending) return; pending = true; + // 화면이 숨겨진 탭에서는 requestAnimationFrame 이 멈추므로 setTimeout 으로도 한 번 보장 + var done = false; var go = function () { if (done) return; done = true; flush(); }; + requestAnimationFrame(go); setTimeout(go, 50); + }); + observe(); + } + function observe() { observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS }); } + function apply(d) { + // 사전을 바꿀 때는 원문으로 되돌린 뒤 다시 치환 (orig 에 저장된 원문 사용) + if (observer) observer.disconnect(); + dict = d; buildPatterns(); translateTree(document.documentElement); document.documentElement.lang = lang; + var info = languages.filter(function (l) { return l.lang === lang; })[0]; document.documentElement.dir = info && info.dir === 'rtl' ? 'rtl' : 'ltr'; + if (observer) observe(); else if (dict) startObserver(); + } + /** 언어별 사전 캐시 (localStorage). stamp 가 서버와 같으면 그대로 쓴다 */ + function cached(l, stamp) { try { var c = JSON.parse(localStorage.getItem('wm_dict_' + l) || 'null'); return c && c.stamp === stamp ? c.dict : null; } catch (e) { return null; } } + function store(l, stamp, d) { try { localStorage.setItem('wm_dict_' + l, JSON.stringify({ stamp: stamp, dict: d })); } catch (e) {} } + function load(cb) { + fetch('/api/i18n/languages').then(function (r) { return r.json(); }).then(function (j) { + languages = j.languages || languages; var info = languages.filter(function (l) { return l.lang === lang; })[0]; + if (!info) { lang = 'ko'; setCookie('ko'); } + if (lang === 'ko') { apply(null); return cb && cb(); } + var hit = cached(lang, info.stamp); if (hit) { if (hit !== dict) apply(hit); ready(); return cb && cb(); } + return fetch('/api/i18n/' + encodeURIComponent(lang) + '.json').then(function (r) { return r.ok ? r.json() : null; }).then(function (dd) { if (dd && dd.dict) { store(lang, info.stamp, dd.dict); apply(dd.dict); ready(); } else { lang = 'ko'; apply(null); } cb && cb(); }); + }).catch(function () { apply(null); cb && cb(); }); + } + /** 언어 바꾸기: 저장 → 쿠키 → 페이지 다시 열기 (서버가 그 언어로 미리 번역한 화면을 받는다) */ + function setLang(l) { + lang = l; try { localStorage.setItem('wm_lang', l); } catch (e) {} setCookie(l); + // v1.3.16: 로그인 사용자는 선택을 계정에 저장한다(관리자가 기본 언어를 강제한 뒤에도 사용자가 다시 고르면 그 선택이 우선). 비로그인이면 실패해도 쿠키로 충분 + var done = function () { location.reload(); }; + try { fetch('/api/me/ui-lang', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lang: l }), credentials: 'same-origin' }).then(done, done); } catch (e) { done(); } + } + /** 헤더에 언어 선택 상자(#langSelect 또는 .lang-switch 컨테이너)가 있으면 채운다. 언어가 하나뿐이면 숨긴다 */ + function renderSwitcher() { + var sel = document.getElementById('langSelect'); if (!sel) return; + sel.innerHTML = ''; languages.forEach(function (l) { var o = document.createElement('option'); o.value = l.lang; o.textContent = l.name; sel.appendChild(o); }); sel.value = lang; + var wrap = sel.closest('.lang-switch') || sel; wrap.style.display = languages.length > 1 ? '' : 'none'; + sel.onchange = function () { setLang(sel.value); }; + } + /** + * 화면 코드용 도우미 (v1.3.10) + * T(문구) : 사용자 데이터(사이트 이름 등)와 섞어 문장을 만들 때, 고정 문구 조각을 먼저 번역한다. + * uiLocale() : 날짜·숫자 표기용 로케일 (ko→ko-KR, ja→ja-JP …). 1.3.9 까지는 'ko-KR' 이 하드코딩되어 다른 언어에서도 한국식 날짜였다. + */ + var LOCALE_OF = { ko: 'ko-KR', en: 'en-US', ja: 'ja-JP', zh: 'zh-CN', 'zh-CN': 'zh-CN', 'zh-TW': 'zh-TW', mn: 'mn-MN', fr: 'fr-FR', de: 'de-DE', es: 'es-ES', vi: 'vi-VN', th: 'th-TH', id: 'id-ID', ru: 'ru-RU', ar: 'ar', tr: 'tr-TR' }; + window.T = function (s) { return t(s); }; + /** TF('{1}원', '1,200') → 번역 사전의 '{1}원' 번역(예: '₩{1}')에 값을 넣는다. 사전에 없으면 원문 틀에 넣는다 */ + window.TF = function (key) { var args = arguments; var tpl = (dict && dict[key]) || key; return tpl.replace(/\{(\d+)\}/g, function (a, n) { var v = args[Number(n)]; return v === undefined ? a : String(v); }); }; + window.uiLocale = function () { return LOCALE_OF[lang] || lang || 'ko-KR'; }; + window.I18N = { get lang() { return lang; }, t: t, setLang: setLang, get languages() { return languages; }, refresh: function () { translateTree(document.documentElement); } }; + // v1.3.10: 캐시된 사전이 있으면 즉시 쓴다(화면 코드가 처음 그릴 때부터 T() 가 번역). 서버와 다른지는 load() 가 확인해 바꾼다 + if (lang !== 'ko') { try { var c0 = JSON.parse(localStorage.getItem('wm_dict_' + lang) || 'null'); if (c0 && c0.dict) { dict = c0.dict; buildPatterns(); } } catch (e) {} } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { if (dict) apply(dict); load(renderSwitcher); }); else { if (dict) apply(dict); load(renderSwitcher); } +})(); diff --git a/modules/webmaker/public/import.js b/modules/webmaker/public/import.js new file mode 100644 index 0000000..aee18ad --- /dev/null +++ b/modules/webmaker/public/import.js @@ -0,0 +1,74 @@ +'use strict'; +// 기존 사이트로 시작하기 +// - 사이트 복사하기(v1.2.0~): 웹 주소만 입력 → 서버가 웹으로 접속해 화면(HTML·CSS·JS·이미지)만 복사해 새 사이트로 만든다. +// 기능(로그인·게시판·신청·결제 등 서버 프로그램)은 복사하지 않는다. 기존 사이트의 모양을 최대한 살려 새 사이트를 만들 때 쓴다. +// - 사이트 관리하기(서버 접속으로 가져오기)는 v1.3.4 에서 새로 시작하는 기능을 없앴다(사용자 요청). +// 이미 가져온 사이트는 관리 탭의 "원본 서버 반영" 카드(onSiteOpened)로 계속 수정·반영할 수 있다. +(() => { + // v1.3.4: "사이트 관리하기(서버 접속으로 가져오기)" 새로 시작하기는 없앴다. 이전에 가져온 사이트의 반영 카드(아래 onSiteOpened)는 그대로 동작한다. + const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }; + // ---------- 사이트 복사하기 (v1.2.0): 웹 주소만으로 화면 코드(HTML·CSS·JS·이미지)를 복사 ---------- + function showCopyStart() { $('copyStartCard').classList.remove('hidden'); $('cpStart').disabled = false; setTimeout(() => $('cpUrl').focus(), 0); } + $('cpStart').onclick = async () => { + let url = $('cpUrl').value.trim(); if (!url) return alert('복사할 사이트 주소를 입력하세요.'); + if (!/^https?:\/\//i.test(url)) url = 'https://' + url; // "example.com" 처럼 입력해도 https 로 시도 + $('cpStart').disabled = true; + try { + const site = await api('/api/sites/copy', { method: 'POST', body: JSON.stringify({ url, title: $('cpTitle').value.trim(), contactEmail: $('cpEmail').value.trim(), tier: $('tierSelect').value || '', optimize: seoOptsOf('copyStartCard') }) }); + $('cpUrl').value = ''; $('cpTitle').value = ''; $('cpEmail').value = ''; + await openSite(site.id); await refreshSiteList(); + } catch (e) { alert('복사 시작 실패: ' + e.message); $('cpStart').disabled = false; } + }; + + // ---------- 가져온 사이트: 반영(배포) 카드 ---------- + function onSiteOpened(site) { + const card = $('importCard'); + if (!site || site.mode !== 'import' || !site.source) { card.classList.add('hidden'); return; } + card.classList.remove('hidden'); $('imCredsBox').classList.add('hidden'); + const s = site.source; const last = site.deploy && site.deploy.last; + const importing = site.status === 'importing' || (site.running && !s.importedAt); + const pill = $('imState'); + if (importing) { pill.textContent = '가져오는 중'; pill.className = 'pill running'; } + else if (!s.importedAt) { pill.textContent = site.importError ? '가져오기 실패' : '가져오기 전'; pill.className = 'pill ' + (site.importError ? 'error' : 'idle'); } + else if (last && last.ok && s.lastPushAt && (!site.versions || site.versions.length <= 1)) { pill.textContent = '반영 완료'; pill.className = 'pill done'; } + else { pill.textContent = '반영 대기'; pill.className = 'pill idle'; } + const info = $('imInfo'); info.innerHTML = ''; + const lines = [ + `원본: ${s.url ? s.url + ' · ' : ''}${s.type === 'ssh' ? 'SSH 키' : (s.protocol === 'ftp' ? 'FTP' : 'SSH/SFTP 비밀번호')} · ${s.user}@${s.host}:${s.port} · 경로 ${s.docroot}${s.shell === false ? ' · 파일 전송 방식' : ''}`, + s.importedAt ? `가져옴: ${new Date(s.importedAt).toLocaleString(uiLocale())} · 파일 ${s.fileCount}개${site.importInfo && site.importInfo.dynamic ? ' · 서버 스크립트 포함(PHP 등)' : ''}` : (site.importError ? `가져오기 실패: ${site.importError}` : '가져오기 진행 중…'), + s.lastPushAt ? `마지막 반영: ${new Date(s.lastPushAt).toLocaleString(uiLocale())}${s.backupNote ? ' · ' + s.backupNote : ''}` : '아직 원본 서버에 반영한 적이 없습니다.', + ]; + for (const l of lines) info.appendChild(el('div', '', l)); + if (site.importError && !s.importedAt) { const b = el('button', 'ghost small-btn', '다시 가져오기'); b.onclick = () => reimport(false); info.appendChild(b); } + $('imBtnDeploy').disabled = importing || !s.importedAt || !!site.running; + const msg = $('imDeployMsg'); msg.className = 'small'; msg.textContent = ''; + if (last) { msg.className = 'small ' + (last.ok ? 'ok' : 'fail'); msg.textContent = `${new Date(last.at).toLocaleString(uiLocale())} ${last.ok ? '반영 완료' : '반영 실패'} — ${last.detail || ''}`; const acc = (last.access && last.access.items) || []; for (const it of acc) { const d = el('div', ''); const a = el('a'); a.href = it.url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = it.url; d.appendChild(a); d.appendChild(document.createTextNode(` — ${it.ok ? it.note : '접속 불가: ' + it.note}`)); msg.appendChild(d); } } + $('imcUser').value = s.user; $('imcPort').value = s.port; $('imcDocroot').value = s.docroot; $('imcUrl').value = s.url || ''; $('imcPw').value = ''; $('imcPw').parentElement.classList.toggle('hidden', s.type !== 'password'); $('imcMsg').textContent = ''; + } + async function reimport(force) { + try { await api(`/api/sites/${current.id}/reimport`, { method: 'POST', body: JSON.stringify({ force }) }); setStatus('running', '기존 사이트 가져오기'); } + catch (e) { alert(e.message); } + } + $('imBtnDeploy').onclick = async () => { + if (!current || !current.source) return; + const first = !((current.deploy && current.deploy.history) || []).some((d) => d.ok); + const s = current.source; + if (!confirm(`${first ? '처음으로 ' : ''}수정 내용을 원본 서버에 반영합니다.\n\n서버: ${s.user}@${s.host}:${s.port}\n경로: ${s.docroot}\n${s.url ? '주소: ' + s.url + '\n' : ''}\n서버의 같은 이름 파일이 덮어써집니다${s.type !== 'password' || s.protocol !== 'ftp' ? ' (반영 전 서버에 백업을 만듭니다)' : ''}. 계속할까요?`)) return; + $('imBtnDeploy').disabled = true; $('imDeployMsg').className = 'small'; $('imDeployMsg').textContent = '반영 중… 아래 "진행 상태" 상자에서 단계와 결과를 확인할 수 있습니다.'; + try { const r = await api(`/api/sites/${current.id}/deploy`, { method: 'POST', body: '{}' }); if (r.site) { current = r.site; renderGit(current); } onSiteOpened(current); } + catch (e) { $('imDeployMsg').className = 'small fail'; $('imDeployMsg').textContent = '반영 실패: ' + e.message; $('imBtnDeploy').disabled = false; try { const s2 = await api(`/api/sites/${current.id}`); current = s2; onSiteOpened(current); } catch (_) {} } + }; + $('imBtnCreds').onclick = () => $('imCredsBox').classList.toggle('hidden'); + $('imcSave').onclick = async () => { + if (!current) return; $('imcSave').disabled = true; $('imcMsg').textContent = '연결 확인 중…'; + try { + const b = { user: $('imcUser').value.trim(), port: $('imcPort').value.trim(), docroot: $('imcDocroot').value.trim(), url: $('imcUrl').value.trim() }; + if ($('imcPw').value) b.password = $('imcPw').value; + const r = await api(`/api/sites/${current.id}/source`, { method: 'PUT', body: JSON.stringify(b) }); + current.source = r.source; onSiteOpened(current); $('imCredsBox').classList.remove('hidden'); $('imcMsg').textContent = '저장되었습니다 — ' + r.detail; + } catch (e) { $('imcMsg').textContent = e.message; } + $('imcSave').disabled = false; + }; + + window.Importer = { showCopyStart, onSiteOpened }; +})(); diff --git a/modules/webmaker/public/index.html b/modules/webmaker/public/index.html new file mode 100644 index 0000000..115b7b5 --- /dev/null +++ b/modules/webmaker/public/index.html @@ -0,0 +1,504 @@ + + + + + +AI Webmaker + + + + + +
    +
    +

    AI Webmaker

    + +
    +
    작업 중인 사이트
    +
    + +
    사이트를 고르면 상태와 운영 주소가 여기에 표시됩니다
    +
    + + +
    + + + +
    +
    +
    +
    + + + +
    +
    + 아직 생성된 페이지가 없습니다 + + + + 새 탭에서 열기 + + +
    +
    +
    +
    +

    미리보기

    +

    왼쪽 만들기에서 사이트를 만들면 이 영역에 결과가 표시됩니다. 만들어 둔 사이트는 왼쪽 레일의 사이트 선택 상자에서 고르고 수정에서 고칩니다.

    +
    +
    + + +
    + + +
    + + +
    +
    + +

    문의 · 제안 · 오류

    + + + +

    +
    보낼 때 문제 해결을 위해 사용 환경 정보가 함께 전송됩니다: 브라우저·운영체제, 화면 해상도와 창 크기, 화면 배율, 언어, 시간대, 서비스 버전, 현재 탭과 사이트 상태. 비밀번호나 입력 내용은 보내지 않습니다.
    +
    +
    +

    캐시 상세 내역

    +

    도메인

    + +

    작업 상세 내역

    + + + + + + + + + + + + + + + + diff --git a/modules/webmaker/public/login.html b/modules/webmaker/public/login.html new file mode 100644 index 0000000..0f95434 --- /dev/null +++ b/modules/webmaker/public/login.html @@ -0,0 +1,110 @@ + + + + + +로그인 — AI Webmaker + + + + + +
    + +

    AI Webmaker

    +
    로그인하면 내 사이트만 보이고, 생성·수정 이력이 Gitea 에 보관됩니다.
    + + +
    + + +
    +
    + + + +
    + +
    + + +

    계정이 없으면 회원가입으로 바로 만들 수 있습니다.

    + +
    + + + + + diff --git a/modules/webmaker/public/manage-ui.js b/modules/webmaker/public/manage-ui.js new file mode 100644 index 0000000..dd51d39 --- /dev/null +++ b/modules/webmaker/public/manage-ui.js @@ -0,0 +1,72 @@ +'use strict'; +/* ============================================================================ + * 관리 탭 오른쪽 "사이트 관리 창" (v1.4.14, 요구 2) + * + * 관리 탭을 고르면 오른쪽 미리보기(iframe·소스 편집기) 대신 이 창이 보인다. 두 가지 보기: + * - 사이트 관리자 : 사이트 관리자 페이지(/site-admin//?embed=1)를 iframe 으로 — 회원·게시판·문의·예약·갤러리 등 + * 모듈 데이터와 사이트 관리자 계정을 여기서 바로 다룬다. 빌더 소유자 세션으로 자동 로그인된다(embed 모드). + * - 사이트 현황 : 접속 통계(오늘·7일·누적)·네트워크·저장 공간, 문의 접수 건수, 켜진 모듈, 메일, 배포·발행 상태, + * 수정 횟수, 버전 수, 사용 캐시를 한 화면에 요약한다. 자료는 app.js 가 이미 받아 둔 것(statsData·current)과 + * 문의 목록 응답을 그대로 쓰고, 모듈 상태만 /api/sites//modules 로 받는다. + * + * app.js 와의 연결점(전역): current(현재 사이트), statsData(접속 통계), krwFmt/fmt/fmtBytes(표시), T/TF(조각 번역), api(). + * - ManageUI.show(site) : 관리 탭이 보일 때 — iframe 을 (아직 안 열었으면) 열고 현황을 그린다 + * - ManageUI.onSiteOpened(site): 사이트가 바뀌면 iframe 주소를 바꾸고(관리 탭이 보일 때만 바로 로드) 현황을 다시 그린다 + * - ManageUI.onStats(st) / onInquiries(r): 통계·문의 자료가 갱신되면 현황 숫자만 다시 그린다 + * ========================================================================== */ +(() => { + const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }; + let view = 'admin'; // 'admin' | 'info' — 마지막으로 고른 보기 (localStorage 에 기억). v1.7.2: 'status'(현황) 는 '사이트 정보'(info) 페이지로 합쳐졌다 + let frameSiteId = null; // iframe 에 지금 열려 있는 사이트 ID (사이트가 바뀌었을 때만 다시 로드) + let inquiries = null; // 마지막 문의 목록 응답 (renderInquiries 가 넘겨 준다) + let modules = null; // { enabled, counts, catalog } — 모듈 상태 (사이트마다 한 번 받는다) + let modulesFor = null; // modules 를 받은 사이트 ID + try { view = ['status', 'info'].includes(localStorage.getItem('manageView')) ? 'info' : 'admin'; } catch (_) {} + + /** 지금 관리 탭이 화면에 보이는지 (보이지 않을 때는 iframe 을 미리 로드하지 않아 불필요한 요청을 막는다) */ + const visible = () => !$('manageView').classList.contains('hidden'); + + /** 보기 전환 버튼 상태와 본문(iframe / 현황 / 빈 안내) 표시 */ + function layout() { + const site = current; + $('mvTabAdmin').classList.toggle('active', view === 'admin'); $('mvTabStatus').classList.toggle('active', view === 'info'); + $('mvEmpty').classList.toggle('hidden', !!site); + $('mvAdminFrame').classList.toggle('hidden', !site || view !== 'admin'); + $('mvStatus').classList.toggle('hidden', !site || view !== 'info'); + $('mvReload').disabled = !site; $('mvOpen').classList.toggle('disabled', !site); + $('mvTitle').textContent = site ? `${T('사이트 관리')} · ${site.title}` : T('사이트 관리'); + if (site) $('mvOpen').href = view === 'admin' ? `/site-admin/${site.id}/` : (site.liveUrl || site.url); + } + /** 사이트 관리자 페이지 iframe 로드 (사이트가 바뀌었거나 강제 새로고침일 때만) */ + function loadFrame(force) { + const site = current; const f = $('mvAdminFrame'); if (!site) { f.removeAttribute('src'); frameSiteId = null; return; } + if (!force && frameSiteId === site.id && f.getAttribute('src')) return; + frameSiteId = site.id; f.src = `/site-admin/${site.id}/?embed=1`; + } + /** 모듈 상태를 받아 현황에 쓴다 (사이트마다 한 번, 새로고침 때 다시) */ + async function loadModules(force) { + const site = current; if (!site) { modules = null; modulesFor = null; return; } + if (!force && modulesFor === site.id && modules) return; + try { modules = await api(`/api/sites/${site.id}/modules`); modulesFor = site.id; } catch (_) { modules = null; modulesFor = site.id; } + } + + /** + * 사이트 정보 페이지 (v1.7.2 요구 2): 그리기는 public/site-info.js 가 맡는다 — 상태 타일·접속 통계 그래프·네트워크 선/막대 그래프·디스크 사용량·페이지/유입. + * 1.6.40 까지의 "사이트 현황" 요약은 이 페이지의 맨 위 타일로 들어갔다. + */ + function renderStatus() { const root = $('mvStatus'); if (!current) { root.innerHTML = ''; return; } if (window.SiteInfoUI) SiteInfoUI.render(root, { site: current, stats: typeof statsData !== 'undefined' ? statsData : null, inquiries, modules }); } + + function show(site) { layout(); if (!site) return; if (view === 'admin') loadFrame(false); else { loadModules(false).then(renderStatus); renderStatus(); } } + function setView(v) { view = v; try { localStorage.setItem('manageView', v); } catch (_) {} show(current); } + $('mvTabAdmin').onclick = () => setView('admin'); $('mvTabStatus').onclick = () => setView('info'); + $('mvReload').onclick = () => { if (!current) return; if (view === 'admin') loadFrame(true); else { loadModules(true).then(renderStatus); if (typeof loadStats === 'function') loadStats(); if (typeof renderInquiries === 'function') renderInquiries(); } }; + + window.ManageUI = { + show, + onSiteOpened(site) { if (!site || site.id !== frameSiteId) { frameSiteId = null; $('mvAdminFrame').removeAttribute('src'); } if (!site || site.id !== modulesFor) { modules = null; modulesFor = null; inquiries = null; } if (visible()) show(site); else layout(); }, + onStats() { if (visible() && view === 'info') renderStatus(); }, + onInquiries(r) { inquiries = r; if (visible() && view === 'info') renderStatus(); }, + /** v1.7.2: 가운데 칸의 "사이트 정보" 요약 카드에서 '자세히 보기' → 운영 화면 + 사이트 정보 페이지 */ + openInfo() { setView('info'); }, + }; +})(); diff --git a/modules/webmaker/public/modules-ui.js b/modules/webmaker/public/modules-ui.js new file mode 100644 index 0000000..03df995 --- /dev/null +++ b/modules/webmaker/public/modules-ui.js @@ -0,0 +1,22 @@ +'use strict'; +/* 빌더 관리 탭 "사이트 기능 모듈" 카드 (v1.0.3) + * 모듈 켜기/끄기와 사이트 관리자 페이지 열기(새 창). 세부 설정·데이터 관리는 사이트 관리자 페이지에서 한다. + * v1.4.14: 카드 안에 내장하던 iframe("여기서 관리")은 없앴다 — 관리 탭의 오른쪽 창(public/manage-ui.js)이 관리자 페이지를 항상 보여 준다. */ +(() => { + let data = null; + const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }; + async function load() { + if (!current) return; $('moduleCard').classList.remove('hidden'); + try { data = await api(`/api/sites/${current.id}/modules`); } catch (e) { $('moduleList').textContent = '모듈 정보를 불러오지 못했습니다: ' + e.message; return; } + const list = $('moduleList'); list.innerHTML = ''; + for (const c of data.catalog) { + const row = el('div', 'module-row'); const sw = el('button', 'switch' + (data.enabled[c.id] ? ' on' : '')); sw.type = 'button'; sw.title = data.enabled[c.id] ? '끄기' : '켜기'; + sw.onclick = async () => { try { const r = await api(`/api/sites/${current.id}/modules/enabled`, { method: 'PUT', body: JSON.stringify({ module: c.id, on: !data.enabled[c.id] }) }); data.enabled = r.enabled; sw.classList.toggle('on', !!data.enabled[c.id]); if (data.enabled[c.id]) logLine({ level: 'warn', text: `${c.label} 모듈을 켰습니다. 수정 지시에 "${c.label}을(를) 추가해 주세요"라고 요청하면 페이지에 들어갑니다. 세부 설정은 사이트 관리자 페이지에서 합니다.` }); } catch (e) { alert(e.message); } }; + const t = el('div'); t.appendChild(el('div', 'mt', T(c.label) + (data.counts && c.collections && Object.keys(c.collections).length ? ` · ${Object.keys(c.collections).map((k) => `${T(c.collections[k].label)} ${data.counts[k] || 0}`).join(', ')}` : ''))); t.appendChild(el('div', 'md', c.desc)); + row.appendChild(sw); row.appendChild(t); const open = el('a', 'ghost small-btn', '관리'); open.href = `/site-admin/${current.id}/`; open.target = '_blank'; open.rel = 'noopener'; open.style.visibility = data.enabled[c.id] ? '' : 'hidden'; row.appendChild(open); list.appendChild(row); + } + $('btnSiteAdmin').href = `/site-admin/${current.id}/`; + } + $('btnModuleReload').onclick = load; + window.ModulesUI = { onSiteOpened: (site) => { if (site && (site.mode !== 'import' || site.source)) load(); } }; +})(); diff --git a/modules/webmaker/public/numfmt.js b/modules/webmaker/public/numfmt.js new file mode 100644 index 0000000..0d8224d --- /dev/null +++ b/modules/webmaker/public/numfmt.js @@ -0,0 +1,54 @@ +'use strict'; +/* ============================================================================ + * 숫자 입력 칸 천 단위 쉼표 (v1.4.5) + * + * 화면의 모든 숫자 입력 칸(input type=number)을 "12,000" 처럼 3자리마다 쉼표가 보이는 칸으로 바꾼다. + * - 브라우저의 number 칸은 쉼표를 넣을 수 없어, 글자 칸(type=text, inputmode=numeric/decimal)으로 바꾸고 입력할 때마다 쉼표를 다시 넣는다. + * - 기존 화면 코드(admin.js 등)는 input.value 로 값을 읽어 서버에 보낸다. 그 코드를 모두 고치지 않도록 이 칸들의 value 를 + * "쉼표를 뺀 숫자 문자열" 로 돌려주고, value 에 넣는 값은 쉼표를 붙여 보이게 한다(요소별 value 속성 재정의). + * → 서버로 가는 값·검증(최소·최대)·저장하지 않은 변경 표시(data-dirty-watch)는 이전과 같다. + * - 나중에 만들어지는 칸(사이트 관리자 입력 창, 도메인 레코드 등)도 MutationObserver 로 따라가 바꾼다. + * - 쉼표가 어울리지 않는 칸(포트 번호 등)은 data-plain 속성을 붙이면 그대로 둔다. + * 전역: window.WMNum = { format(값), clean(문자열), enhance(입력칸) } + * ========================================================================== */ +(function () { + var desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + /** 화면에 보일 모양: 정수부 3자리마다 쉼표, 소수부·음수 부호는 그대로 */ + function format(raw) { + raw = clean(raw); if (raw === '' || raw === '-' || raw === '.') return raw; + var neg = raw.charAt(0) === '-'; var s = neg ? raw.slice(1) : raw; var dot = s.indexOf('.'); + var int = dot < 0 ? s : s.slice(0, dot); var frac = dot < 0 ? '' : s.slice(dot); + int = int.replace(/^0+(?=\d)/, '').replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return (neg ? '-' : '') + int + frac; + } + /** 쉼표·공백·숫자 아닌 글자를 뺀 값 (서버로 보낼 값). 부호는 맨 앞, 소수점은 하나만 */ + function clean(v) { + var s = String(v == null ? '' : v).replace(/[^\d.\-]/g, ''); var neg = s.charAt(0) === '-'; s = s.replace(/-/g, ''); + var i = s.indexOf('.'); if (i >= 0) s = s.slice(0, i + 1) + s.slice(i + 1).replace(/\./g, ''); + return (neg ? '-' : '') + s; + } + /** 입력 중 쉼표를 다시 넣어도 커서가 같은 숫자 뒤에 머물도록 위치를 옮긴다 */ + function reformat(inp) { + var old = desc.get.call(inp); var pos = inp.selectionStart == null ? old.length : inp.selectionStart; + var keep = old.slice(0, pos).replace(/[^\d.\-]/g, '').length; // 커서 앞의 의미 있는 글자 수 + var next = format(old); if (next === old) return; desc.set.call(inp, next); + var p = 0, n = 0; while (p < next.length && n < keep) { if (/[\d.\-]/.test(next.charAt(p))) n++; p++; } + try { inp.setSelectionRange(p, p); } catch (_) {} + } + function enhance(inp) { + if (!inp || inp.__wmNum || inp.tagName !== 'INPUT' || inp.type !== 'number' || inp.hasAttribute('data-plain')) return; + inp.__wmNum = true; var cur = desc.get.call(inp); var step = inp.getAttribute('step'); + inp.type = 'text'; inp.setAttribute('inputmode', step && step !== 'any' && Number(step) % 1 === 0 && !(Number(inp.getAttribute('min')) < 0) ? 'numeric' : 'decimal'); + inp.setAttribute('autocomplete', 'off'); inp.classList.add('num-input'); + Object.defineProperty(inp, 'value', { configurable: true, get: function () { return clean(desc.get.call(this)); }, set: function (v) { desc.set.call(this, format(v)); } }); + inp.value = cur; inp.addEventListener('input', function () { reformat(inp); }); + } + function scan(root) { if (!root || !root.querySelectorAll) return; if (root.tagName === 'INPUT') enhance(root); var l = root.querySelectorAll('input[type="number"]'); for (var i = 0; i < l.length; i++) enhance(l[i]); } + function start() { + scan(document.body); + new MutationObserver(function (ms) { for (var i = 0; i < ms.length; i++) { var m = ms[i]; if (m.type === 'attributes') enhance(m.target); else for (var j = 0; j < m.addedNodes.length; j++) scan(m.addedNodes[j]); } }) + .observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['type'] }); + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start(); + window.WMNum = { format: format, clean: clean, enhance: enhance }; +})(); diff --git a/modules/webmaker/public/optimize.js b/modules/webmaker/public/optimize.js new file mode 100644 index 0000000..94d6e62 --- /dev/null +++ b/modules/webmaker/public/optimize.js @@ -0,0 +1,273 @@ +'use strict'; +/* ============================================================================ + * 관리 탭: 검색·AI 최적화(SEO·AEO·GEO) 검토·반영, 검색사이트 등록 (v1.3.5) + * + * 검색·AI 최적화 카드 (#optimizeCard) + * - 생성 옵션으로 고른 항목은 생성이 끝나면 AI 가 초안을 만든다(GET/POST /api/sites//optimize[/draft]). + * - 사용자가 초안의 값을 고치고 항목별 "반영" 체크를 정한 뒤 "검토한 내용 반영" 을 누르면(POST …/optimize/apply) + * 서버가 HTML ·FAQ 영역·sitemap.xml·robots.txt·llms.txt 에 넣고 버전을 저장한다. + * - 각 입력에 작성 기준(글자 수 등)과 현재 글자 수를 보여 줘 검토를 돕는다. + * 검색사이트 등록 카드 (#searchCard) + * - 배포가 끝난 사이트만. 구글·네이버: 확인 코드 입력 → 태그 넣고 재배포 → 검색사이트에서 확인 → 사이트맵 제출. + * 사람이 해야 하는 단계는 체크리스트로 보이며 완료 표시를 할 수 있다(관리자 콘솔 "검색 등록" 탭에서도 진행 가능). + * - IndexNow: 키 파일을 올리고 빙·네이버 등에 색인 요청. + * 전역: window.OptimizeUI.onSiteOpened(site) — app.js 가 사이트를 열거나 작업이 끝날 때 부른다. + * ========================================================================== */ +(() => { + const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }; + const btn = (label, cls, fn) => { const b = el('button', cls || 'ghost small-btn', label); b.type = 'button'; b.onclick = fn; return b; }; + let siteId = null; let ov = null; let ed = null; // ov: 서버 상태, ed: 편집 중인 값 (draft 또는 final 의 복사본) + // v1.3.10 다국어: edAll[언어] = 언어별 편집 값, curLang = 지금 보는 언어 탭 (단일 언어 사이트는 edAll 없음) + let edAll = null; let curLang = null; + const LABEL = { none: '사용 안 함', waiting: '생성 후 초안 예정', drafting: '초안 만드는 중', draft: '검토 대기', applied: '반영됨', error: '초안 실패' }; + + // ---------- 검색·AI 최적화 ---------- + async function loadOptimize() { + try { ov = await api(`/api/sites/${siteId}/optimize`); } catch (_) { return; } + const st = ov.status; $('optState').textContent = LABEL[st] || st; $('optState').className = 'pill small ' + (st === 'applied' ? 'done' : st === 'draft' ? 'waiting' : st === 'drafting' || st === 'waiting' ? 'running' : st === 'error' ? 'error' : 'idle'); + const box = $('optBody'); box.innerHTML = ''; + if (st === 'drafting' || st === 'waiting') { box.appendChild(el('p', 'muted small', st === 'waiting' ? '사이트 생성·검증이 끝나면 AI 가 초안을 만듭니다.' : 'AI 가 사이트 페이지를 읽고 초안을 만들고 있습니다. 끝나면 여기서 검토할 수 있습니다.')); return; } + if (st === 'none' || st === 'error' || (!ov.draft && !ov.final)) { renderStarter(box, st === 'error' ? ov.error : null); return; } + ed = JSON.parse(JSON.stringify(ov.applied && ov.final ? ov.final : ov.draft)); + edAll = null; + if (ov.langs) { + // 언어별 편집 값: 기본 언어는 위의 ed, 다른 언어는 반영 값 → 없으면 그 언어 초안 + edAll = {}; for (const l of ov.langs) { const src = l.isDefault ? ed : ((ov.applied && ov.langFinals[l.code]) || ov.langDrafts[l.code] || null); edAll[l.code] = l.isDefault ? ed : (src ? JSON.parse(JSON.stringify(src)) : null); } + if (!curLang || !edAll.hasOwnProperty(curLang)) curLang = ov.langs[0].code; + } + renderLangTabs(box); + } + /** v1.3.10 언어 탭 (다국어 사이트): 탭마다 그 언어 폴더(/en/ 등)에 들어갈 값을 따로 검토한다 */ + function renderLangTabs(box) { + box.innerHTML = ''; + if (!edAll) { renderEditor(box); return; } + const tabs = el('div', 'opt-langs'); + for (const l of ov.langs) { const b = btn(`${l.name}${l.isDefault ? ' · ' + T('기본') : ''}`, 'ghost small-btn' + (curLang === l.code ? ' on' : ''), () => { curLang = l.code; renderLangTabs(box); }); b.lang = l.code; tabs.appendChild(b); } + box.appendChild(tabs); + const cur = ov.langs.find((l) => l.code === curLang); + box.appendChild(el('p', 'muted small', TF('{1} 페이지(/{2}/)에 들어갈 값입니다. 언어마다 따로 검토하고, 아래 "반영" 은 모든 언어를 함께 반영합니다.', `${cur.name} (${T(cur.label)})`, cur.code))); + if (!edAll[curLang]) { box.appendChild(el('p', 'msg err small', ov.langErrors && ov.langErrors[curLang] ? TF('이 언어 초안을 만들지 못했습니다: {1}', ov.langErrors[curLang]) : T('이 언어 초안이 아직 없습니다. "AI 로 다시 만들기" 로 만드세요.'))); renderEditor(box, true); return; } + ed = edAll[curLang]; renderEditor(box); + // 값 입력칸만 그 언어의 글 방향·언어로 (아랍어 등은 오른쪽→왼쪽). 안내 문구·버튼은 화면 언어 그대로 + box.querySelectorAll('.opt-field input, .opt-field textarea').forEach((i) => { i.dir = cur.dir; i.lang = cur.code; }); + } + /** 초안이 없을 때: 항목 고르고 AI 초안 만들기 */ + function renderStarter(box, err) { + if (err) box.appendChild(el('p', 'msg err small', '초안을 만들지 못했습니다: ' + err)); + box.appendChild(el('p', 'muted small', '검색엔진(SEO)·AI 답변 엔진(AEO)·생성형 AI 검색(GEO)에 맞는 값을 AI 가 사이트 내용으로 초안을 만들고, 검토 후 반영합니다. (기본 등급, 캐시 차감)')); + const row = el('div', 'row'); const ck = {}; + for (const [k, l] of [['seo', 'SEO'], ['aeo', 'AEO'], ['geo', 'GEO']]) { const lab = el('label', 'chk'); const c = el('input'); c.type = 'checkbox'; c.checked = ov.wanted[k] !== false; ck[k] = c; lab.appendChild(c); lab.appendChild(document.createTextNode(' ' + l)); row.appendChild(lab); } + row.appendChild(btn('AI 로 초안 만들기', 'primary small-btn', async () => { try { await api(`/api/sites/${siteId}/optimize/draft`, { method: 'POST', body: JSON.stringify({ wanted: { seo: ck.seo.checked, aeo: ck.aeo.checked, geo: ck.geo.checked } }) }); setTimeout(loadOptimize, 400); } catch (e) { alert(e.message); } })); + box.appendChild(row); + } + /** 글자 수 안내가 붙은 입력. rule: [권장 최소, 권장 최대, 최대] */ + function field(label, obj, key, opts = {}) { + const wrap = el('label', 'opt-field'); const head = el('span', '', label); wrap.appendChild(head); + const inp = opts.area ? el('textarea') : el('input'); if (opts.area) inp.rows = opts.rows || 3; inp.value = Array.isArray(obj[key]) ? obj[key].join(opts.sep || ', ') : (obj[key] || ''); if (opts.placeholder) inp.placeholder = opts.placeholder; + const cnt = opts.rule ? el('span', 'opt-count') : null; if (cnt) head.appendChild(cnt); + const upd = () => { const v = inp.value; obj[key] = opts.list ? v.split(opts.sep === '\n' ? /\n/ : /,/).map((x) => x.trim()).filter(Boolean) : v; if (cnt) { const n = v.trim().length; const [lo, hi, max] = opts.rule; cnt.textContent = ' ' + (max ? TF('{1}자 (권장 {2}~{3}, 최대 {4})', n, lo, hi, max) : TF('{1}자 (권장 {2}~{3})', n, lo, hi)); /* v1.3.10: 조각 번역(TF) — 값이 인접한 문장은 화면에서 나눠 맞출 수 없음 */ cnt.className = 'opt-count ' + (n > (max || hi) ? 'bad' : n < lo || n > hi ? 'warn' : 'ok'); } }; + inp.oninput = upd; upd(); wrap.appendChild(inp); return wrap; + } + const useBox = (obj, key, label) => { const lab = el('label', 'chk'); const c = el('input'); c.type = 'checkbox'; c.checked = obj[key] !== false; c.onchange = () => { obj[key] = c.checked; }; lab.appendChild(c); lab.appendChild(document.createTextNode(' ' + (label || '반영'))); return lab; }; + function section(box, title, help) { const d = el('details', 'opt-sec'); d.open = true; const s = el('summary', '', title); d.appendChild(s); if (help) d.appendChild(el('p', 'muted small', help)); box.appendChild(d); return d; } + /** 초안(또는 반영값) 편집 화면 */ + function renderEditor(box, buttonsOnly) { + if (!buttonsOnly) box.appendChild(el('p', 'muted small', ov.applied ? TF('반영됨 ({1}). 값을 고친 뒤 다시 반영할 수 있습니다.', new Date(ov.appliedAt).toLocaleString(uiLocale())) + (ov.liveBase ? '' : ' ' + T('운영 주소가 생기면(배포) canonical·사이트맵 주소가 자동으로 채워집니다.')) : TF('AI 초안입니다 ({1}).', ov.draftAt ? new Date(ov.draftAt).toLocaleString(uiLocale()) : '') + ' ' + T(' 내용을 확인·수정하고 반영할 항목만 체크한 뒤 "검토한 내용 반영" 을 누르세요. 반영 전에는 사이트에 들어가지 않습니다.'))); + if (!buttonsOnly && ed.seo) { + const d = section(box, 'SEO — 검색 결과 제목·설명·키워드', '제목은 핵심어를 앞쪽에, 페이지마다 다르게. 설명은 페이지 내용을 요약하고 행동을 유도합니다. 과장·키워드 나열은 피하세요.'); + const two = el('div', 'two'); two.appendChild(field('사이트 이름', ed.seo, 'siteName')); two.appendChild(field('사이트 핵심 검색어 (쉼표)', ed.seo, 'keywords', { list: true })); d.appendChild(two); + for (const pg of ed.seo.pages) { + const card = el('div', 'opt-page'); const h = el('div', 'opt-page-head'); h.appendChild(el('b', '', pg.file)); h.appendChild(useBox(pg, 'use')); card.appendChild(h); + card.appendChild(field('검색 제목 (title)', pg, 'title', { rule: [25, 35, 60] })); + card.appendChild(field('검색 설명 (meta description)', pg, 'description', { area: true, rows: 2, rule: [70, 110, 155] })); + card.appendChild(field('페이지 검색어 (쉼표)', pg, 'keywords', { list: true })); + const more = el('details', 'opt-more'); more.appendChild(el('summary', '', '공유 미리보기 (SNS)·h1')); + more.appendChild(field('공유 제목 (og:title)', pg, 'ogTitle', { rule: [10, 40, 90] })); more.appendChild(field('공유 설명 (og:description)', pg, 'ogDescription', { area: true, rows: 2, rule: [40, 100, 200] })); + more.appendChild(field('h1 제안 (참고용 — 본문은 바꾸지 않음)', pg, 'h1')); card.appendChild(more); d.appendChild(card); + } + } + if (!buttonsOnly && ed.aeo) { + const d = section(box, 'AEO — 질문형 FAQ·핵심 답변', '질문은 실제 고객이 검색할 문장으로, 답은 첫 문장에 바로 답하고 2~4문장 이내로. "사이트에 있음" 이 아닌 질문은 반영 시 해당 페이지에 "자주 묻는 질문" 영역으로 추가됩니다.'); + d.appendChild(field('핵심 답변 요약 (AI·음성 답변용)', ed.aeo, 'summary', { area: true, rows: 2, rule: [60, 200, 400] })); + const list = el('div'); d.appendChild(list); + const drawFaqs = () => { + list.innerHTML = ''; + ed.aeo.faqs.forEach((f, i) => { + const card = el('div', 'opt-page'); const h = el('div', 'opt-page-head'); h.appendChild(el('b', '', `질문 ${i + 1}`)); h.appendChild(useBox(f, 'use')); + const onp = el('label', 'chk'); const oc = el('input'); oc.type = 'checkbox'; oc.checked = !!f.onPage; oc.onchange = () => { f.onPage = oc.checked; }; onp.appendChild(oc); onp.appendChild(document.createTextNode(' 사이트에 이미 있음')); h.appendChild(onp); + const sel = el('select'); sel.style.width = 'auto'; sel.style.margin = '0'; for (const fl of ov.files) sel.appendChild(new Option(fl, fl)); sel.value = f.page; sel.onchange = () => { f.page = sel.value; }; h.appendChild(sel); + h.appendChild(btn('삭제', 'ghost small-btn', () => { ed.aeo.faqs.splice(i, 1); drawFaqs(); })); card.appendChild(h); + card.appendChild(field('질문', f, 'q', { rule: [8, 40, 200] })); card.appendChild(field('답변', f, 'a', { area: true, rows: 3, rule: [40, 200, 800] })); list.appendChild(card); + }); + list.appendChild(btn('+ 질문 추가', 'ghost small-btn', () => { ed.aeo.faqs.push({ q: '', a: '', page: ov.files[0] || 'index.html', onPage: false, use: true }); drawFaqs(); })); + }; + drawFaqs(); + } + if (!buttonsOnly && ed.geo) { + const d = section(box, 'GEO — 사업체 정보·인용용 사실·llms.txt', '생성형 AI 검색이 사업체를 정확히 알아보고 인용하도록 합니다. 사실과 다른 값은 지우세요(모르는 값은 비워 둡니다).'); + const e = ed.geo.entity; const h = el('div', 'opt-page-head'); h.appendChild(el('b', '', '사업체 정보 (구조화 데이터)')); h.appendChild(useBox(ed.geo, 'useEntity')); d.appendChild(h); + const g1 = el('div', 'two'); const tsel = el('label', 'opt-field'); tsel.appendChild(el('span', '', '유형')); const ts = el('select'); for (const t of ['Organization', 'LocalBusiness', 'Restaurant', 'Store', 'ProfessionalService', 'MedicalClinic', 'EducationalOrganization']) ts.appendChild(new Option(t, t)); ts.value = e.type; ts.onchange = () => { e.type = ts.value; }; tsel.appendChild(ts); g1.appendChild(tsel); g1.appendChild(field('이름', e, 'name')); d.appendChild(g1); + d.appendChild(field('소개', e, 'description', { area: true, rows: 2 })); + const g2 = el('div', 'two'); g2.appendChild(field('전화', e, 'telephone')); g2.appendChild(field('이메일', e, 'email')); d.appendChild(g2); + const g3 = el('div', 'two'); g3.appendChild(field('주소 (도로명)', e.address, 'streetAddress')); g3.appendChild(field('시·군·구', e.address, 'addressLocality')); g3.appendChild(field('시·도', e.address, 'addressRegion')); g3.appendChild(field('우편번호', e.address, 'postalCode')); d.appendChild(g3); + const g4 = el('div', 'two'); g4.appendChild(field('영업시간 (쉼표, 예: Mo-Fr 09:00-18:00)', e, 'openingHours', { list: true })); g4.appendChild(field('가격대', e, 'priceRange')); d.appendChild(g4); + d.appendChild(field('공식 SNS·지도 주소 (한 줄에 하나)', e, 'sameAs', { area: true, rows: 2, list: true, sep: '\n' })); + d.appendChild(field('인용용 핵심 사실 (한 줄에 하나)', ed.geo, 'facts', { area: true, rows: 4, list: true, sep: '\n' })); + const h2 = el('div', 'opt-page-head'); h2.appendChild(el('b', '', 'llms.txt (AI 크롤러용 사이트 요약)')); h2.appendChild(useBox(ed.geo, 'useLlms')); d.appendChild(h2); + const ll = field('', ed.geo, 'llmsTxt', { area: true, rows: 8 }); ll.querySelector('textarea').classList.add('mono'); d.appendChild(ll); + } + const row = el('div', 'row'); const msg = el('span', 'muted small'); + row.appendChild(btn(ov.applied ? '고친 내용 다시 반영' : '검토한 내용 반영', 'primary small-btn', async () => { + msg.textContent = T('반영 중…'); + // 다국어: 기본 언어 값은 final, 다른 언어는 langs[언어] 로 함께 보낸다 + const body = edAll ? { final: edAll[ov.langs[0].code], langs: Object.fromEntries(ov.langs.slice(1).filter((l) => edAll[l.code]).map((l) => [l.code, edAll[l.code]])) } : { final: ed }; + try { const r = await api(`/api/sites/${siteId}/optimize/apply`, { method: 'POST', body: JSON.stringify(body) }); msg.textContent = TF('반영했습니다 — 바뀐 파일 {1}개.', r.changed.length) + (r.changed.length ? ` (${r.changed.slice(0, 5).join(', ')}${r.changed.length > 5 ? ' …' : ''})` : '') + ' ' + T('운영 사이트에는 다음 배포 때 올라갑니다.'); if (typeof current !== 'undefined' && current && r.site) Object.assign(current, r.site); /* app.js 의 현재 사이트(전역 let) */ if (typeof loadFrame === 'function') loadFrame(); setTimeout(loadOptimize, 1500); } + catch (e) { msg.textContent = e.message; } + })); + row.appendChild(btn('AI 로 다시 만들기', 'ghost small-btn', async () => { if (!confirm('AI 초안을 다시 만듭니다(캐시 차감). 지금 고친 값은 반영하지 않으면 사라집니다. 계속할까요?')) return; try { await api(`/api/sites/${siteId}/optimize/draft`, { method: 'POST', body: JSON.stringify({ wanted: ov.wanted && (ov.wanted.seo || ov.wanted.aeo || ov.wanted.geo) ? ov.wanted : { seo: !!ed.seo, aeo: !!ed.aeo, geo: !!ed.geo } }) }); setTimeout(loadOptimize, 400); } catch (e) { alert(e.message); } })); + if (ov.applied) row.appendChild(btn('반영한 값 빼기', 'ghost small-btn danger', async () => { if (!confirm('사이트에 넣은 검색·AI 최적화 값(관리 구역·FAQ 영역·자동 생성 파일)을 뺍니다. 초안은 남습니다. 계속할까요?')) return; try { const r = await api(`/api/sites/${siteId}/optimize/remove`, { method: 'POST', body: '{}' }); msg.textContent = TF('뺐습니다 — 바뀐 파일 {1}개', r.changed.length); if (typeof loadFrame === 'function') loadFrame(); loadOptimize(); } catch (e) { alert(e.message); } })); + row.appendChild(msg); box.appendChild(row); + } + + // ---------- 검색사이트 등록 ---------- + async function loadSearch() { + let v; try { v = await api(`/api/sites/${siteId}/search`); } catch (_) { return; } + const box = $('searchBody'); box.innerHTML = ''; + if (!v.availability.ok) { box.appendChild(el('p', 'muted small', v.availability.reason)); return; } + box.appendChild(el('p', 'muted small', TF('운영 주소 {1} 기준으로 등록합니다.', v.liveUrl) + (v.seoApplied ? '' : ' ' + T('검색·AI 최적화 값을 먼저 반영하면 검색 결과 제목·설명이 더 좋아집니다.')))); + for (const e of v.engines) box.appendChild(engineBlock(e)); + // IndexNow + const ix = el('div', 'reg-engine'); ix.appendChild(el('b', '', 'IndexNow (빙·네이버 등 색인 요청)')); + ix.appendChild(el('p', 'muted small', v.indexnow && v.indexnow.last ? TF('마지막 요청 {1}: {2}', new Date(v.indexnow.lastPingAt).toLocaleString(uiLocale()), T(v.indexnow.last.detail)) : '사이트에 키 파일을 올리고(재배포) 모든 페이지의 색인을 요청합니다. 페이지를 고친 뒤에도 다시 보낼 수 있습니다.')); + ix.appendChild(btn('색인 요청 보내기', 'ghost small-btn', () => act('indexnow', {}))); + box.appendChild(ix); + } + /** 검색엔진 한 개의 등록 단계 표시 */ + function engineBlock(e) { + const d = el('div', 'reg-engine'); const head = el('div', 'opt-page-head'); head.appendChild(el('b', '', e.label)); head.appendChild(el('span', 'pill small ' + (e.state === '등록 완료' ? 'done' : e.started ? 'waiting' : 'idle'), e.state)); d.appendChild(head); + if (!e.started) { + const row = el('div', 'row'); + if (e.engine === 'google' && e.autoAvailable) { d.appendChild(el('p', 'muted small', T('구글 API 로 확인 코드 받기 → 태그 넣고 재배포 → 소유 확인 → Search Console 등록·사이트맵 제출까지 모두 자동으로 진행합니다.'))); row.appendChild(btn('자동 등록 시작', 'primary small-btn', () => act('start', { engine: e.engine, method: 'meta' }))); } /* v1.3.10 */ + else if (e.engine === 'google') { const sel = el('select'); sel.style.width = 'auto'; sel.style.margin = '0'; sel.appendChild(new Option('HTML 태그로 확인 (권장)', 'meta')); sel.appendChild(new Option('도메인 DNS(TXT)로 확인', 'dns')); row.appendChild(sel); row.appendChild(btn('등록 시작', 'primary small-btn', () => act('start', { engine: e.engine, method: sel.value }))); } + else row.appendChild(btn('등록 시작', 'primary small-btn', () => act('start', { engine: e.engine }))); + d.appendChild(row); return d; + } + // v1.3.10 자동 진행 상태 (구글 서비스 계정) + if (e.auto) { const p = el('p', 'small ' + (e.auto.state === 'error' ? 'msg err' : 'muted'), `${e.auto.state === 'running' ? '⏳ ' : e.auto.state === 'done' ? '✓ ' : '✗ '}${T(e.auto.detail)}`); d.appendChild(p); if (e.auto.state === 'running') setTimeout(loadSearch, 4000); } + if (e.autoAvailable && e.method !== 'dns' && (!e.auto || e.auto.state === 'error') && e.state !== '등록 완료') { const r0 = el('div', 'row'); r0.appendChild(btn(e.auto ? '자동 진행 다시' : '자동 진행으로 전환', 'primary small-btn', () => act('auto', { engine: e.engine }))); d.appendChild(r0); } + const ol = el('ol', 'reg-steps'); + for (const s of e.steps) { + const li = el('li', s.done ? 'done' : ''); li.appendChild(el('span', 'reg-actor ' + s.actor, s.actor === 'auto' ? '자동' : '직접')); li.appendChild(el('b', '', ' ' + s.title)); li.appendChild(el('div', 'muted small', s.detail)); + const row = el('div', 'row'); + if (s.id === 'code' && s.actor === 'manual') { const inp = el('input'); inp.placeholder = '확인 코드 또는 meta 태그 전체'; inp.value = e.code || ''; inp.style.flex = '1'; row.appendChild(inp); row.appendChild(btn(e.method === 'dns' ? '저장' : '저장하고 태그 넣기·재배포', 'ghost small-btn', () => act('code', { engine: e.engine, code: inp.value }))); } + if (s.actor === 'auto' && e.autoAvailable && e.method !== 'dns') { /* 구글 자동 진행 단계 — 버튼 없음 */ } + else if (s.id === 'apply' && e.code) row.appendChild(btn(s.done ? '다시 넣고 재배포' : '태그 넣고 재배포', 'primary small-btn', () => act('apply', { engine: e.engine }))); + if (s.id === 'dns' && e.code) row.appendChild(btn('DNS 확인', 'ghost small-btn', () => act('check', { engine: e.engine }))); + if (s.id === 'verify' && e.method !== 'dns' && e.code) row.appendChild(btn('운영 페이지에 태그 있는지 확인', 'ghost small-btn', () => act('check', { engine: e.engine }))); + if (['verify', 'sitemap', 'dns'].includes(s.id) && s.actor === 'manual') row.appendChild(btn(s.done ? '완료 취소' : '완료 표시', 'ghost small-btn', () => act('step', { engine: e.engine, step: s.id, done: !s.done }))); + if (row.children.length) li.appendChild(row); ol.appendChild(li); + } + d.appendChild(ol); + const foot = el('div', 'row'); const a = el('a', 'btn ghost small-btn', TF('{1} 열기', T(e.label))); a.href = e.console; a.target = '_blank'; a.rel = 'noopener'; foot.appendChild(a); + foot.appendChild(btn('등록 기록 지우기', 'ghost small-btn', () => { if (confirm('이 검색사이트의 등록 진행 기록을 지웁니다(사이트의 확인 태그는 다음 배포 때 빠집니다). 계속할까요?')) act('stop', { engine: e.engine }); })); d.appendChild(foot); + return d; + } + /** 등록 동작 호출 → 결과 표시 후 다시 그림 */ + async function act(action, body) { + try { const r = await api(`/api/sites/${siteId}/search/${action}`, { method: 'POST', body: JSON.stringify(body) }); if (r.check) alert(`${r.check.ok ? '✓' : '✗'} ${r.check.detail}`); if (action === 'indexnow' && r.indexnow && r.indexnow.last) alert(r.indexnow.last.detail); } + catch (e) { alert(e.message); } + loadSearch(); + } + + // ---------- v1.3.10 다국어 카드 ---------- + /** 지금 언어 목록과 추가·빼기 체크 (GET/PUT /api/sites//langs) */ + async function loadLangs(site) { + const box = $('langBody'); let d; try { d = await api(`/api/sites/${site.id}/langs`); } catch (_) { return; } + box.innerHTML = ''; const L = d.langs; const multi = !!(L && L.list && L.list.length > 1); + $('langState').textContent = multi ? TF('{1}개 언어', L.list.length) : T('한 언어'); $('langState').className = 'pill small ' + (multi ? 'done' : 'idle'); + if (site.source) { box.appendChild(el('p', 'muted small', T('원본 서버에서 가져온 사이트는 구조를 바꿀 수 없어 다국어로 만들 수 없습니다. "사이트 복사하기" 로 새 사이트를 만든 뒤 사용하세요.'))); return; } + if (multi) { + const now = el('div', 'lang-now'); for (const c of L.list) { const lc = d.locales.find((x) => x.code === c); const a = el('a', '', `/${c}/ ${lc ? lc.name : c}${c === L.default ? ' · ' + T('기본') : ''}`); a.href = `${site.url.replace(/\/?$/, '/')}${c}/`; a.target = '_blank'; a.rel = 'noopener'; now.appendChild(a); } box.appendChild(now); + if (L.syncedAt) box.appendChild(el('p', 'muted small', TF('마지막 번역 맞춤: {1}', new Date(L.syncedAt).toLocaleString(uiLocale())))); + } else box.appendChild(el('p', 'muted small', T('지금은 한 언어 사이트입니다. 추가 언어를 고르면 AI 가 페이지를 기본 언어 폴더로 옮긴 뒤(수정 작업 1회) 다른 언어 페이지를 만듭니다.'))); + let defSel = null; + if (!multi) { const lab = el('label', 'lang-default', T('기본 언어 (지금 사이트의 언어)')); defSel = el('select'); for (const l of d.locales) defSel.appendChild(new Option(`${l.name} — ${T(l.label)}`, l.code)); defSel.value = site.uiLang && d.locales.some((l) => l.code === site.uiLang) ? site.uiLang : 'ko'; lab.appendChild(defSel); box.appendChild(lab); } + const chips = el('div', 'lang-chips'); const def = () => (multi ? L.default : defSel.value); + for (const l of d.locales) { const lab = el('label', 'chk lang-chip'); const c = el('input'); c.type = 'checkbox'; c.value = l.code; c.checked = multi && L.list.includes(l.code); lab.appendChild(c); lab.appendChild(document.createTextNode(' ' + l.name)); lab.title = T(l.label); chips.appendChild(lab); } + const syncDef = () => { for (const i of chips.querySelectorAll('input')) { const isDef = i.value === def(); i.disabled = isDef; if (isDef) i.checked = true; i.parentElement.classList.toggle('off', isDef); } }; + if (defSel) defSel.onchange = syncDef; syncDef(); box.appendChild(chips); + const row = el('div', 'row'); const msg = el('span', 'muted small'); + row.appendChild(btn(multi ? T('언어 변경 적용') : T('다국어 사이트로 바꾸기'), 'primary small-btn', async () => { + const list = [...chips.querySelectorAll('input:checked')].map((i) => i.value); if (list.length < 2) { msg.textContent = T('추가 언어를 하나 이상 고르세요'); return; } + if (!confirm(multi ? T('언어 목록을 바꿉니다. 새 언어는 번역(캐시 차감)하고, 뺀 언어 폴더는 지웁니다. 계속할까요?') : T('AI 가 페이지를 기본 언어 폴더로 옮기고(수정 작업, 캐시 차감) 다른 언어 페이지를 번역해 만듭니다. 계속할까요?'))) return; + msg.textContent = T('진행 중…'); try { await api(`/api/sites/${site.id}/langs`, { method: 'PUT', body: JSON.stringify({ default: def(), list, xDefault: multi ? (L.xDefault || L.default) : def() }) }); msg.textContent = T('시작했습니다. 진행 상황은 작업 로그에 보입니다.'); } catch (e) { msg.textContent = e.message; } + })); + row.appendChild(msg); box.appendChild(row); + langSettings(site, d, box, multi); + if (multi) loadQuality(site, box); + } + /** + * v1.3.16 다국어·현지 설정: URL 원칙 안내, x-default, 사업장 시간대·통화·국가(현지 형식의 기준), 사이트 관리 언어(사이트 관리자 화면·알림 메일). + * 저장하면 AI 없이 언어 선택 페이지·현지 형식·검색 값만 다시 쓴다(PUT /api/sites//langs/settings). + */ + function langSettings(site, d, box, multi) { + const sec = el('details', 'opt-sec'); sec.appendChild(el('summary', '', T('주소 원칙 · x-default · 현지 설정'))); box.appendChild(sec); + sec.appendChild(el('p', 'muted small', T(d.principle || ''))); + const two = el('div', 'two'); let xs = null; + if (multi) { const lab = el('label', 'opt-field'); lab.appendChild(el('span', '', T('x-default (다른 언어 방문자에게 보일 언어)'))); xs = el('select'); for (const c of d.langs.list) { const l = d.locales.find((x) => x.code === c); xs.appendChild(new Option(`${l ? l.name : c} (${c})`, c)); } xs.appendChild(new Option(T('언어 선택 페이지 (자동 이동 없음)'), 'root')); xs.value = d.langs.xDefault || d.langs.default; lab.appendChild(xs); two.appendChild(lab); } + const al = el('label', 'opt-field'); al.appendChild(el('span', '', T('사이트 관리 언어 (사이트 관리자 화면·알림 메일)'))); const as = el('select'); for (const l of d.locales) as.appendChild(new Option(`${l.name} (${l.code})`, l.code)); as.value = d.adminLang || 'ko'; al.appendChild(as); two.appendChild(al); sec.appendChild(two); + const three = el('div', 'two'); const inp = (label, val, ph) => { const lab = el('label', 'opt-field'); lab.appendChild(el('span', '', label)); const i = el('input'); i.value = val || ''; i.placeholder = ph; lab.appendChild(i); three.appendChild(lab); return i; }; + const tz = inp(T('사업장 시간대'), d.locale.timeZone, 'Asia/Seoul'); const cur = inp(T('가격 통화 (ISO 4217)'), d.locale.currency, 'KRW'); const co = inp(T('사업장 국가 (ISO 3166)'), d.locale.country, 'KR'); sec.appendChild(three); + sec.appendChild(el('p', 'muted small', T('날짜·시각은 사업장 시간대로, 가격은 이 통화로(언어마다 기호·자릿수 형식만 바뀜), 전화번호는 페이지 지역이 사업장 국가와 같으면 국내식·다르면 국제식(+82)으로, 주소·이름은 언어마다 순서가 바뀌어 표시됩니다. 페이지에서는 data-wm-date·datetime·price·tel·address·name·legal 속성으로 쓰입니다.'))); + const r2 = el('div', 'row'); const m2 = el('span', 'muted small'); + r2.appendChild(btn(T('설정 저장'), 'primary small-btn', async () => { m2.textContent = T('저장 중…'); try { const body = { adminLang: as.value, timeZone: tz.value.trim(), currency: cur.value.trim(), country: co.value.trim() }; if (xs) body.xDefault = xs.value; const r = await api(`/api/sites/${site.id}/langs/settings`, { method: 'PUT', body: JSON.stringify(body) }); m2.textContent = TF('저장했습니다 — 바뀐 파일 {1}개', r.changed); if (typeof loadFrame === 'function') loadFrame(); } catch (e) { m2.textContent = e.message; } })); + r2.appendChild(m2); sec.appendChild(r2); + } + const pct = (x) => `${(x * 100).toFixed(1)}%`; + const REVIEW = { approved: '검수 완료', rejected: '수정 필요', pending: '검수 대기' }; + /** v1.3.16 번역 품질 게이트(누락률·잔존율·링크 대응·검수)와 로케일별 메타·구조화 데이터 현황 */ + async function loadQuality(site, box) { + let d; try { d = await api(`/api/sites/${site.id}/langs/quality`); } catch (_) { return; } + const q = d.quality; if (!q) return; + const sec = el('details', 'opt-sec'); sec.open = !q.pass; const sm = el('summary', '', `${T('번역 품질 게이트')} — ${q.pass ? T('통과') : T('미통과')}`); sec.appendChild(sm); box.appendChild(sec); + sec.appendChild(el('p', 'muted small', TF('기준: 번역 누락률 ≤ {1} (시스템 문구 누락 0), 원문 잔존율 ≤ {2}, 사이트 안 링크가 기본 언어와 같은 짝, 언어별 검수 완료. 페이지가 바뀌면 그 언어는 다시 "검수 대기" 가 됩니다.', pct(q.thresholds.maxMissing), pct(q.thresholds.maxResidue)))); + const wrap = el('div', 'table-wrap'); const t = el('table', 'q-table'); wrap.appendChild(t); sec.appendChild(wrap); + const hr = el('tr'); for (const h of ['언어', '누락률', '잔존율', '링크', '검수', '']) hr.appendChild(el('th', '', T(h))); t.appendChild(hr); + for (const [c, r] of Object.entries(q.languages)) { + const tr = el('tr'); tr.appendChild(el('td', '', c + (r.source ? ' · ' + T('기본') : ''))); + if (r.source) { const td0 = el('td', 'muted', T('원문')); td0.colSpan = 3; tr.appendChild(td0); tr.appendChild(el('td', 'ok', T(REVIEW.approved))); tr.appendChild(el('td')); t.appendChild(tr); continue; } + const cell = (ok, text, title) => { const x = el('td', ok ? 'ok' : 'bad', text); if (title) x.title = title; return x; }; + tr.appendChild(cell(r.checks.missing, pct(r.missing.rate) + (r.runtimeMissing ? ` · ${TF('시스템 문구 {1}', r.runtimeMissing)}` : ''), r.missing.samples.join('\n'))); + tr.appendChild(cell(r.checks.residue, pct(r.residue.rate), r.residue.samples.join('\n'))); + tr.appendChild(cell(r.checks.links, r.links.ok ? T('일치') : T('불일치'), [...r.links.issues, ...r.links.missingPages].join('\n'))); + tr.appendChild(cell(r.checks.review, T(REVIEW[r.review] || r.review), r.reviewNote || '')); + const act = el('td'); const go = (state) => async () => { const note = state === 'rejected' ? prompt(T('고칠 점을 적어 주세요 (선택)')) || '' : ''; try { await api(`/api/sites/${site.id}/langs/review`, { method: 'POST', body: JSON.stringify({ lang: c, state, note }) }); loadLangs(site); } catch (e) { alert(e.message); } }; + const a = el('a', 'btn ghost small-btn', T('보기')); a.href = `${site.url.replace(/\/?$/, '/')}${c}/`; a.target = '_blank'; a.rel = 'noopener'; act.appendChild(a); + if (r.review !== 'approved') act.appendChild(btn(T('검수 완료'), 'ghost small-btn', go('approved'))); if (r.review !== 'rejected') act.appendChild(btn(T('수정 필요'), 'ghost small-btn', go('rejected'))); + tr.appendChild(act); t.appendChild(tr); + } + // 로케일별 메타·구조화 데이터 + const mx = d.matrix; if (!mx) return; + const ms = el('details', 'opt-sec'); ms.appendChild(el('summary', '', T('로케일별 메타·구조화 데이터'))); box.appendChild(ms); + ms.appendChild(el('p', 'muted small', mx.baseUrl ? TF('운영 주소 {1} 기준 · x-default: {2}', mx.baseUrl, mx.xDefault === 'root' ? T('언어 선택 페이지') : mx.xDefault) : T('배포 전이라 canonical·hreflang·사이트맵의 절대 주소는 배포할 때 채워집니다.'))); + const w2 = el('div', 'table-wrap'); const t2 = el('table', 'q-table'); w2.appendChild(t2); ms.appendChild(w2); + const h2 = el('tr'); for (const h of ['언어', '페이지', 'title', 'description', 'canonical', 'og:locale', 'hreflang', 'sitemap']) h2.appendChild(el('th', '', T(h))); t2.appendChild(h2); + const yn = (v) => (v === null ? '—' : v ? '✓' : '✗'); + for (const l of mx.languages) for (const pg of l.pages) { + const tr = el('tr'); tr.appendChild(el('td', '', `${l.lang}${pg.file === 'index.html' ? ` · ${l.schema || T('사업체 정보 없음')}${l.geo === 'default' ? ' (' + T('기본 언어 값') + ')' : ''}` : ''}`)); tr.appendChild(el('td', '', pg.file)); + if (pg.missing) { const x = el('td', 'bad', T('페이지 없음')); x.colSpan = 6; tr.appendChild(x); t2.appendChild(tr); continue; } + const tdt = el('td', pg.title ? '' : 'bad', pg.title || '✗'); tdt.lang = l.hreflang; tr.appendChild(tdt); const tdd = el('td', pg.description ? '' : 'bad', pg.description ? pg.description.slice(0, 60) : '✗'); tdd.lang = l.hreflang; tr.appendChild(tdd); + tr.appendChild(el('td', pg.canonicalOk === false ? 'bad' : '', yn(pg.canonicalOk))); tr.appendChild(el('td', pg.localeOk ? 'ok' : 'bad', pg.ogLocale || '✗')); tr.appendChild(el('td', '', String(pg.hreflang))); tr.appendChild(el('td', pg.inSitemap === false ? 'bad' : '', yn(pg.inSitemap))); + t2.appendChild(tr); + } + } + + /** 사이트를 열거나 작업이 끝났을 때: 생성이 끝난 사이트에만 카드를 보인다 */ + function onSiteOpened(site) { + const ready = !!(site && (!site.plan || ['generated', 'published'].includes(site.plan.stage)) && (site.files || []).some((f) => /\.html?$/i.test(f))); + $('optimizeCard').classList.toggle('hidden', !ready); $('searchCard').classList.toggle('hidden', !ready); $('langCard').classList.toggle('hidden', !ready); + if (!ready) { siteId = null; return; } + siteId = site.id; loadOptimize(); loadSearch(); loadLangs(site); + } + window.OptimizeUI = { onSiteOpened }; +})(); diff --git a/modules/webmaker/public/pwrule.js b/modules/webmaker/public/pwrule.js new file mode 100644 index 0000000..94f854b --- /dev/null +++ b/modules/webmaker/public/pwrule.js @@ -0,0 +1,23 @@ +'use strict'; +/* ============================================================================ + * 비밀번호 규칙 안내 (v1.3.17) + * 화면의 "비밀번호(6자 이상)" 같은 안내 문구를 관리자 콘솔에서 정한 지금 규칙으로 바꿔 보인다. + * 대상: data-pw-rule 속성이 있는 입력칸(placeholder 의 괄호 안 규칙)과 라벨. 규칙 검사는 서버가 한다. + * data-pw-rule="placeholder" — placeholder 를 "<원래 앞말> (<규칙>)" 으로 + * data-pw-rule="label" — 라벨 글자의 "(…)" 부분을 규칙으로 + * 전역: window.pwRuleText (규칙 문장, 불러오기 전에는 null), window.applyPwRule(root) + * ========================================================================== */ +(function () { + window.pwRuleText = null; + var tr = function (s) { return typeof window.T === 'function' ? window.T(s) : s; }; + function apply(root) { + if (!window.pwRuleText) return; + (root || document).querySelectorAll('[data-pw-rule]').forEach(function (el) { + var mode = el.getAttribute('data-pw-rule'); + if (mode === 'placeholder') { var head = (el.getAttribute('data-pw-head') || ''); el.placeholder = head ? head + ' (' + window.pwRuleText + ')' : window.pwRuleText; } + else if (mode === 'label') { var n = el.firstChild; if (n && n.nodeType === 3) n.nodeValue = n.nodeValue.replace(/\([^)]*\)/, '(' + window.pwRuleText + ')'); } + }); + } + window.applyPwRule = apply; + fetch('/api/auth/password-policy', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { if (d && d.text) { window.pwRuleText = d.text; apply(); } }).catch(function () {}); +})(); diff --git a/modules/webmaker/public/setup.html b/modules/webmaker/public/setup.html new file mode 100644 index 0000000..b53d200 --- /dev/null +++ b/modules/webmaker/public/setup.html @@ -0,0 +1,48 @@ + + + + + + +초기 설정 — AI Webmaker + + + + + + +
    +

    AI Webmaker 초기 설정

    + + 관리자 콘솔 + +
    + +
    + + + + + diff --git a/modules/webmaker/public/setup.js b/modules/webmaker/public/setup.js new file mode 100644 index 0000000..67b88d1 --- /dev/null +++ b/modules/webmaker/public/setup.js @@ -0,0 +1,297 @@ +'use strict'; +/** + * AI Webmaker 초기 설정 화면 (v1.5.7, 요구 2·7) + * + * 설정 파일(lib/config.js)의 항목표를 받아 그룹별 카드로 그리고, 바뀐 값만 저장한다. 두 곳에서 같은 코드를 쓴다. + * - 전용 설정 페이지 (setup-server.js, 포트 3081): 설치 직후 토큰 또는 관리자 로그인으로 들어온다. API /api/setup/* + * - 관리자 콘솔 › 시스템 설정 탭 (admin.html): 로그인한 관리자. API /api/admin/config/* + * 어느 쪽이든 WMSetup.mount(컨테이너, { apiBase }) 로 붙인다. 화면 문구는 한국어 원문이며 i18n 런타임이 번역한다. + * + * 화면 구성 + * 0) (v1.5.11, 요구 1·2) 설치 점검표: 관리자 비밀번호 · AI 엔진 인증 · 공개 주소 — 하나라도 안 되면 서비스 첫 화면이 이 페이지로 넘어온다. + * 바로 아래에 "관리자 계정"(최초 관리자 만들기 · 비밀번호 재설정 · 새 관리자 — v1.6.31 고정 비밀번호 없음) 과 "AI 엔진 인증"(Claude 서버 로그인/OAuth 토큰/API 키, OpenAI) 카드 + * 1) 상태 카드: 웹메이커 서비스 · 설정 페이지 서비스 · Gitea(소스 보관) · Arimail(메일) · Claude · Codex · Resend — 연결 확인 버튼 + * 2) 설정 그룹 카드: 항목마다 입력칸 + 출처(파일/환경 변수/기본값) 배지 + 설명. 비밀값은 저장 여부·힌트만 보이고 새 값을 넣으면 바뀐다 + * 3) 설치 안내: 연동 서비스 설치 스크립트 명령 (현재 값으로 채운 예시) + * 저장 → PUT { values }. 재시작이 필요한 항목이 바뀌면 안내와 함께 "서비스 재시작" 버튼을 강조한다. + * 열리는 곳 (v1.5.11): 별도 포트(standalone, 기본 8795) · 본 서비스 /setup(inline — 토큰 또는 관리자 로그인) · 관리자 콘솔 탭. 앞 둘은 API /api/setup, 탭은 /api/admin/config + */ +(function () { + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const SOURCE = { file: '설정 파일', env: '환경 변수', default: '기본값', vault: '보관소' }; + const CSS = ` +.wms { font-size: 14px; } +.wms .wms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; margin: 10px 0 16px; } +.wms .wms-stat { border: 1px solid var(--line, #e3e6ea); border-radius: 10px; padding: 10px 12px; background: var(--panel, #fff); } +.wms .wms-stat h3 { margin: 0 0 4px; font-size: 14px; display: flex; align-items: center; gap: 8px; } +.wms .wms-stat .d { font-size: 12px; color: var(--muted, #6b7684); word-break: break-all; } +.wms .wms-stat .act { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; } +.wms .pill { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; background: #eef1f5; color: #475569; } +.wms .pill.ok { background: #d9f5e6; color: #0f7a46; } .wms .pill.bad { background: #fde2e2; color: #b42318; } .wms .pill.warn { background: #fff1d6; color: #9a5b00; } +.wms .wms-group { border: 1px solid var(--line, #e3e6ea); border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; background: var(--panel, #fff); } +.wms .wms-group h2 { margin: 0 0 4px; font-size: 16px; display: flex; align-items: center; gap: 8px; } +.wms .wms-group .gd { color: var(--muted, #6b7684); font-size: 12px; margin-bottom: 8px; } +.wms .wms-field { display: grid; grid-template-columns: minmax(160px, 220px) 1fr; gap: 6px 12px; padding: 8px 0; border-top: 1px dashed var(--line, #e3e6ea); align-items: start; } +.wms .wms-field label { font-weight: 600; font-size: 13px; padding-top: 6px; } +.wms .wms-field label .env { display: block; font-weight: 400; font-size: 11px; color: var(--muted, #6b7684); font-family: ui-monospace, monospace; } +.wms .wms-field input[type=text], .wms .wms-field input[type=password], .wms .wms-field input[type=number], .wms .wms-field select { width: 100%; max-width: 640px; margin: 0; box-sizing: border-box; } +.wms .wms-field input.dirty, .wms .wms-field select.dirty { border-color: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,.15); } +.wms .wms-field .meta { font-size: 11px; color: var(--muted, #6b7684); margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.wms .wms-field .desc { font-size: 12px; color: var(--muted, #6b7684); margin-top: 3px; line-height: 1.45; } +.wms .wms-field.ro input { background: #f6f7f9; color: #475569; } +.wms .wms-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 8px 0; } +.wms .wms-msg { font-size: 12px; min-height: 16px; } +.wms .wms-msg.ok { color: #0f7a46; } .wms .wms-msg.bad { color: #b42318; } +.wms details.wms-adv summary { cursor: pointer; color: var(--muted, #6b7684); font-size: 13px; } +.wms code.cmd { display: block; background: #0f172a; color: #e2e8f0; padding: 8px 10px; border-radius: 8px; font-size: 12px; overflow: auto; white-space: pre; margin: 4px 0 8px; } +.wms .wms-secret { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.wms .wms-secret input { flex: 1; min-width: 200px; } +.wms .req { color: #b42318; } +.wms .wms-head { display: flex; gap: 14px; flex-wrap: wrap; align-items: baseline; margin-bottom: 6px; } +.wms .wms-head .muted { font-size: 12px; } +/* v1.5.16 (요구 1): 점검표 카드는 밝은 배경(호박색·연두색)을 쓰는데 글자 색을 정하지 않아 관리자 콘솔(어두운 테마: 글자 #e6ebf5, 제목 #ffc457)에서는 + 글자가 배경과 거의 같은 밝기라 읽을 수 없었다. 배경의 보색 계열로 글자 색을 명시한다 — 호박색 배경 ↔ 짙은 파랑, 연두 배경 ↔ 짙은 자주 (어느 테마에서든 같은 색) */ +.wms .wms-check { border: 2px solid #f59e0b; border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; background: #fffbeb; color: #1e3a8a; } +.wms .wms-check h2, .wms .wms-check b { color: #1e3a8a; } +.wms .wms-check .gd { color: #1e40af; } +.wms .wms-check.done { border-color: #10b981; background: #ecfdf5; color: #701a75; } +.wms .wms-check.done h2, .wms .wms-check.done b { color: #86198f; } +.wms .wms-check.done .gd, .wms .wms-check.done li.warn { color: #86198f; } +.wms .wms-check h2 { margin: 0 0 6px; font-size: 16px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.wms .wms-check ul { margin: 6px 0 0; padding-left: 0; list-style: none; } +.wms .wms-check li { display: grid; grid-template-columns: 84px 1fr; gap: 8px; padding: 5px 0; border-top: 1px dashed rgba(30,58,138,.18); font-size: 13px; align-items: start; } +.wms .wms-check li .h { color: #1d4ed8; font-size: 12px; margin-top: 2px; } +.wms .wms-check li.warn { color: #1e40af; font-size: 12px; } +.wms .wms-check li.warn b { color: #1e40af; } +.wms .wms-check .pill { color: #1e293b; } .wms .wms-check .pill.ok { color: #0f7a46; } .wms .wms-check .pill.bad { color: #b42318; } .wms .wms-check .pill.warn { color: #9a5b00; } +.wms .wms-inline { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 6px 0; } +.wms .wms-inline input, .wms .wms-inline select { margin: 0; min-width: 160px; } +.wms table.wms-admins { border-collapse: collapse; font-size: 13px; margin: 6px 0; } +.wms table.wms-admins td, .wms table.wms-admins th { border-bottom: 1px solid var(--line, #e3e6ea); padding: 4px 10px 4px 0; text-align: left; } +.wms .wms-radio { display: flex; gap: 14px; flex-wrap: wrap; margin: 6px 0; font-size: 13px; } +.wms .wms-sub { border-top: 1px dashed var(--line, #e3e6ea); padding-top: 8px; margin-top: 8px; } +`; + function ensureStyle() { if (document.getElementById('wms-style')) return; const s = document.createElement('style'); s.id = 'wms-style'; s.textContent = CSS; document.head.appendChild(s); } + function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } + function btn(label, cls, fn) { const b = el('button', cls || 'ghost', label); b.type = 'button'; b.onclick = fn; return b; } + + /** + * 설정 화면을 컨테이너에 붙인다. + * @param {HTMLElement} root + * @param {{apiBase:string, standalone?:boolean, onAuthFail?:Function}} opts + */ + function mount(root, opts) { + ensureStyle(); root.classList.add('wms'); + let csrfTok = ''; const getCsrf = async () => { if (csrfTok) return csrfTok; try { const r = await fetch(opts.apiBase.replace(/\/api\/.*$/, '') + (opts.apiBase.includes('/api/setup') ? '/api/setup/session' : '/api/auth/csrf')); const b = await r.json(); csrfTok = b.csrf || ''; } catch (_) {} return csrfTok; }; // v1.6.31 CSRF + const api = async (path, init) => { + const tok = await getCsrf(); const r = await fetch(opts.apiBase + path, { headers: { 'Content-Type': 'application/json', ...(tok ? { 'X-WM-CSRF': tok } : {}) }, ...(init || {}) }); + const b = await r.json().catch(() => ({})); + if (r.status === 401 && opts.onAuthFail) { opts.onAuthFail(); throw new Error('로그인이 필요합니다'); } + if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`); + return b; + }; + let view = null; const inputs = new Map(); // key → { def, get(): 값 또는 undefined(변경 없음), el } + const msg = el('div', 'wms-msg'); const setMsg = (t, ok) => { msg.textContent = t || ''; msg.className = 'wms-msg ' + (t ? (ok ? 'ok' : 'bad') : ''); }; + + async function load() { root.innerHTML = '

    설정을 불러오는 중…

    '; try { view = await api(''); render(); } catch (e) { root.innerHTML = ''; root.appendChild(el('p', 'muted', '불러오기 실패: ' + e.message)); } } + + function pill(ok, labelOk, labelBad) { return el('span', 'pill ' + (ok === true ? 'ok' : ok === false ? 'bad' : 'warn'), ok === true ? (labelOk || '정상') : ok === false ? (labelBad || '확인 필요') : '알 수 없음'); } + /** 상태 카드 하나 */ + function statCard(title, st, extra) { + const c = el('div', 'wms-stat'); const h = el('h3'); h.appendChild(document.createTextNode(title)); h.appendChild(pill(st ? !!st.ok : null)); c.appendChild(h); + c.appendChild(el('div', 'd', st ? (st.detail || '') : '상태 없음')); + const act = el('div', 'act'); (extra || []).forEach((b) => b && act.appendChild(b)); if (act.childNodes.length) c.appendChild(act); + return c; + } + const testBtn = (kind, card) => btn('연결 확인', 'ghost', async () => { const d = card.querySelector('.d'); d.textContent = '확인 중…'; try { const r = await api(`/test/${kind}`, { method: 'POST', body: '{}' }); d.textContent = r.detail || (r.ok ? '정상' : '실패'); card.querySelector('.pill').replaceWith(pill(!!r.ok)); } catch (e) { d.textContent = '확인 실패: ' + e.message; } }); + const provBtn = (label, kind, action, card) => btn(label, 'ghost', async () => { const d = card.querySelector('.d'); d.textContent = '실행 중…'; try { const r = await api(`/provision/${kind}/${action}`, { method: 'POST', body: '{}' }); d.textContent = r.detail; } catch (e) { d.textContent = '실패: ' + e.message; } }); + const linkBtn = (label, href) => { if (!href) return null; const a = el('a', 'btn ghost', label); a.href = href; a.target = '_blank'; a.rel = 'noopener'; return a; }; + + function render() { + root.innerHTML = ''; inputs.clear(); + const v = view; const s = v.status || {}; const cfg = v.config; + // ---- 머리말: 서버·파일 정보 ---- + const head = el('div', 'wms-head'); + head.appendChild(el('b', '', `AI Webmaker v${v.version}`)); + head.appendChild(el('span', 'muted', `서버 ${v.server.hostname} (${(v.server.ips || []).join(', ') || '-'}) · 계정 ${v.server.user} · Node ${v.server.node}`)); + head.appendChild(el('span', 'muted', `설정 파일 ${cfg.file}${cfg.exists ? (cfg.updatedAt ? ` · 저장 ${new Date(cfg.updatedAt).toLocaleString()} (${cfg.updatedBy || ''})` : '') : ' · 아직 없음 (저장하면 만들어짐)'}`)); + if (cfg.broken) head.appendChild(el('span', 'wms-msg bad', `설정 파일을 읽을 수 없습니다: ${cfg.broken}`)); + root.appendChild(head); + // ---- v1.5.11: 설치 점검표 · 관리자 계정 · AI 엔진 인증 ---- + if (v.ready) root.appendChild(checklistCard(v)); + if (v.admins) root.appendChild(adminCard(v)); + if (v.aiAuth) root.appendChild(aiCard(v)); + // ---- 상태 카드 ---- + const grid = el('div', 'wms-grid'); + { const c = statCard('웹메이커 서비스', s.service ? { ok: s.service.active, detail: s.service.detail } : null); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(btn('서비스 재시작', 'ghost', () => restart())); grid.appendChild(c); } + if (s.setup) grid.appendChild(statCard('초기 설정 페이지 서비스 (webmaker-setup)', { ok: s.setup.active, detail: s.setup.detail })); + { const c = statCard('소스 보관 — ' + (s.source && s.source.provider !== 'none' ? 'Gitea' : '연동 안 함'), s.source); const acts = [testBtn('source', c)]; if (s.source && s.source.apiUrl && s.source.api && !s.source.api.skipped) { acts.push(provBtn('저장소 만들기', 'source', 'ensure-repo', c)); acts.push(provBtn('공개키 등록', 'source', 'register-key', c)); } acts.push(linkBtn('Gitea 열기', s.source && s.source.web)); c.querySelector('.act') || c.appendChild(el('div', 'act')); acts.filter(Boolean).forEach((b) => c.querySelector('.act').appendChild(b)); if (s.source && s.source.publicKey) { const ta = el('textarea'); ta.readOnly = true; ta.rows = 2; ta.style.cssText = 'width:100%;font-size:11px;font-family:ui-monospace,monospace;margin-top:6px'; ta.value = `이 서버의 공개키 (${s.source.keyFile || ''}):\n${s.source.publicKey}`; c.appendChild(ta); } grid.appendChild(c); } + { const c = statCard('메일 서버 — ' + (s.mail && s.mail.provider !== 'none' ? 'Arimail' : '연동 안 함'), s.mail); c.querySelector('.act') || c.appendChild(el('div', 'act')); [testBtn('mail', c), linkBtn('웹메일', s.mail && s.mail.webmail), linkBtn('Arimail 관리자 페이지', s.mail && s.mail.admin)].filter(Boolean).forEach((b) => c.querySelector('.act').appendChild(b)); grid.appendChild(c); } + { const c = statCard('Claude Code', s.claude); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(testBtn('claude', c)); grid.appendChild(c); } + { const c = statCard('OpenAI Codex (선택)', s.codex); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(testBtn('codex', c)); grid.appendChild(c); } + { const c = statCard('메일 발송 (Resend)', s.resend); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(testBtn('resend', c)); grid.appendChild(c); } + root.appendChild(grid); + // ---- 저장 막대 ---- + const bar = el('div', 'wms-bar'); + bar.appendChild(btn('바뀐 값 모두 저장', 'primary', () => saveAll())); + bar.appendChild(btn('서비스 재시작', 'ghost', () => restart())); + bar.appendChild(btn('환경 변수에서 옮겨 적기', 'ghost', async () => { try { const r = await api('/migrate-env', { method: 'POST', body: '{}' }); setMsg(r.detail, true); await load(); } catch (e) { setMsg(e.message, false); } })); + bar.appendChild(btn('새로고침', 'ghost', () => load())); + bar.appendChild(msg); root.appendChild(bar); + // ---- 설정 그룹 ---- + const advanced = []; + for (const g of cfg.groups) { const card = groupCard(g); if (g.advanced) advanced.push(card); else root.appendChild(card); } + if (advanced.length) { const d = el('details', 'wms-adv'); d.appendChild(el('summary', '', '고급 설정 보기 (외부 API 주소)')); advanced.forEach((c) => d.appendChild(c)); root.appendChild(d); } + // ---- 설치 안내 ---- + const inst = el('div', 'wms-group'); inst.appendChild(el('h2', '', '연동 서비스 설치·등록 명령')); + inst.appendChild(el('div', 'gd', '서비스 서버에서 서비스 실행 계정으로 실행합니다. 이미 설치되어 있으면 등록(연동 정보 저장)만 합니다. 실행 뒤 이 화면을 새로고침하면 상태가 바뀝니다.')); + const cmd = (label, text) => { inst.appendChild(el('div', '', label)); const c = el('code', 'cmd', text); inst.appendChild(c); }; + cmd('Gitea (사이트 소스 보관) 설치·등록', v.install.gitea); + cmd('Arimail (메일 서버) 새로 설치·등록', v.install.arimail); + cmd('이미 설치된 Arimail 등록만', v.install.arimailRegister); + cmd('설치된 연동 서비스 점검·자동 등록', v.install.services); + cmd('명령줄로 설정 보기·바꾸기', v.install.cli + '\nnode scripts/config.js set service.publicBaseUrl https://webmaker.example.com'); + root.appendChild(inst); + } + + /** v1.5.11 설치 점검표 카드: 필수 항목(관리자·AI 인증·공개 주소)과 참고 항목. 끝나면 서비스로 가는 링크 */ + function checklistCard(v) { + const r = v.ready; const c = el('div', 'wms-check' + (r.required ? '' : ' done')); + const h = el('h2'); h.appendChild(document.createTextNode(r.required ? '설치 점검표 — 아직 운영할 수 없습니다' : '설치 점검표 — 운영할 수 있습니다')); + h.appendChild(el('span', 'pill ' + (r.required ? 'warn' : 'ok'), r.required ? `${r.items.filter((i) => !i.ok).length}개 남음` : '완료')); c.appendChild(h); + c.appendChild(el('div', 'gd', r.required ? '아래 항목이 모두 끝날 때까지 서비스 첫 화면(/, /login)은 이 페이지로 넘어옵니다. 끝나면 자동으로 정상 로그인 화면이 나옵니다. (점검 전에 로그인 화면을 열려면 /login?direct=1)' : '서비스 첫 화면은 더 이상 이 페이지로 넘어오지 않습니다. 설정은 관리자 콘솔 › 시스템 설정 탭이나 이 페이지에서 계속 바꿀 수 있습니다.')); + const ul = el('ul'); + for (const it of r.items) { const li = el('li'); li.appendChild(pill(it.ok, '완료', '필요')); const d = el('div'); d.appendChild(el('b', '', it.label + ' — ')); d.appendChild(document.createTextNode(it.detail || '')); if (!it.ok && it.hint) d.appendChild(el('div', 'h', '→ ' + it.hint)); li.appendChild(d); ul.appendChild(li); } + for (const w of r.warnings || []) { const li = el('li', 'warn'); li.appendChild(el('span', 'pill' + (w.ok ? ' ok' : ''), w.ok ? '있음' : '참고')); const d = el('div'); d.appendChild(el('b', '', w.label + ' (참고) — ')); d.appendChild(document.createTextNode(w.detail || '')); li.appendChild(d); ul.appendChild(li); } + c.appendChild(ul); + const bar = el('div', 'wms-bar'); bar.appendChild(btn('다시 점검', 'ghost', () => load())); + const home = opts.standalone ? (v.server.publicBaseUrl || '') + '/' : '/'; + if (!r.required) { const a = el('a', 'btn primary', '서비스 열기 (로그인 화면)'); a.href = home + 'login?direct=1'; bar.appendChild(a); } + else { const a = el('a', 'btn ghost', '로그인 화면 열기 (점검 전)'); a.href = home + 'login?direct=1'; bar.appendChild(a); } + c.appendChild(bar); return c; + } + /** v1.5.11 관리자 계정 카드: 목록 · 기본 관리자 만들기 · 비밀번호 재설정 · 새 관리자 */ + function adminCard(v) { + const st = v.admins; const card = el('div', 'wms-group'); card.appendChild(el('h2', '', '관리자 계정')); + card.appendChild(el('div', 'gd', '처음 설치한 서버에는 관리자 계정이 없습니다(v1.6.31: 고정 비밀번호 admin/admin 을 만들지 않습니다). 이 화면(접속 토큰)에서 아이디·비밀번호를 정해 최초 관리자를 만드세요. 설치 점검이 끝나면 접속 토큰은 더 쓸 수 없고 관리자 로그인으로만 들어옵니다.')); + const m = el('span', 'wms-msg'); + if (st.admins.length) { const t = el('table', 'wms-admins'); const tr = el('tr'); ['아이디', '이름', '마지막 로그인', '상태'].forEach((x) => tr.appendChild(el('th', '', x))); t.appendChild(tr); for (const u of st.admins) { const r = el('tr'); r.appendChild(el('td', '', u.id)); r.appendChild(el('td', '', u.name || '')); r.appendChild(el('td', '', u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '없음')); const td = el('td'); td.appendChild(u.mustChangePassword ? el('span', 'pill warn', '기본 비밀번호 — 변경 필요') : el('span', 'pill ok', '사용 가능')); r.appendChild(td); t.appendChild(r); } card.appendChild(t); } + else card.appendChild(el('p', 'wms-msg bad', '관리자 계정이 없습니다.')); + const act = async (path, body, okNote) => { try { const r = await api(path, { method: 'POST', body: JSON.stringify(body || {}) }); m.textContent = r.detail || okNote; m.className = 'wms-msg ok'; await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } }; + const row1 = el('div', 'wms-inline'); + if (!st.admins.length) row1.appendChild(btn('관리자 admin 을 1회용 비밀번호로 만들기', 'ghost', async () => { try { const r = await api('/admin/bootstrap', { method: 'POST', body: '{}' }); m.textContent = r.password ? `관리자 admin 을 만들었습니다. 1회용 비밀번호(지금만 표시): ${r.password} — 로그인 뒤 바로 바꾸세요` : (r.detail || '이미 있습니다'); m.className = 'wms-msg ok'; await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } })); + row1.appendChild(m); card.appendChild(row1); + // 비밀번호 재설정 + const sub1 = el('div', 'wms-sub'); sub1.appendChild(el('b', '', '관리자 비밀번호 재설정')); const r1 = el('div', 'wms-inline'); + const sel = el('select'); for (const u of st.admins) { const o = el('option', '', u.id); o.value = u.id; sel.appendChild(o); } const pw1 = el('input'); pw1.type = 'password'; pw1.placeholder = '새 비밀번호 (6자 이상)'; pw1.autocomplete = 'new-password'; + r1.appendChild(sel); r1.appendChild(pw1); r1.appendChild(btn('재설정', 'ghost', () => act('/admin/password', { id: sel.value, password: pw1.value }))); sub1.appendChild(r1); if (st.admins.length) card.appendChild(sub1); + // 새 관리자 + const sub2 = el('div', 'wms-sub'); sub2.appendChild(el('b', '', '새 관리자 만들기')); const r2 = el('div', 'wms-inline'); + const id2 = el('input'); id2.placeholder = '아이디 (영문 소문자·숫자 3~32자)'; id2.autocomplete = 'off'; const nm2 = el('input'); nm2.placeholder = '이름 (회사명)'; const pw2 = el('input'); pw2.type = 'password'; pw2.placeholder = '비밀번호 (6자 이상)'; pw2.autocomplete = 'new-password'; + r2.appendChild(id2); r2.appendChild(nm2); r2.appendChild(pw2); r2.appendChild(btn('만들기', 'ghost', () => act('/admin/create', { id: id2.value, name: nm2.value, password: pw2.value }))); sub2.appendChild(r2); card.appendChild(sub2); + return card; + } + /** v1.5.11 AI 엔진 인증 카드: Claude(서버 로그인 · OAuth 토큰 · API 키) 와 OpenAI(API 키 · codex 서버 로그인) */ + function aiCard(v) { + const a = v.aiAuth; const s = v.status || {}; const card = el('div', 'wms-group'); card.appendChild(el('h2', '', 'AI 엔진 인증')); + card.appendChild(el('div', 'gd', 'AI 작업(사이트 생성·수정)에는 Claude Code 인증이 필요합니다. 서버 로그인은 서비스 실행 계정으로 서버에서 claude 를 한 번 실행해 브라우저로 로그인하는 방식이고, 서버에 들어가기 어려우면 OAuth 토큰(다른 PC 에서 claude setup-token)이나 API 키를 여기에 넣습니다. 관리자 콘솔 › AI 연결 탭의 "Claude 모델 연결" 과 같은 값입니다(v1.5.16).')); + const m = el('span', 'wms-msg'); + // Claude + const c1 = el('div'); c1.appendChild(el('b', '', 'Claude Code')); c1.appendChild(el('span', 'muted', ' — ' + (s.claude ? s.claude.detail : ''))); + const modes = [['server-login', '서버 로그인 (claude 실행 후 브라우저 로그인)'], ['oauth-token', 'OAuth 토큰 (claude setup-token 으로 발급)'], ['api-key', 'Anthropic API 키 (sk-ant-…)']]; + const rad = el('div', 'wms-radio'); const cur = (a.claude || {}).mode || 'server-login'; let mode = cur; + const tok = el('input'); tok.type = 'password'; tok.autocomplete = 'new-password'; tok.placeholder = a.claude && a.claude.hasOauthToken ? '저장된 OAuth 토큰 있음 — 바꾸려면 새 값' : 'OAuth 토큰 (sk-ant-oat…)'; const key = el('input'); key.type = 'password'; key.autocomplete = 'new-password'; key.placeholder = a.claude && a.claude.hasApiKey ? '저장된 API 키 있음 — 바꾸려면 새 값' : 'API 키 (sk-ant-api…)'; + const show = () => { tok.classList.toggle('hidden', mode !== 'oauth-token'); key.classList.toggle('hidden', mode !== 'api-key'); }; + for (const [val, label] of modes) { const l = el('label', 'radio'); const r = el('input'); r.type = 'radio'; r.name = 'wmsClaudeMode'; r.value = val; r.checked = val === cur; r.onchange = () => { mode = val; show(); }; l.appendChild(r); l.appendChild(document.createTextNode(' ' + label)); rad.appendChild(l); } + c1.appendChild(rad); const r1 = el('div', 'wms-inline'); r1.appendChild(tok); r1.appendChild(key); show(); + r1.appendChild(btn('Claude 인증 저장·확인', 'primary', async () => { m.textContent = '확인 중…'; m.className = 'wms-msg'; try { const r = await api('/ai/claude', { method: 'PUT', body: JSON.stringify({ mode, oauthToken: tok.value, apiKey: key.value }) }); m.textContent = r.detail; m.className = 'wms-msg ' + (r.ok ? 'ok' : 'bad'); await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } })); + c1.appendChild(r1); card.appendChild(c1); + // OpenAI (선택) + const o = a.openai; const c2 = el('div', 'wms-sub'); c2.appendChild(el('b', '', 'OpenAI (선택)')); c2.appendChild(el('span', 'muted', ' — ' + (s.codex ? s.codex.detail : ''))); + const en = el('label', 'radio'); const cb = el('input'); cb.type = 'checkbox'; cb.checked = !!(o && o.enabled); en.appendChild(cb); en.appendChild(document.createTextNode(' OpenAI 제공자 켬')); + const rad2 = el('div', 'wms-radio'); let am = (o && o.authMode) || 'api-key'; const okey = el('input'); okey.type = 'password'; okey.autocomplete = 'new-password'; okey.placeholder = o && o.hasKey ? `저장된 키 ${o.apiKeyMasked || ''} — 바꾸려면 새 값` : 'OpenAI API 키 (sk-…)'; + for (const [val, label] of [['api-key', 'API 키'], ['server-login', '서버 로그인 (codex login)']]) { const l = el('label', 'radio'); const r = el('input'); r.type = 'radio'; r.name = 'wmsOpenaiMode'; r.value = val; r.checked = val === am; r.onchange = () => { am = val; okey.classList.toggle('hidden', am !== 'api-key'); }; l.appendChild(r); l.appendChild(document.createTextNode(' ' + label)); rad2.appendChild(l); } + okey.classList.toggle('hidden', am !== 'api-key'); + const r2 = el('div', 'wms-inline'); r2.appendChild(en); r2.appendChild(rad2); r2.appendChild(okey); + r2.appendChild(btn('OpenAI 저장', 'ghost', async () => { try { const r = await api('/ai/openai', { method: 'PUT', body: JSON.stringify({ enabled: cb.checked, authMode: am, apiKey: okey.value }) }); m.textContent = r.detail; m.className = 'wms-msg ' + (r.ok ? 'ok' : 'bad'); await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } })); + c2.appendChild(r2); card.appendChild(c2); + const bar = el('div', 'wms-bar'); bar.appendChild(m); card.appendChild(bar); + return card; + } + /** 그룹 카드: 항목 입력칸 */ + function groupCard(g) { + const card = el('div', 'wms-group'); const h = el('h2', '', g.label); card.appendChild(h); card.appendChild(el('div', 'gd', g.desc)); + const ro = g.keys.every((k) => k.deployTime); + for (const k of g.keys) card.appendChild(fieldRow(k)); + if (!ro) { const bar = el('div', 'wms-bar'); const m = el('span', 'wms-msg'); bar.appendChild(btn('이 묶음 저장', 'primary', () => saveKeys(g.keys.map((k) => k.key), m))); bar.appendChild(m); card.appendChild(bar); } + return card; + } + /** 항목 한 줄 */ + function fieldRow(k) { + const row = el('div', 'wms-field' + (k.deployTime ? ' ro' : '')); const lab = el('label'); lab.appendChild(document.createTextNode(k.label)); if (k.required) lab.appendChild(el('span', 'req', ' *')); if (k.env) lab.appendChild(el('span', 'env', k.env)); row.appendChild(lab); + const box = el('div'); let get; + const markDirty = (inp, dirty) => inp.classList.toggle('dirty', dirty); + if (k.type === 'secret') { + const wrap = el('div', 'wms-secret'); const inp = el('input'); inp.type = 'password'; inp.autocomplete = 'new-password'; inp.placeholder = k.hasSecret ? `저장됨 ${k.secretHint} — 바꾸려면 새 값 입력` : '아직 없음 — 값을 입력해 저장'; wrap.appendChild(inp); + const clr = el('label', 'radio'); const cb = el('input'); cb.type = 'checkbox'; clr.appendChild(cb); clr.appendChild(document.createTextNode(' 지우기')); if (k.hasSecret) wrap.appendChild(clr); + inp.oninput = () => markDirty(inp, !!inp.value); cb.onchange = () => markDirty(inp, cb.checked || !!inp.value); + get = () => (cb.checked ? null : (inp.value ? inp.value : undefined)); box.appendChild(wrap); + inputs.set(k.key, { def: k, get, el: inp }); + } else if (k.type === 'bool') { + const lab2 = el('label', 'radio'); const cb = el('input'); cb.type = 'checkbox'; cb.checked = k.value === true || k.value === 'true' || k.value === 1 || k.value === '1'; const init = cb.checked; lab2.appendChild(cb); lab2.appendChild(document.createTextNode(' 켬')); cb.disabled = !!k.deployTime; cb.onchange = () => markDirty(cb, cb.checked !== init); + get = () => (cb.checked !== init ? cb.checked : undefined); box.appendChild(lab2); inputs.set(k.key, { def: k, get, el: cb }); + } else if (k.type === 'enum') { + const sel = el('select'); for (const [val, label] of k.options || []) { const o = el('option', '', label); o.value = val; sel.appendChild(o); } sel.value = k.value || ''; const init = sel.value; sel.disabled = !!k.deployTime; sel.onchange = () => markDirty(sel, sel.value !== init); + get = () => (sel.value !== init ? sel.value : undefined); box.appendChild(sel); inputs.set(k.key, { def: k, get, el: sel }); + } else { + const inp = el('input'); inp.type = k.type === 'int' ? 'number' : 'text'; if (k.type === 'int') { inp.setAttribute('data-plain', '1'); if (k.min !== undefined) inp.min = k.min; if (k.max !== undefined) inp.max = k.max; } + inp.value = k.value === undefined || k.value === null ? '' : String(k.value); const init = inp.value; inp.readOnly = !!k.deployTime; inp.placeholder = k.default !== '' && k.default !== undefined ? `기본값: ${k.default}` : ''; + inp.oninput = () => markDirty(inp, inp.value !== init); + get = () => (inp.value !== init ? inp.value : undefined); box.appendChild(inp); inputs.set(k.key, { def: k, get, el: inp }); + } + const meta = el('div', 'meta'); meta.appendChild(el('span', 'pill ' + (k.source === 'file' || k.source === 'vault' ? 'ok' : k.source === 'env' ? 'warn' : ''), SOURCE[k.source] || k.source)); if (k.restart) meta.appendChild(el('span', 'pill', '재시작 후 적용')); if (k.deployTime) meta.appendChild(el('span', 'pill', '배포 때 정해짐 — 읽기 전용')); if (k.source === 'env' && !k.deployTime) meta.appendChild(el('span', '', '→ 저장하면 설정 파일 값이 환경 변수보다 우선합니다')); + box.appendChild(meta); box.appendChild(el('div', 'desc', k.desc)); row.appendChild(box); return row; + } + /** 바뀐 값 모으기 */ + function collect(keys) { const values = {}; let n = 0; for (const key of keys) { const it = inputs.get(key); if (!it || it.def.deployTime) continue; const v = it.get(); if (v === undefined) continue; values[key] = v; n++; } return { values, n }; } + async function saveKeys(keys, m) { + const { values, n } = collect(keys); const target = m || msg; const set = (t, ok) => { target.textContent = t; target.className = 'wms-msg ' + (ok ? 'ok' : 'bad'); }; + if (!n) return set('바뀐 값이 없습니다', true); + try { const r = await api('', { method: 'PUT', body: JSON.stringify({ values }) }); set(r.note, true); await load(); if (r.restart) setMsg('재시작해야 적용되는 항목이 바뀌었습니다 — 위의 "서비스 재시작" 을 누르세요.', true); } catch (e) { set('저장 실패: ' + e.message, false); } + } + function saveAll() { return saveKeys([...inputs.keys()], null); } + async function restart() { + if (!confirm('웹메이커 서비스를 다시 시작합니다. 실행 중인 AI 작업이 있으면 끝날 때까지(최대 10분) 기다린 뒤 재시작합니다. 계속할까요?')) return; + setMsg('재시작 중… (AI 작업이 있으면 기다립니다)', true); + try { const r = await api('/restart', { method: 'POST', body: '{}' }); setMsg(r.detail, !!r.ok); setTimeout(load, 1500); } catch (e) { setMsg(e.message, false); } + } + load(); + return { reload: load }; + } + + /** + * 전용 설정 페이지(setup.html)의 부팅: 로그인 상태를 확인하고, 없으면 토큰/관리자 로그인 상자를 보인다. + * 관리자 콘솔 안에서는 쓰지 않는다(admin.js 가 mount 만 부른다). + */ + function boot() { + const root = document.getElementById('setupRoot'); const login = document.getElementById('setupLogin'); + // standalone: 별도 포트(setup-server.js) · inline: 본 서비스의 /setup (v1.5.11, 토큰 또는 관리자 로그인) — 둘 다 /api/setup. 그 밖(관리자 콘솔 탭)은 /api/admin/config + const standalone = !!document.querySelector('meta[name="wm-setup-standalone"]'); const inline = !!document.querySelector('meta[name="wm-setup-inline"]'); + const apiBase = standalone || inline ? '/api/setup' : '/api/admin/config'; + const ver = document.getElementById('setupVer'); + const showLogin = (note) => { root.innerHTML = ''; login.classList.remove('hidden'); if (note) document.getElementById('setupLoginMsg').textContent = note; }; + const start = () => { login.classList.add('hidden'); mount(root, { apiBase, standalone, onAuthFail: () => showLogin('세션이 끝났습니다. 다시 로그인하세요.') }); }; + if (!standalone && !inline) return start(); + // 주소의 ?token= 은 설치 스크립트가 알려 준 접속 토큰 — 한 번 쓰고 주소에서 지운다 + const u = new URL(location.href); const tok = u.searchParams.get('token'); + const doLogin = async (body) => { const r = await fetch('/api/setup/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const b = await r.json().catch(() => ({})); if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`); return b; }; + (async () => { + try { const r = await fetch('/api/setup/session'); const b = await r.json().catch(() => ({})); if (ver && b.version) ver.textContent = 'v' + b.version; const ta = document.getElementById('toAdmin'); if (ta && standalone && b.publicBaseUrl) ta.href = b.publicBaseUrl + '/admin'; if (b.loggedIn) return start(); } catch (_) {} + if (tok) { try { await doLogin({ token: tok }); u.searchParams.delete('token'); history.replaceState(null, '', u.toString()); return start(); } catch (e) { showLogin('토큰이 맞지 않습니다: ' + e.message); } } else showLogin(''); + })(); + document.getElementById('setupTokenBtn').onclick = async () => { try { await doLogin({ token: document.getElementById('setupToken').value.trim() }); start(); } catch (e) { document.getElementById('setupLoginMsg').textContent = e.message; } }; + document.getElementById('setupAdminBtn').onclick = async () => { try { await doLogin({ id: document.getElementById('setupId').value.trim(), password: document.getElementById('setupPw').value }); start(); } catch (e) { document.getElementById('setupLoginMsg').textContent = e.message; } }; + const lo = document.getElementById('setupLogout'); if (lo) lo.onclick = async () => { await fetch('/api/setup/logout', { method: 'POST' }); location.reload(); }; + } + window.WMSetup = { mount, boot }; +})(); diff --git a/modules/webmaker/public/site-admin.html b/modules/webmaker/public/site-admin.html new file mode 100644 index 0000000..b735af2 --- /dev/null +++ b/modules/webmaker/public/site-admin.html @@ -0,0 +1,53 @@ + + + + + +사이트 관리자 + + + + +

    사이트 관리자

    + + +

    + + + + diff --git a/modules/webmaker/public/site-admin.js b/modules/webmaker/public/site-admin.js new file mode 100644 index 0000000..bc26cf9 --- /dev/null +++ b/modules/webmaker/public/site-admin.js @@ -0,0 +1,356 @@ +'use strict'; +/* ============================================================================ + * 사이트 관리자 페이지 (v1.0.3) — /site-admin/<사이트ID>/ + * 켜진 모듈(회원·게시판·문의·설문·예약·갤러리·지도·후기·FAQ·블로그·자료실·일정관리)의 데이터와 설정을 관리한다. + * 인증: 빌더 세션 쿠키(소유자/운영자) 가 있으면 그대로, 없으면 사이트 관리자 계정(회원 role=admin)으로 로그인해 토큰(localStorage)으로 호출한다. + * 화면은 lib/modules.js 의 CATALOG(옵션 스키마·표 열) 로 대부분 범용 렌더링하고, 설문 양식 편집기·갤러리 업로드 같은 것만 전용 UI 를 둔다. + * ========================================================================== */ +const $ = (id) => document.getElementById(id); +const SITE = (location.pathname.match(/\/site-admin\/([a-f0-9]{32})/) || [])[1]; +const EMBED = new URLSearchParams(location.search).get('embed') === '1'; +const TOKEN_KEY = 'wm_siteadmin_' + SITE; +let token = ''; try { token = localStorage.getItem(TOKEN_KEY) || ''; } catch (_) {} +let ov = null; let tab = 'home'; +const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +const dt = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''); +async function api(p, opt = {}) { + const headers = opt.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token; + const r = await fetch(`/api/site-admin/${SITE}${p}`, { credentials: 'same-origin', ...opt, headers: { ...headers, ...(opt.headers || {}) } }); + if (r.status === 401) { showLogin(); throw new Error('로그인이 필요합니다'); } + const ct = r.headers.get('content-type') || ''; const body = ct.includes('json') ? await r.json().catch(() => ({})) : await r.blob(); + if (!r.ok) throw new Error((body && body.error) || `HTTP ${r.status}`); + return body; +} +function showLogin() { $('loginBox').classList.remove('hidden'); $('app').classList.add('hidden'); } +$('lGo').onclick = async () => { + $('lMsg').textContent = '확인 중…'; + try { const r = await fetch(`/api/m/${SITE}/members/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: $('lId').value.trim(), password: $('lPw').value }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error || '로그인 실패'); if (j.member.role !== 'admin') throw new Error('사이트 관리자 권한이 없는 계정입니다'); token = j.token; try { localStorage.setItem(TOKEN_KEY, token); } catch (_) {} await boot(); } + catch (e) { $('lMsg').textContent = e.message; $('lMsg').className = 'msg err'; } +}; +$('btnLogout').onclick = () => { token = ''; try { localStorage.removeItem(TOKEN_KEY); } catch (_) {} showLogin(); }; + +// ---------- 공통 렌더 ---------- +const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }; +function btn(label, cls, fn) { const b = el('button', cls, label); b.type = 'button'; b.onclick = fn; return b; } +/** 설정 스키마(옵션) → 입력 폼 */ +function settingsForm(mod, into) { + const cat = ov.catalog.find((c) => c.id === mod); const cur = ov.settings[mod] || {}; const kv = el('div', 'kv'); + for (const o of cat.options || []) { + kv.appendChild(el('label', '', o.label)); let inp; + if (o.type === 'boolean') { inp = el('input'); inp.type = 'checkbox'; inp.checked = !!cur[o.key]; inp.style.width = 'auto'; } + else if (o.type === 'select') { inp = el('select'); for (const [v, l] of o.values) inp.appendChild(new Option(l, v)); inp.value = cur[o.key] == null ? o.def : cur[o.key]; } + else if (o.type === 'list') { inp = el('input'); inp.value = (cur[o.key] || []).join(', '); inp.placeholder = '쉼표로 구분'; } + else if (o.type === 'time') { inp = el('input'); inp.type = 'time'; inp.value = cur[o.key] || o.def; } + else { inp = el('input'); inp.type = o.type === 'number' ? 'number' : 'text'; inp.value = cur[o.key] == null ? '' : cur[o.key]; } + inp.dataset.key = o.key; inp.dataset.type = o.type; kv.appendChild(inp); + } + into.appendChild(kv); + const row = el('div', 'row'); const m = el('span', 'msg'); + row.appendChild(btn('설정 저장', 'primary', async () => { const body = {}; kv.querySelectorAll('[data-key]').forEach((i) => { body[i.dataset.key] = i.dataset.type === 'boolean' ? i.checked : i.value; }); try { ov.settings[mod] = await api(`/settings/${mod}`, { method: 'PUT', body: JSON.stringify(body) }); m.textContent = '저장되었습니다'; m.className = 'msg ok'; } catch (e) { m.textContent = e.message; m.className = 'msg err'; } })); row.appendChild(m); into.appendChild(row); +} +/** 범용 표: 열 정의대로 표시, 수정/삭제 */ +async function table(coll, into, opts = {}) { + const items = await api(`/c/${coll}`); const cols = opts.columns || []; const wrap = el('div', 'table-wrap'); const t = el('table'); const tr = el('tr'); + for (const c of cols) tr.appendChild(el('th', '', LABEL[c] || c)); tr.appendChild(el('th', '', '')); t.appendChild(tr); + for (const it of items) { + const r = el('tr'); + for (const c of cols) { const td = el('td'); let v = it[c]; if (opts.format && opts.format[c]) v = opts.format[c](v, it); /* v1.4.18 format 에 항목 전체도 넘긴다 */ else if (typeof v === 'number' && Number.isFinite(v)) v = v.toLocaleString(uiLocale()); /* v1.4.5 숫자 3자리 쉼표 */ else if (c === 'createdAt' || c === 'at') v = dt(v); else if (typeof v === 'boolean') { const p = el('span', 'pill ' + (v ? 'ok' : 'warn'), v ? (c === 'approved' ? '승인' : c === 'sent' ? '발송' : '예') : (c === 'approved' ? '대기' : c === 'sent' ? '미발송' : '아니오')); td.appendChild(p); r.appendChild(td); continue; } else if (Array.isArray(v)) v = v.join(', '); else if (c === 'status') { const p = el('span', 'pill ' + (v === 'confirmed' ? 'ok' : v === 'cancelled' ? 'off' : 'warn'), STATUS[v] || v); td.appendChild(p); r.appendChild(td); continue; } else if (c === 'role') v = v === 'admin' ? '관리자' : '회원'; else if (c === 'rating') v = '★'.repeat(v || 0); const d = el('div', 'wrap', v == null ? '' : String(v)); td.appendChild(d); r.appendChild(td); } + const act = el('td', 'act'); + for (const a of (opts.actions || [])) act.appendChild(btn(a.label, 'small' + (a.danger ? ' danger' : ''), () => a.fn(it))); + if (opts.edit) act.appendChild(btn('수정', 'small', () => opts.edit(it))); /* v1.4.18 전용 편집기(일정) */ + else if (opts.editable !== false) act.appendChild(btn('수정', 'small', () => editDialog(coll, it, opts))); + act.appendChild(btn('삭제', 'small danger', async () => { if (!confirm('삭제할까요?')) return; await api(`/c/${coll}/${it.id}`, { method: 'DELETE' }); render(); })); + r.appendChild(act); t.appendChild(r); + } + if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '아직 항목이 없습니다'); td.colSpan = cols.length + 1; r.appendChild(td); t.appendChild(r); } + wrap.appendChild(t); into.appendChild(wrap); return items; +} +const LABEL = { when: '날짜·시간', unit: '단위', repeat: '반복', next: '다음 발생', location: '장소', published: '공개', pinned: '고정', summary: '요약', cover: '대표 이미지', description: '설명', originalName: '파일', size: '크기', downloads: '내려받음', mailAddress: '메일 주소', name: '이름', email: '이메일', role: '권한', approved: '승인', createdAt: '등록', title: '제목', author: '글쓴이', category: '분류', secret: '비밀', views: '조회', postId: '글', body: '내용', active: '진행', responses: '응답 수', surveyId: '설문', respondent: '응답자', date: '날짜', time: '시간', contact: '연락처', status: '상태', note: '요청', tags: '태그', src: '이미지', q: '질문', a: '답변', order: '순서', rating: '별점', text: '내용', subject: '제목', sent: '메일', at: '접수', loginId: '아이디', caption: '설명' }; +const STATUS = { pending: '대기', confirmed: '확정', cancelled: '취소' }; +/** 수정/추가 대화상자: 필드 정의에 따라 입력 생성 */ +const FIELDS = { + members: [['name', '이름'], ['email', '이메일'], ['role', '권한', 'select', [['member', '회원'], ['admin', '관리자']]], ['approved', '승인', 'bool']], + posts: [['title', '제목'], ['category', '분류'], ['author', '글쓴이'], ['body', '내용', 'text'], ['secret', '비밀글', 'bool'], ['pinned', '상단 고정', 'bool']], + comments: [['body', '내용', 'text'], ['secret', '비밀 댓글', 'bool']], + bookings: [['date', '날짜', 'date'], ['time', '시간', 'time'], ['name', '이름'], ['contact', '연락처'], ['note', '요청', 'text'], ['status', '상태', 'select', [['pending', '대기'], ['confirmed', '확정'], ['cancelled', '취소']]]], + reviews: [['name', '이름'], ['rating', '별점(1~5)', 'number'], ['category', '분류'], ['text', '내용', 'text'], ['approved', '공개', 'bool']], + faq: [['q', '질문'], ['a', '답변', 'text'], ['category', '분류'], ['order', '순서', 'number']], + gallery: [['src', '이미지 주소'], ['thumb', '썸네일 주소(선택)'], ['title', '제목'], ['caption', '설명'], ['tags', '태그(쉼표)'], ['order', '순서', 'number']], + // v1.4.0 블로그·자료실 + blogPosts: [['title', '제목'], ['summary', '요약(비우면 본문 앞부분)'], ['body', '본문 (빈 줄 = 문단, 주소는 자동 링크)', 'text'], ['cover', '대표 이미지 주소(선택)'], ['category', '분류'], ['tags', '태그(쉼표)'], ['author', '글쓴이'], ['published', '공개', 'bool'], ['pinned', '상단 고정', 'bool']], + blogComments: [['name', '이름'], ['body', '내용', 'text'], ['approved', '공개', 'bool']], + libraryFiles: [['title', '제목'], ['description', '설명', 'text'], ['category', '분류'], ['tags', '태그(쉼표)'], ['published', '공개', 'bool'], ['pinned', '상단 고정', 'bool']], +}; +/** v1.4.0 바이트 → 읽기 쉬운 크기 */ +const fmtSize = (n) => { n = Number(n) || 0; return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : n < 1073741824 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1073741824).toFixed(2)} GB`; }; +function editDialog(coll, item, opts) { + const fields = FIELDS[coll]; if (!fields) return; const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = (item ? '수정' : '추가') + ' — ' + ((opts && opts.label) || coll); $('dlgMsg').textContent = ''; + const inputs = {}; + for (const [k, label, type, values] of fields) { const lab = el('label', '', label); let inp; if (type === 'text') { inp = el('textarea'); inp.rows = 5; inp.value = item ? (item[k] || '') : ''; } else if (type === 'bool') { inp = el('input'); inp.type = 'checkbox'; inp.style.width = 'auto'; inp.checked = item ? !!item[k] : (k === 'approved'); } else if (type === 'select') { inp = el('select'); for (const [v, l] of values) inp.appendChild(new Option(l, v)); if (item) inp.value = item[k]; } else { inp = el('input'); inp.type = type === 'number' ? 'number' : type === 'date' ? 'date' : type === 'time' ? 'time' : 'text'; inp.value = item ? (Array.isArray(item[k]) ? item[k].join(', ') : (item[k] == null ? '' : item[k])) : ''; } inputs[k] = [inp, type]; lab.appendChild(inp); body.appendChild(lab); } + $('dlgSave').onclick = async () => { const data = {}; for (const [k, [inp, type]] of Object.entries(inputs)) data[k] = type === 'bool' ? inp.checked : inp.value; try { if (item) await api(`/c/${coll}/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api(`/c/${coll}`, { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } }; + $('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal(); +} + +// ---------- 화면 ---------- +async function boot() { + try { ov = await api('/overview'); } catch (e) { return; } + $('loginBox').classList.add('hidden'); $('app').classList.remove('hidden'); $('hSite').textContent = ov.site ? ov.site.title : SITE.slice(0, 8); $('hWho').textContent = ov.who || ''; $('btnLogout').hidden = !token; + if (EMBED) document.querySelector('header').style.display = 'none'; + // v1.1.4: 이 페이지의 직접 접근 주소 (빌더 없이 들어오면 사이트 관리자 계정으로 로그인) + let bar = document.getElementById('saUrl'); if (!bar) { bar = document.createElement('div'); bar.id = 'saUrl'; bar.className = 'sa-url'; document.querySelector('header').insertAdjacentElement('afterend', bar); } + const direct = `${location.origin}/site-admin/${SITE}/`; bar.innerHTML = ''; bar.appendChild(Object.assign(document.createElement('span'), { textContent: '관리자 페이지 주소:' })); const code = document.createElement('code'); code.textContent = direct; bar.appendChild(code); + const cp = btn('복사', 'small', async () => { try { await navigator.clipboard.writeText(direct); cp.textContent = '복사됨'; setTimeout(() => (cp.textContent = '복사'), 1500); } catch (_) { prompt('주소', direct); } }); bar.appendChild(cp); + bar.appendChild(btn('새 창으로 열기', 'small', () => window.open(direct, '_blank', 'noopener'))); + bar.appendChild(Object.assign(document.createElement('span'), { className: 'muted', textContent: '— 빌더를 거치지 않고 이 주소로 들어오면 "관리자 계정" 탭에서 만든 사이트 관리자 계정으로 로그인해야 합니다.' })); + renderTabs(); render(); +} +function renderTabs() { + const tabs = $('tabs'); tabs.innerHTML = ''; + const add = (id, label, n) => { const b = btn(label, tab === id ? 'active' : '', () => { tab = id; renderTabs(); render(); }); if (n) { const s = el('span', 'n', `(${n})`); b.appendChild(s); } tabs.appendChild(b); }; + add('home', '모듈 · 설정'); + for (const c of ov.catalog) if (ov.enabled[c.id] && Object.keys(c.collections).length) add(c.id, c.label, c.id === 'booking' ? ov.counts.pendingBookings : c.id === 'reviews' ? ov.counts.pendingReviews : c.id === 'members' ? ov.counts.pendingMembers : null); + if (ov.enabled.inquiry) add('inquiry', '문의', ov.counts.unsentInquiries || null); + if (ov.mail && ov.mail.systemEnabled) add('mail', '메일'); // v1.3.2: 운영자가 이 사이트의 메일 시스템을 켰을 때만 (설정중 포함) + add('account', '관리자 계정'); +} +async function render() { + const m = $('main'); m.innerHTML = ''; + try { + if (tab === 'home') return renderHome(m); + if (tab === 'account') return renderAccount(m); + if (tab === 'mail') return renderMail(m); + if (tab === 'inquiry') return renderInquiries(m); + const cat = ov.catalog.find((c) => c.id === tab); if (!cat) return; + const card = el('div', 'card'); card.appendChild(el('h2', '', cat.label + ' 설정')); settingsForm(tab, card); m.appendChild(card); + for (const [coll, def] of Object.entries(cat.collections)) { + const c2 = el('div', 'card'); const h = el('h2', '', def.label); c2.appendChild(h); + if (coll === 'surveys') { h.appendChild(btn('+ 설문 만들기', 'small primary', () => surveyEditor(null))); await table(coll, c2, { columns: def.columns, editable: false, actions: [{ label: '문항 편집', fn: (it) => surveyEditor(it) }, { label: '응답 보기', fn: (it) => { tab = 'survey'; renderResponses(m, it); } }, { label: 'xlsx 내려받기', fn: (it) => { window.open(`/api/site-admin/${SITE}/export/survey/${it.id}.xlsx${token ? '?token=' + encodeURIComponent(token) : ''}`, '_blank'); } }] }); } + else if (coll === 'responses') { c2.appendChild(el('p', 'muted small', '응답은 설문 표의 "응답 보기"에서 설문별로 확인하고 xlsx 로 내려받을 수 있습니다.')); } + else if (coll === 'gallery') { h.appendChild(btn('+ 이미지 주소로 추가', 'small', () => editDialog(coll, null, def))); const up = el('input'); up.type = 'file'; up.accept = 'image/*'; up.multiple = true; up.style.width = 'auto'; up.onchange = async () => { const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); try { await api('/upload', { method: 'POST', body: fd }); render(); } catch (e) { alert(e.message); } }; const lab = el('label', '', '이미지 업로드(여러 장 가능): '); lab.appendChild(up); c2.appendChild(lab); await table(coll, c2, { columns: def.columns, label: def.label }); } + // v1.4.0 블로그 글: 추가 + 대표 이미지 올리기(올린 이미지 주소를 글의 "대표 이미지 주소" 에 붙여 넣는다) + else if (coll === 'blogPosts') { + h.appendChild(btn('+ 글쓰기', 'small primary', () => editDialog(coll, null, def))); + const up = el('input'); up.type = 'file'; up.accept = 'image/*'; up.style.width = 'auto'; const out = el('span', 'muted small'); + up.onchange = async () => { const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); try { const r = await api('/upload?to=blog', { method: 'POST', body: fd }); out.textContent = r.added.length ? `올린 이미지 주소: ${r.added.map((a) => a.url).join(' , ')} (글의 대표 이미지 주소에 붙여 넣으세요)` : '이미지 파일(15MB 이하)만 올릴 수 있습니다'; } catch (e) { alert(e.message); } }; + const lab = el('label', '', '대표 이미지 올리기: '); lab.appendChild(up); c2.appendChild(lab); c2.appendChild(out); + await table(coll, c2, { columns: def.columns, label: def.label }); + } + else if (coll === 'blogComments') { let titles = {}; try { titles = Object.fromEntries(((await api('/c/blogPosts')) || []).map((x) => [x.id, x.title])); } catch (_) {} await table(coll, c2, { columns: def.columns, label: def.label, format: { postId: (v) => titles[v] || v }, actions: [{ label: '공개', fn: async (it) => { await api(`/c/blogComments/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); } + // v1.4.0 자료실: 파일 올리기(여러 개) — 크기 상한은 자료실 설정, 파일은 사이트 폴더 밖에 저장되어 API 로만 내려받는다 + else if (coll === 'libraryFiles') { + const box = el('div', 'row'); const up = el('input'); up.type = 'file'; up.multiple = true; up.style.width = 'auto'; const cat = el('input'); cat.placeholder = '분류(선택)'; cat.style.width = '140px'; const out = el('span', 'muted small'); + const go = btn('올리기', 'small primary', async () => { if (!up.files.length) { out.textContent = '파일을 고르세요'; return; } const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); fd.append('category', cat.value.trim()); out.textContent = '올리는 중…'; try { const r = await api('/library', { method: 'POST', body: fd }); out.textContent = `${r.added.length}개 올림` + (r.skipped.length ? ` · 건너뜀: ${r.skipped.map((x) => `${x.name}(${x.reason})`).join(', ')}` : ''); setTimeout(render, 600); } catch (e) { out.textContent = e.message; } }); + box.appendChild(up); box.appendChild(cat); box.appendChild(go); box.appendChild(out); c2.appendChild(box); + await table(coll, c2, { columns: def.columns, label: def.label, format: { size: fmtSize } }); + } + else if (coll === 'events') { await renderCalendarEvents(c2, def, h); } // v1.4.18 일정관리 + else if (coll === 'members') { await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '승인', fn: async (it) => { await api(`/c/members/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); } + else if (coll === 'bookings') { h.appendChild(btn('+ 예약 추가', 'small', () => editDialog(coll, null, def))); await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '확정', fn: async (it) => { await api(`/c/bookings/${it.id}`, { method: 'PUT', body: JSON.stringify({ status: 'confirmed' }) }); render(); } }, { label: '취소', fn: async (it) => { await api(`/c/bookings/${it.id}`, { method: 'PUT', body: JSON.stringify({ status: 'cancelled' }) }); render(); } }] }); } + else if (coll === 'reviews') { await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '공개', fn: async (it) => { await api(`/c/reviews/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); } + else { if (FIELDS[coll] && coll !== 'comments') h.appendChild(btn('+ 추가', 'small', () => editDialog(coll, null, def))); await table(coll, c2, { columns: def.columns, label: def.label }); } + m.appendChild(c2); + } + } catch (e) { m.appendChild(el('p', 'msg err', e.message)); } +} +function renderHome(m) { + const card = el('div', 'card'); card.appendChild(el('h2', '', '기능 모듈 켜기 / 끄기')); card.appendChild(el('p', 'muted small', '켜면 이 관리자 페이지에 해당 탭이 생기고, 빌더가 사이트를 만들거나 수정할 때 표준 코드가 삽입됩니다.')); + const grid = el('div', 'mod-grid'); + for (const c of ov.catalog) { const box = el('div', 'mod'); box.appendChild(el('b', '', c.label)); const sw = el('button', 'switch' + (ov.enabled[c.id] ? ' on' : '')); sw.type = 'button'; sw.onclick = async () => { try { ov.enabled = await api('/enabled', { method: 'PUT', body: JSON.stringify({ module: c.id, on: !ov.enabled[c.id] }) }); sw.classList.toggle('on', !!ov.enabled[c.id]); renderTabs(); } catch (e) { alert(e.message); } }; box.appendChild(sw); box.appendChild(el('div', 'd', c.desc)); grid.appendChild(box); } + card.appendChild(grid); m.appendChild(card); + const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '사이트 주소')); c2.appendChild(el('p', 'small', ov.site && ov.site.liveUrl ? `운영 주소: ${ov.site.liveUrl}` : '아직 배포되지 않았습니다.')); c2.appendChild(el('p', 'muted small', `모듈 API: ${location.origin}/api/m/${SITE}/… · 이 관리자 페이지: ${location.origin}/site-admin/${SITE}/`)); m.appendChild(c2); +} +async function renderInquiries(m) { + const card = el('div', 'card'); card.appendChild(el('h2', '', '문의 접수')); settingsForm('inquiry', card); m.appendChild(card); + const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '접수 내역')); const items = await api('/inquiries'); + const t = el('table'); const tr = el('tr'); for (const h of ['접수', '유형', '이름', '이메일', '제목', '내용', '첨부', '메일']) tr.appendChild(el('th', '', h)); t.appendChild(tr); + for (const q of items) { const r = el('tr'); for (const v of [dt(q.at), q.category || (q.extra && q.extra.category) || '-', q.name, q.email + (q.phone ? ' / ' + q.phone : ''), q.subject || '-', q.message]) { const td = el('td'); td.appendChild(el('div', 'wrap', v)); r.appendChild(td); } const fa = el('td'); for (const f of q.files || []) { const a = el('a', '', f.name); a.href = `/api/site-admin/${SITE}/files/${encodeURIComponent(f.stored)}${token ? '?token=' + encodeURIComponent(token) : ''}`; a.target = '_blank'; a.style.display = 'block'; fa.appendChild(a); } r.appendChild(fa); const td = el('td'); td.appendChild(el('span', 'pill ' + (q.sent ? 'ok' : 'warn'), q.sent ? '발송' : (q.error ? '미발송' : '기록'))); td.title = q.error || ''; r.appendChild(td); t.appendChild(r); } + if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '접수된 문의가 없습니다'); td.colSpan = 8; r.appendChild(td); t.appendChild(r); } + const w = el('div', 'table-wrap'); w.appendChild(t); c2.appendChild(w); m.appendChild(c2); +} +async function renderResponses(m, survey) { + m.innerHTML = ''; const card = el('div', 'card'); card.appendChild(el('h2', '', `응답 — ${survey.title}`)); const row = el('div', 'row'); row.appendChild(btn('← 설문 목록', '', () => render())); row.appendChild(btn('xlsx 내려받기', 'primary', () => window.open(`/api/site-admin/${SITE}/export/survey/${survey.id}.xlsx${token ? '?token=' + encodeURIComponent(token) : ''}`, '_blank'))); card.appendChild(row); + const items = (await api('/c/responses')).filter((r) => r.surveyId === survey.id); const t = el('table'); const tr = el('tr'); for (const h of ['응답 일시', '응답자', ...survey.questions.map((q) => q.label)]) tr.appendChild(el('th', '', h)); tr.appendChild(el('th')); t.appendChild(tr); + for (const r of items) { const row2 = el('tr'); row2.appendChild(el('td', '', dt(r.createdAt))); row2.appendChild(el('td', '', r.respondent)); for (const q of survey.questions) { const v = r.answers[q.id]; const td = el('td'); td.appendChild(el('div', 'wrap', Array.isArray(v) ? v.join(', ') : (v == null ? '' : String(v)))); row2.appendChild(td); } const act = el('td', 'act'); act.appendChild(btn('삭제', 'small danger', async () => { if (confirm('이 응답을 삭제할까요?')) { await api(`/c/responses/${r.id}`, { method: 'DELETE' }); renderResponses(m, survey); } })); row2.appendChild(act); t.appendChild(row2); } + if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '응답이 없습니다'); td.colSpan = survey.questions.length + 3; r.appendChild(td); t.appendChild(r); } + const w = el('div', 'table-wrap'); w.appendChild(t); card.appendChild(w); m.appendChild(card); +} +/** 설문 양식 편집기 (구글폼식 문항) */ +function surveyEditor(item) { + const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = item ? '설문 문항 편집' : '새 설문'; $('dlgMsg').textContent = ''; + const title = el('input'); title.placeholder = '설문 제목'; title.value = item ? item.title : ''; const desc = el('textarea'); desc.rows = 2; desc.placeholder = '설명(선택)'; desc.value = item ? item.description : ''; const thanks = el('input'); thanks.placeholder = '제출 후 안내 문구'; thanks.value = item ? item.thanks : '응답해 주셔서 감사합니다.'; const active = el('input'); active.type = 'checkbox'; active.style.width = 'auto'; active.checked = item ? item.active !== false : true; + body.appendChild(el('label', '', '제목')).appendChild(title); body.appendChild(el('label', '', '설명')).appendChild(desc); body.appendChild(el('label', '', '제출 후 문구')).appendChild(thanks); const al = el('label', '', '진행 중 (끄면 사이트에서 응답을 받지 않음) '); al.appendChild(active); body.appendChild(al); + body.appendChild(el('h3', '', '문항')); const qbox = el('div'); body.appendChild(qbox); const qs = item ? JSON.parse(JSON.stringify(item.questions)) : []; + const TYPES = [['short', '단답'], ['long', '장문'], ['single', '객관식(하나)'], ['multi', '객관식(여러 개)'], ['scale', '척도(1~5)'], ['date', '날짜'], ['email', '이메일'], ['number', '숫자']]; + const draw = () => { qbox.innerHTML = ''; qs.forEach((q, i) => { const r = el('div', 'q-row'); const lab = el('input'); lab.placeholder = '질문'; lab.value = q.label || ''; lab.oninput = () => { q.label = lab.value; }; const ty = el('select'); for (const [v, l] of TYPES) ty.appendChild(new Option(l, v)); ty.value = q.type || 'short'; ty.onchange = () => { q.type = ty.value; draw(); }; const req = el('label', '', '필수 '); const rc = el('input'); rc.type = 'checkbox'; rc.style.width = 'auto'; rc.checked = !!q.required; rc.onchange = () => { q.required = rc.checked; }; req.appendChild(rc); const rm = btn('×', 'small danger', () => { qs.splice(i, 1); draw(); }); r.appendChild(lab); r.appendChild(ty); r.appendChild(req); r.appendChild(rm); if (q.type === 'single' || q.type === 'multi') { const op = el('textarea'); op.rows = 3; op.placeholder = '선택지 (줄바꿈으로 구분)'; op.value = (q.options || []).join('\n'); op.oninput = () => { q.options = op.value.split('\n').map((x) => x.trim()).filter(Boolean); }; r.appendChild(op); } qbox.appendChild(r); }); qbox.appendChild(btn('+ 문항 추가', 'small', () => { qs.push({ type: 'short', label: '', required: false, options: [] }); draw(); })); }; + draw(); + $('dlgSave').onclick = async () => { const data = { title: title.value, description: desc.value, thanks: thanks.value, active: active.checked, questions: qs }; try { if (item) await api(`/c/surveys/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api('/c/surveys', { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } }; + $('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal(); +} +async function renderAccount(m) { + const card = el('div', 'card'); card.appendChild(el('h2', '', '사이트 관리자 계정')); card.appendChild(el('p', 'muted small', '이 관리자 페이지에 빌더 로그인 없이 들어올 수 있는 계정입니다(회원 목록에 "관리자" 권한으로 표시). 사이트 소유자가 직원 등에게 운영을 맡길 때 만듭니다.')); + const id = el('input'); id.placeholder = '아이디 (영문·숫자, 로그인에 사용)'; const pw = el('input'); pw.type = 'password'; pw.placeholder = window.pwRuleText ? `비밀번호(${window.pwRuleText})` : '비밀번호(6자 이상)'; const name = el('input'); name.placeholder = '표시 이름'; const msg = el('span', 'msg'); + const row = el('div', 'row'); row.appendChild(id); row.appendChild(pw); row.appendChild(name); row.appendChild(btn('만들기', 'primary', async () => { try { const r = await api('/admin-account', { method: 'POST', body: JSON.stringify({ loginId: id.value.trim(), password: pw.value, name: name.value.trim() }) }); id.value = pw.value = name.value = ''; await render(); const m2 = document.getElementById('saAccMsg'); if (m2) { m2.textContent = `관리자 계정 "${r.name}" 을(를) 만들었습니다. 위 주소로 들어와 아이디·비밀번호로 로그인할 수 있습니다.`; m2.className = 'msg ok'; } } catch (e) { msg.textContent = e.message; msg.className = 'msg err'; } })); card.appendChild(row); msg.id = 'saAccMsg'; card.appendChild(msg); m.appendChild(card); + const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '관리자 권한 계정 목록')); + // v1.3.0: 메일 시스템이 켜져 있으면 계정마다 "메일 사용" 을 바꿀 수 있다(같은 아이디·비밀번호로 웹메일 로그인) + const mailOn = ov.mail && ov.mail.systemEnabled; + if (mailOn) c2.appendChild(el('p', 'muted small', '"메일 사용" 을 켠 계정은 같은 아이디·비밀번호로 웹메일(아이디@사이트도메인)을 씁니다. 끈 계정은 관리용으로만 쓰입니다. 먼저 "메일" 탭에서 이 사이트의 웹메일을 켜야 합니다.')); + await table('members', c2, { columns: ['name', 'loginId', 'email', 'role', ...(mailOn ? ['mailAddress'] : []), 'createdAt'], label: '회원', actions: mailOn ? [{ label: '메일 사용 켜기/끄기', fn: (it) => (it.loginId ? toggleMemberMail(it) : alert('사이트 관리자 계정(아이디가 있는 계정)만 메일을 쓸 수 있습니다')) }] : [] }); m.appendChild(c2); +} +if (!SITE) { document.body.innerHTML = '

    잘못된 주소입니다.

    '; } else boot(); + +// ---------- 메일 (v1.3.0) ---------- +/** + * 메일 탭: 이 사이트 웹메일 켜기/끄기, DNS 등록 안내·확인, 계정별 메일 사용. + * 서버 API: GET/POST /api/site-admin//mail[/enable|/disable|/check], PUT /mail/account/<회원ID> + */ +const MAIL_LEVEL_CLS = { ready: 'ok', 'send-only': 'ok', 'setting-up': 'warn', off: 'off' }; +async function renderMail(m) { + const v = await api('/mail'); const st = v.status; + const top = el('div', 'card'); top.appendChild(el('h2', '', '웹메일')); + const banner = el('p', 'mail-banner ' + (MAIL_LEVEL_CLS[st.level] || ''), st.message); top.appendChild(banner); + // v1.3.2: 켜기·끄기와 도메인 설정은 운영자가 관리자 콘솔에서 한다. 여기서는 상태·DNS 안내·계정만 + top.insertBefore(el('div', 'mail-state', `메일 시스템: ${st.label}`), banner); + if (st.enabled) { + const info = el('div', 'kv'); + info.appendChild(el('label', '', '메일 도메인')); info.appendChild(el('div', '', st.domain)); + info.appendChild(el('label', '', '웹메일 주소')); const links = el('div'); const a1 = el('a', '', v.siteWebmailUrl || v.webmailUrl); a1.href = v.siteWebmailUrl || v.webmailUrl; a1.target = '_blank'; a1.rel = 'noopener'; links.appendChild(a1); + if (v.siteWebmailUrl) links.appendChild(el('div', 'muted small', '사이트 주소/webmail 은 메일 시스템이 켜진 뒤 한 번 다시 배포해야 운영 주소에 생깁니다. 바로 쓰려면: ' + v.webmailUrl)); + info.appendChild(links); top.appendChild(info); + const row = el('div', 'row'); + row.appendChild(btn('웹메일 열기', 'primary', () => window.open(v.webmailUrl, '_blank', 'noopener'))); + row.appendChild(btn('DNS 다시 확인', '', async () => { try { await api('/mail/check', { method: 'POST', body: '{}' }); render(); } catch (e) { alert(e.message); } })); + top.appendChild(row); + } + m.appendChild(top); + // DNS 안내 표 + if (v.records || v.recordsError) { + const c = el('div', 'card'); c.appendChild(el('h2', '', `DNS 등록 안내${v.previewDomain ? ' — ' + v.previewDomain : ''}`)); + if (v.recordsError) c.appendChild(el('p', 'msg err', v.recordsError)); + else { + const dnsMap = {}; for (const r of ((st.dns && st.dns.records) || [])) dnsMap[r.key] = r; + c.appendChild(el('p', 'muted small', `도메인 관리 화면에서 아래 레코드를 추가하세요. 반영에는 몇 분~최대 하루가 걸릴 수 있습니다.${st.dns ? ' 마지막 확인: ' + dt(st.dns.checkedAt) : ''}`)); + const t = el('table'); const tr = el('tr'); for (const h of ['종류', '호스트(이름)', '값', '우선순위', '필수', '확인 결과']) tr.appendChild(el('th', '', h)); t.appendChild(tr); + for (const r of v.records) { + const row = el('tr'); const chk = dnsMap[r.key]; + for (const val of [r.type, r.host, r.value, r.priority || '', r.required ? '필수' : '권장']) { const td = el('td'); td.appendChild(el('div', 'wrap mono', String(val))); row.appendChild(td); } + const td = el('td'); if (chk) { td.appendChild(el('span', 'pill ' + (chk.ok ? 'ok' : (r.required ? 'off' : 'warn')), chk.ok ? '확인됨' : '미확인')); td.appendChild(el('div', 'muted small wrap', '현재: ' + chk.found)); } else td.appendChild(el('span', 'muted small', '켠 뒤 확인')); row.appendChild(td); + row.title = r.why; t.appendChild(row); + } + const w = el('div', 'table-wrap'); w.appendChild(t); c.appendChild(w); + c.appendChild(el('p', 'muted small', st.resend && st.resend.error ? `보내기용 도메인 등록 실패: ${st.resend.error}` : '보내기용 레코드(DKIM 등, Resend)는 웹메일을 켜면 함께 표시되며 Resend 가 확인합니다. 받기용 MX·SPF 는 이 서비스가 직접 조회합니다.')); + } + m.appendChild(c); + } + // 계정별 메일 사용 + const ac = el('div', 'card'); ac.appendChild(el('h2', '', '메일 계정')); + ac.appendChild(el('p', 'muted small', '"관리자 계정" 탭에서 만든 사이트 관리자 계정만 메일을 쓸 수 있습니다. 메일 사용을 켜면 같은 아이디·비밀번호로 웹메일에 로그인합니다(관리자 계정과 메일 계정은 같은 계정). 끄면 관리용으로만 쓰이고, 그 계정의 메일은 모두 지워집니다.')); + // v1.6.39 (요구 3): 메일 서버(Arimail)가 다른 서버에 있어도 비밀번호가 같게 유지된다 — 관리자가 이 페이지에 로그인할 때마다 메일 서버 비밀번호를 맞춘다. 그 상태를 열로 보여 준다 + const arimail = st.backend === 'arimail'; + if (arimail) ac.appendChild(el('p', 'muted small', `메일 서버(${st.mailServer && st.mailServer.host ? st.mailServer.host : 'Arimail'})의 비밀번호는 이 계정의 사이트 관리자 비밀번호와 같게 유지됩니다 — 관리자가 이 페이지에 로그인하면 자동으로 맞춰지므로 비밀번호를 따로 기억할 필요가 없습니다. "동기화" 가 "다음 로그인 때" 이면 그 관리자가 한 번 로그인하면 됩니다.`)); + const syncText = (a) => { const s = a.sync || { state: 'n/a' }; if (!arimail || !a.mail) return '-'; if (s.state === 'synced') return `관리자 비밀번호와 같음${s.at ? ' · ' + new Date(s.at).toLocaleString() : ''}`; if (s.state === 'pending') return '다음 로그인 때 맞춰짐 (지금은 켤 때 넣은 임시 비밀번호)'; if (s.state === 'error') return `실패: ${s.error || ''} — 관리자 비밀번호를 메일 서버 규칙에 맞게 바꾸거나 운영자에게 알리세요`; return '-'; }; + const t = el('table'); const tr = el('tr'); for (const h of ['이름', '아이디', '메일 주소', '메일 사용', '비밀번호 동기화']) tr.appendChild(el('th', '', h)); t.appendChild(tr); + for (const a of v.accounts) { + const row = el('tr'); row.appendChild(el('td', '', a.name)); row.appendChild(el('td', '', a.loginId)); row.appendChild(el('td', '', a.mailAddress || (st.domain ? `${a.loginId}@${st.domain} (사용 안 함)` : '-'))); + const syncTd = el('td', a.sync && a.sync.state === 'error' ? 'msg err small' : 'muted small', syncText(a)); row.appendChild(syncTd); + const td = el('td'); const sw = el('button', 'switch' + (a.mail ? ' on' : '')); sw.type = 'button'; sw.title = a.mail ? '메일 사용 중 — 눌러서 끄기' : '관리용 계정 — 눌러서 메일 사용 켜기'; sw.disabled = !st.enabled && !a.mail; sw.onclick = () => toggleMemberMail(a, st); td.appendChild(sw); row.appendChild(td); t.appendChild(row); + } + if (!v.accounts.length) { const r = el('tr'); const td = el('td', 'muted', '사이트 관리자 계정이 없습니다. "관리자 계정" 탭에서 먼저 만드세요.'); td.colSpan = 5; r.appendChild(td); t.appendChild(r); } + const w = el('div', 'table-wrap'); w.appendChild(t); ac.appendChild(w); m.appendChild(ac); +} +/** + * 계정 메일 사용 켜기/끄기. + * 끌 때는 메일함이 지워지므로 ① 삭제 안내 확인 ② "삭제" 입력 ③ 지금 로그인한 관리자의 비밀번호 재입력을 받는다. + * @param {{id, loginId, mail}} acc 계정 + */ +async function toggleMemberMail(acc, st) { + if (!acc.mail) { + // v1.5.7: 받는 메일 서버(Arimail)가 연결된 사이트는 켤 때 메일 비밀번호를 받아 Arimail 계정을 만든다(같은 로그인 ID). + // v1.6.39 (요구 3): 메일 비밀번호 = 사이트 관리자 비밀번호. 본인 계정이면 지금 로그인한 비밀번호를 넣어야 하고(다르면 서버가 거절), + // 소유자·운영자가 다른 관리자의 계정을 대신 켤 때는 임시 비밀번호를 넣는다 — 그 관리자가 다음에 로그인하면 자동으로 관리자 비밀번호로 맞춰진다. + if (st && st.backend === 'arimail') { + const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = `메일 사용 켜기 — ${acc.loginId}`; $('dlgMsg').textContent = ''; + body.appendChild(el('p', 'small', `메일 서버(${st.mailServer && st.mailServer.host ? st.mailServer.host : 'Arimail'})에 ${acc.loginId}@${st.domain} 계정을 만듭니다. 메일 비밀번호는 이 계정의 사이트 관리자 비밀번호와 같게 유지됩니다 — 본인 계정이면 지금 로그인한 비밀번호를 그대로 입력하세요. 다른 관리자의 계정을 대신 켜는 경우에는 임시 비밀번호를 넣으면 되고, 그 관리자가 다음에 로그인할 때 자동으로 맞춰집니다.`)); + const p1 = el('input'); p1.type = 'password'; p1.placeholder = '사이트 관리자 비밀번호 (대신 켤 때는 임시 비밀번호)'; p1.autocomplete = 'current-password'; const p2 = el('input'); p2.type = 'password'; p2.placeholder = '비밀번호 다시 입력'; p2.autocomplete = 'current-password'; + body.appendChild(el('label', '', '비밀번호')).appendChild(p1); body.appendChild(el('label', '', '다시 입력')).appendChild(p2); + $('dlgSave').textContent = '계정 만들고 켜기'; + $('dlgSave').onclick = async () => { if (!p1.value) { $('dlgMsg').textContent = '메일 비밀번호를 입력하세요'; return; } if (p1.value !== p2.value) { $('dlgMsg').textContent = '두 비밀번호가 다릅니다'; return; } try { const r = await api(`/mail/account/${acc.id}`, { method: 'PUT', body: JSON.stringify({ on: true, password: p1.value }) }); $('dlg').close(); $('dlgSave').textContent = '저장'; if (r.note) alert(`메일 사용을 켰습니다.\n${r.note}`); render(); } catch (e) { $('dlgMsg').textContent = e.message; } }; + $('dlgCancel').onclick = () => { $('dlg').close(); $('dlgSave').textContent = '저장'; }; $('dlg').showModal(); return; + } + try { const r = await api(`/mail/account/${acc.id}`, { method: 'PUT', body: JSON.stringify({ on: true }) }); if (r.note) alert(`메일 사용을 켰습니다.\n${r.note}`); render(); } catch (e) { alert(e.message); } return; + } + if (!confirm(`"${acc.loginId}" 계정의 메일 사용을 끕니다.\n\n이 계정의 메일(${acc.mailAddress || ''})과 메일함이 모두 지워지며 되돌릴 수 없습니다. 관리자 계정은 그대로 남아 관리용으로만 쓰입니다.\n\n계속할까요?`)) return; + const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = '메일 사용 끄기 — 다시 확인'; $('dlgMsg').textContent = ''; + body.appendChild(el('p', 'small', `${acc.mailAddress || acc.loginId} 의 메일이 모두 삭제됩니다. 확인란에 "삭제" 를 입력하고, 지금 로그인한 관리자의 비밀번호를 입력하세요.`)); + const c = el('input'); c.placeholder = '삭제'; const pw = el('input'); pw.type = 'password'; pw.placeholder = '관리자 비밀번호'; pw.autocomplete = 'current-password'; + body.appendChild(el('label', '', '확인 문구')).appendChild(c); body.appendChild(el('label', '', '관리자 비밀번호')).appendChild(pw); + $('dlgSave').textContent = '메일 삭제 후 끄기'; + $('dlgSave').onclick = async () => { try { const r = await api(`/mail/account/${acc.id}`, { method: 'PUT', body: JSON.stringify({ on: false, confirm: c.value, password: pw.value }) }); $('dlg').close(); $('dlgSave').textContent = '저장'; alert(`메일 사용을 껐습니다. 지운 메일 ${r.deletedMessages || 0}통.`); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } }; + $('dlgCancel').onclick = () => { $('dlg').close(); $('dlgSave').textContent = '저장'; }; $('dlg').showModal(); +} + +// ---------- 일정관리 (v1.4.18) ---------- +/** + * 일정관리 탭의 일정 카드: 날짜 조회(양력 → 음력·간지·절기), 일정 표(다음 발생일 포함), 일정 편집기. + * 일정 데이터 규칙은 lib/calendar.js(normalizeEvent) — 시·분/일/주 단위, 양력·음력 시작일, 반복(매일·매주·매월·매년). + */ +const CAL_UNIT = { time: '시간 단위', day: '일 단위', week: '주 단위' }; +const CAL_FREQ = { none: '반복 없음', daily: '매일', weekly: '매주', monthly: '매월', yearly: '매년' }; +const CAL_WD = ['일', '월', '화', '수', '목', '금', '토']; +const CAL_COLORS = [['', '기본(파랑)'], ['green', '초록'], ['red', '빨강'], ['orange', '주황'], ['purple', '보라'], ['gray', '회색']]; +/** 일정 한 건의 날짜 표시: 음력 일정이면 "음력 2026.9.4 (양 2026-10-14)" */ +const calWhen = (it) => (it.cal === 'lunar' && it.lunar ? `음력 ${it.lunar.year}.${it.lunar.leap ? '윤' : ''}${it.lunar.month}.${it.lunar.day} (양 ${it.date})` : it.date) + (it.unit === 'time' ? ` ${it.time}${it.endTime ? '–' + it.endTime : ''}` : it.length > 1 ? ` · ${it.length}${it.unit === 'week' ? '주' : '일'}` : it.unit === 'week' ? ' · 1주' : ''); +const calRepeat = (it) => { const r = it.repeat || {}; if (!r.freq || r.freq === 'none') return '-'; let s = (r.interval > 1 ? `${r.interval}${{ daily: '일', weekly: '주', monthly: '개월', yearly: '년' }[r.freq]}마다` : CAL_FREQ[r.freq]) + (it.cal === 'lunar' && (r.freq === 'monthly' || r.freq === 'yearly') ? '(음력)' : ''); if (r.freq === 'weekly' && (r.weekdays || []).length) s += ' ' + r.weekdays.map((d) => CAL_WD[d]).join('·'); if (r.until) s += ` ~${r.until}`; return s; }; +async function renderCalendarEvents(card, def, h) { + h.appendChild(btn('+ 일정 추가', 'small primary', () => eventEditor(null))); + // 날짜 조회 도구: 캘린더에 보이는 음력·간지가 맞는지 바로 확인할 수 있다 (공개 API /api/m/<사이트>/calendar/day) + const tool = el('div', 'row'); const di = el('input'); di.type = 'date'; di.style.width = '170px'; di.value = new Date().toISOString().slice(0, 10); const out = el('span', 'small'); + const look = async () => { out.textContent = '조회 중…'; try { const r = await fetch(`/api/m/${SITE}/calendar/day?date=${encodeURIComponent(di.value)}`); const j = await r.json(); if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); out.textContent = `음력 ${j.lunar.year}.${j.lunar.leap ? '윤' : ''}${j.lunar.month}.${j.lunar.day} (${j.lunar.monthDays}일까지) · ${j.ganji.text} (${j.ganji.textHanja}, ${j.ganji.zodiac}띠)` + (j.term ? ` · 절기: ${j.term}` : ''); out.className = 'small ok'; } catch (e) { out.textContent = e.message; out.className = 'small msg err'; } }; + const lab = el('label', '', '날짜 조회(양력 → 음력·만세력 간지·절기): '); lab.appendChild(di); tool.appendChild(lab); tool.appendChild(btn('조회', 'small', look)); tool.appendChild(out); card.appendChild(tool); + card.appendChild(el('p', 'muted small', '일정은 시·분 단위(하루 안의 시작~종료 시각) / 일 단위(며칠) / 주 단위(몇 주)로 등록하고, 반복(매일·매주·매월·매년)과 음력 기준(음력 생일·제사·명절 등)을 정할 수 있습니다. "다음 발생" 은 오늘 이후 처음 캘린더에 나타나는 날입니다.')); + await table('events', card, { columns: ['title', 'when', 'unit', 'repeat', 'category', 'next', 'published', 'createdAt'], label: def.label, edit: (it) => eventEditor(it), format: { when: (v, it) => calWhen(it), unit: (v) => CAL_UNIT[v] || v, repeat: (v, it) => calRepeat(it), next: (v) => v || '(2년 안에 없음)' } }); +} +/** 일정 추가·수정 대화상자. 단위·달력·반복 선택에 따라 보이는 입력이 달라진다. */ +function eventEditor(item) { + const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = (item ? '일정 수정' : '일정 추가'); $('dlgMsg').textContent = ''; + const cats = ((ov.settings.calendar || {}).categories || []); + const field = (label, inp, hint) => { const l = el('label', '', label); l.appendChild(inp); if (hint) l.appendChild(el('span', 'muted small', ' ' + hint)); body.appendChild(l); return l; }; + // 숫자 칸(연도·월·일·기간·간격)은 3자리 쉼표를 붙이지 않는다(numfmt.js 의 data-plain) — 2026 이 2,026 으로 보이면 안 된다 + const inp = (type, val, w) => { const i = el('input'); i.type = type; if (type === 'number') i.setAttribute('data-plain', ''); if (val != null) i.value = val; if (w) i.style.width = w; return i; }; + const sel = (pairs, val) => { const s = el('select'); for (const [v, l] of pairs) s.appendChild(new Option(l, v)); s.value = val; return s; }; + const title = inp('text', item ? item.title : ''); title.maxLength = 120; field('제목 *', title); + const cat = inp('text', item ? item.category : ''); cat.setAttribute('list', 'calCats'); let dl = document.getElementById('calCats'); if (!dl) { dl = document.createElement('datalist'); dl.id = 'calCats'; document.body.appendChild(dl); } dl.innerHTML = cats.map((c) => `