diff --git a/Jenkinsfile b/Jenkinsfile index c18d145..62609b6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,15 +1,16 @@ -// Wrapped — CI: auto SemVer bump + multi-arch push (Harbor + Docker Hub) + deploy +// Wrapped — CI: multi-arch push (Harbor + Docker Hub) + Helm deploy // // Триггер: push в main (webhook Gitea → Jenkins Multibranch). -// Коммиты с [skip ci] в сообщении — без повторного bump (анти-цикл после version commit). +// Версия образа = файл VERSION из коммита (без авто-bump в CI). +// Bump: make bump-patch / make push локально — что запушили, то и уйдёт в релиз. // // Credentials (Manage Jenkins → Credentials → Global): // harbor-devops-tools-push-pull-access — Harbor devops-tools (robot) // docker-hub — Docker Hub (inecs) -// ssh-gitea-key — SSH к Gitea (push bump VERSION) -// gitea-jenkins-token — token Gitea (если нужен API; SCM обычно ssh-gitea-key) +// ssh-gitea-key — SCM checkout Gitea +// k3s-kubeconfig — в job Deploy (Jenkinsfile.deploy) // -// Версия: файл VERSION (+ pyproject / Helm). Теги образа: :, :, :latest +// Теги образа: :, :, :latest // Deploy: devops-tools/wrapped/wrapped-deploy/main pipeline { @@ -29,8 +30,6 @@ pipeline { RELEASE_TAG = "${env.GIT_COMMIT?.take(7) ?: 'dev'}" BUILDX_BUILDER = "jenkins-wrapped-${env.BUILD_NUMBER}" DEPLOY_JOB = 'devops-tools/wrapped/wrapped-deploy/main' - // SSH «Доступ к Gitea по SSH» — push bump-коммита VERSION - GIT_SSH_CREDENTIALS_ID = 'ssh-gitea-key' TZ = 'Europe/Moscow' } @@ -51,20 +50,14 @@ pipeline { steps { container('docker') { script { - sh 'apk add --no-cache python3 git openssh-client >/dev/null' - def lastMsg = sh(script: 'git log -1 --pretty=%B', returnStdout: true).trim() - if (lastMsg.contains('[skip ci]')) { - env.IMAGE_VERSION = sh(script: 'cat VERSION', returnStdout: true).trim() - env.SKIP_CI = '1' - echo "Skip CI for version-bump commit ([skip ci]); VERSION=${env.IMAGE_VERSION}" - } else { - env.IMAGE_VERSION = sh( - script: 'PYTHONPATH=. python3 scripts/bump_version.py patch', - returnStdout: true - ).trim() - env.SKIP_CI = '0' - echo "Bumped VERSION → ${env.IMAGE_VERSION}" + env.IMAGE_VERSION = sh( + script: "tr -d '[:space:]' < VERSION", + returnStdout: true + ).trim() + if (!env.IMAGE_VERSION) { + error('VERSION file is empty') } + echo "IMAGE_VERSION from VERSION → ${env.IMAGE_VERSION}" } } } @@ -72,9 +65,9 @@ pipeline { stage('Build & push') { when { - allOf { - anyOf { branch 'main'; branch 'master' } - expression { return env.SKIP_CI != '1' } + anyOf { + branch 'main' + branch 'master' } } steps { @@ -139,40 +132,11 @@ pipeline { } } - stage('Commit version') { - when { - allOf { - anyOf { branch 'main'; branch 'master' } - expression { return env.SKIP_CI != '1' } - } - } - steps { - container('docker') { - sshagent(credentials: [env.GIT_SSH_CREDENTIALS_ID]) { - sh ''' - set -eux - git config user.email "jenkins@antropoff.ru" - git config user.name "Jenkins" - git add VERSION pyproject.toml helm/wrapped/Chart.yaml helm/wrapped/values.yaml - if git diff --cached --quiet; then - echo "No version files to commit" - exit 0 - fi - git commit -m "Bump version to ${IMAGE_VERSION} [skip ci]." - # Multibranch detached HEAD → push to branch name - BRANCH="${BRANCH_NAME:-main}" - git push origin "HEAD:refs/heads/${BRANCH}" - ''' - } - } - } - } - stage('Trigger deploy') { when { - allOf { - anyOf { branch 'main'; branch 'master' } - expression { return env.SKIP_CI != '1' } + anyOf { + branch 'main' + branch 'master' } } steps { diff --git a/README.md b/README.md index 4f4597b..03ce151 100644 --- a/README.md +++ b/README.md @@ -432,7 +432,7 @@ docker buildx build \ | Harbor | `hub.antropoff.ru/devops-tools/wrapped` | | Docker Hub | `inecs/wrapped` | -Теги на каждый реестр: `:` (из `VERSION`, авто +0.0.1 на каждый CI build), `:`, `:latest`. +Теги на каждый реестр: `:` (ровно из `VERSION` в коммите), `:`, `:latest`. ### Версионирование @@ -440,18 +440,18 @@ docker buildx build \ | Где | Поведение | |-----|-----------| -| UI | справа в футере: `vX.Y.Z` | +| UI (модалка «?») | бейдж `vX.Y.Z` | | `make bump-patch` / `bump-minor` | ручной bump | | `make push` | автоматически `bump-patch`, затем commit/push | -| Jenkins (`Jenkinsfile`) | на `main` автоматически `bump-patch`, образ с новым тегом, commit `Bump version to X.Y.Z [skip ci].`, затем Deploy | +| Jenkins (`Jenkinsfile`) | читает `VERSION` из коммита **без** доп. bump → те же теги в образе и на кластере | -Коммиты с `[skip ci]` не бампят версию снова (анти-цикл). Push bump VERSION — credential `ssh-gitea-key` (Доступ к Gitea по SSH). +Что в `VERSION` запушили — то и уйдёт в Harbor/Hub/деплой (и в футер модалки внутри образа). Credentials (Global, как в job’ах): | ID | Тип | Назначение | |----|-----|------------| -| `ssh-gitea-key` | SSH Username with private key | SCM + push bump VERSION | +| `ssh-gitea-key` | SSH Username with private key | SCM checkout | | `gitea-jenkins-token` | Secret text / token | API Gitea (webhook/API при необходимости) | | `harbor-devops-tools-push-pull-access` | Username/password | Harbor `devops-tools` (robot) | | `docker-hub` | Username/password | Docker Hub `inecs` | @@ -492,7 +492,7 @@ kubectl -n wrapped rollout status deployment/wrapped Credential: `k3s-kubeconfig` (Secret file) — kubeconfig к K3S. -Рекомендуемая связка job’ов уже в [`Jenkinsfile`](Jenkinsfile): после **Version** + **Build & push** стадия **Trigger deploy** вызывает `devops-tools/wrapped/wrapped-deploy/main` с актуальным SemVer (`wait: true`). Push VERSION: `ssh-gitea-key`. +Рекомендуемая связка job’ов уже в [`Jenkinsfile`](Jenkinsfile): после **Version** (чтение `VERSION`) + **Build & push** стадия **Trigger deploy** вызывает `devops-tools/wrapped/wrapped-deploy/main` с тем же SemVer (`wait: true`). Ручной запуск Deploy: **Build with Parameters** (пустой `IMAGE_TAG` → `cat VERSION`). diff --git a/VERSION b/VERSION index 17e51c3..d917d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2 diff --git a/app/main.py b/app/main.py index 8bf72b9..f3dfcaa 100644 --- a/app/main.py +++ b/app/main.py @@ -99,14 +99,19 @@ def create_app() -> FastAPI: app.include_router(admin.api_router) templates = Jinja2Templates(directory="app/templates") - templates.env.globals["app_version"] = app_version - templates.env.globals["app_version_label"] = get_app_version_label() - admin.templates.env.globals["app_version"] = app_version - admin.templates.env.globals["app_version_label"] = get_app_version_label() + # Callables so Jinja re-reads VERSION (dev mount / after bump) on each render + templates.env.globals["app_version"] = get_app_version + templates.env.globals["app_version_label"] = get_app_version_label + admin.templates.env.globals["app_version"] = get_app_version + admin.templates.env.globals["app_version_label"] = get_app_version_label @app.get("/health", tags=["System"], summary="Health check", include_in_schema=docs_on) async def health(): - return {"status": "ok", "service": settings.app_name, "version": app_version} + return { + "status": "ok", + "service": settings.app_name, + "version": get_app_version(), + } @app.get("/", include_in_schema=False) async def index(request: Request): diff --git a/app/static/css/app.css b/app/static/css/app.css index 6634cd5..faf0129 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -201,6 +201,10 @@ html[data-theme="dark"] .icon-btn:hover { box-shadow: none; } .icon { width: 18px; height: 18px; } +.icon-btn i { + font-size: 1.05rem; + line-height: 1; +} .hidden { display: none !important; } .shell { @@ -784,126 +788,24 @@ html[data-theme="light"] .form-alert-attempts { text-align: center; color: var(--muted); font-size: 0.85rem; - padding: 0.5rem 1.25rem 2.25rem; + padding: 0.75rem 1.25rem 2rem; max-width: 760px; margin: 0 auto; } -.footer-pillars { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 0.55rem 0.7rem; -} -.footer-pillars li { - display: inline-flex; - align-items: center; - gap: 0.55rem; - padding: 0.55rem 0.85rem; - border: 1px solid rgba(110, 168, 255, 0.22); - border-radius: 12px; - background: linear-gradient(160deg, rgba(110, 168, 255, 0.1), rgba(13, 20, 32, 0.35)); - color: var(--text); - text-align: left; - backdrop-filter: blur(8px); - max-width: 240px; -} -html[data-theme="light"] .footer-pillars li { - background: linear-gradient(160deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.85)); - border-color: rgba(47, 111, 237, 0.2); -} -.footer-pillars i { - color: var(--accent-2); - font-size: 0.95rem; - width: 1.1rem; - text-align: center; - flex-shrink: 0; -} -.footer-pillars span { - display: flex; - flex-direction: column; - gap: 0.1rem; - min-width: 0; -} -.footer-pillars strong { - font-size: 0.8rem; - font-weight: 650; - letter-spacing: 0.01em; - line-height: 1.2; -} -.footer-pillars small { - font-size: 0.7rem; - color: var(--muted); - font-weight: 500; - line-height: 1.25; -} @media (max-width: 720px) { .site-footer { max-width: 100%; - padding: 0.35rem 0.6rem 1.5rem; - } - .footer-pillars { - flex-wrap: nowrap; - gap: 0.3rem; - width: 100%; - } - .footer-pillars li { - flex: 1 1 0; - max-width: none; - min-width: 0; - flex-direction: column; - align-items: center; - justify-content: flex-start; - gap: 0.2rem; - padding: 0.4rem 0.3rem; - border-radius: 9px; - text-align: center; - } - .footer-pillars i { - font-size: 0.72rem; - width: auto; - } - .footer-pillars span { - align-items: center; - gap: 0.05rem; - } - .footer-pillars strong { - font-size: 0.62rem; - line-height: 1.15; - } - .footer-pillars small { - font-size: 0.55rem; - line-height: 1.2; + padding: 0.5rem 0.85rem 1.5rem; } .footer-copy { - margin-top: 0.65rem; font-size: 0.72rem; } } .footer-copy { - margin: 0.85rem 0 0; + margin: 0; font-size: 0.8rem; color: var(--muted); - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - flex-wrap: wrap; -} -.footer-copy-left { - min-width: 0; -} -.footer-copy-version { - margin-left: auto; - font-family: var(--font-mono); - font-size: 0.72rem; - font-weight: 500; - letter-spacing: 0.02em; - color: var(--muted); - opacity: 0.9; - white-space: nowrap; + text-align: center; } .footer-copy a { color: var(--accent-2); @@ -914,6 +816,56 @@ html[data-theme="light"] .footer-pillars li { text-decoration: underline; } +.about-modal-panel { + width: min(520px, 100%); +} +.about-modal-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.85rem; + margin-bottom: 0.15rem; +} +.about-modal-head .modal-title { + margin: 0; + flex: 1; + min-width: 0; +} +.about-version { + flex-shrink: 0; + margin-top: 0.15rem; + padding: 0.28rem 0.65rem; + border-radius: 999px; + border: 1px solid rgba(110, 168, 255, 0.28); + background: linear-gradient(160deg, rgba(110, 168, 255, 0.16), rgba(61, 214, 198, 0.08)); + color: var(--accent-2); + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.03em; + line-height: 1; + white-space: nowrap; +} +html[data-theme="light"] .about-version { + border-color: rgba(47, 111, 237, 0.25); + background: linear-gradient(160deg, rgba(47, 111, 237, 0.12), rgba(14, 160, 140, 0.08)); + color: var(--accent); +} +.about-modal-body { + display: grid; + gap: 0.75rem; + margin: 0.85rem 0 1.1rem; +} +.about-modal-body p { + margin: 0; + color: var(--muted); + font-size: 0.92rem; + line-height: 1.5; +} +.about-modal-panel .modal-actions { + justify-content: flex-end; +} + .sr-only { position: absolute; width: 1px; height: 1px; diff --git a/app/static/js/about.js b/app/static/js/about.js new file mode 100644 index 0000000..aa51a47 --- /dev/null +++ b/app/static/js/about.js @@ -0,0 +1,36 @@ +(() => { + const modal = document.getElementById("about-modal"); + const toggle = document.getElementById("about-toggle"); + if (!modal || !toggle) return; + + function syncToggleLabels() { + const label = window.WrappedI18n?.t("about.open") || "About Wrapped"; + toggle.setAttribute("title", label); + toggle.setAttribute("aria-label", label); + } + + function openModal() { + modal.classList.remove("hidden"); + document.body.classList.add("modal-open"); + modal.querySelector("[data-about-close].btn")?.focus?.(); + } + + function closeModal() { + modal.classList.add("hidden"); + document.body.classList.remove("modal-open"); + toggle.focus?.(); + } + + toggle.addEventListener("click", openModal); + modal.addEventListener("click", (e) => { + if (e.target.closest("[data-about-close]")) closeModal(); + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !modal.classList.contains("hidden")) closeModal(); + }); + + if (window.WrappedI18n?.onChange) { + window.WrappedI18n.onChange(syncToggleLabels); + } + syncToggleLabels(); +})(); diff --git a/app/static/js/i18n.js b/app/static/js/i18n.js index dffe2c2..1226699 100644 --- a/app/static/js/i18n.js +++ b/app/static/js/i18n.js @@ -10,13 +10,14 @@ "footer.cap.media.hint": "Screenshots, archives, docs", "footer.cap.browser": "Encrypted in-browser", "footer.cap.browser.hint": "Only ciphertext reaches the server", - "footer.pillar.zk": "Zero-knowledge", - "footer.pillar.once": "One-time", - "footer.pillar.encrypted": "Encrypted", - "footer.pillar.zk.hint": "Server never sees content", - "footer.pillar.once.hint": "Gone after unwrap", - "footer.pillar.encrypted.hint": "Key lives only in your link", "footer.copy.author": "Sergey Antropov", + "about.open": "About Wrapped", + "about.title": "About Wrapped", + "about.p1": "Wrapped is a one-time secure drop for text, images and files.", + "about.p2": "Send a note or code snippet, and attachments too: documents, archives, screenshots (drag-and-drop, file picker, or paste from the clipboard). Both text and files are encrypted in the browser before upload — only ciphertext reaches the server.", + "about.p3": "The server never sees plaintext: encrypted data stays on the server until the recipient opens the link with the key and decrypts the package.", + "about.p4": "After a successful unwrap the server copy is destroyed. The encryption key lives in the URL fragment (#…) and is not sent to the server with the page request. Optionally, a wrap can also be protected with a password.", + "about.close": "Got it", "brand.tagline": "Wrap. Send. Vanish.", "create.eyebrow": "Secure drop", "create.title": "Wrap. Send. Vanish.", @@ -227,13 +228,14 @@ "footer.cap.media.hint": "Скриншоты, архивы, документы", "footer.cap.browser": "Шифрование в браузере", "footer.cap.browser.hint": "На сервер уходит только ciphertext", - "footer.pillar.zk": "Zero-knowledge", - "footer.pillar.once": "Одноразово", - "footer.pillar.encrypted": "Зашифровано", - "footer.pillar.zk.hint": "Сервер не видит содержимое", - "footer.pillar.once.hint": "После открытия — удаление", - "footer.pillar.encrypted.hint": "Ключ только в вашей ссылке", "footer.copy.author": "Сергей Антропов", + "about.open": "О проекте Wrapped", + "about.title": "О проекте Wrapped", + "about.p1": "Wrapped — сервис одноразовой безопасной передачи текста, изображений и файлов.", + "about.p2": "Можно отправить заметку или код, а также вложения: документы, архивы, скриншоты (drag-and-drop, выбор с диска или вставка из буфера). И текст, и файлы шифруются в браузере до загрузки — на сервер уходит только ciphertext.", + "about.p3": "Сервер никогда не видит plaintext: зашифрованные данные лежат на сервере до тех пор, пока получатель не откроет ссылку с ключом и не расшифрует пакет.", + "about.p4": "После успешной расшифровки копия на сервере уничтожается. Ключ шифрования живёт во фрагменте URL (#…) и не уходит на сервер вместе с запросом страницы. При желании wrap можно дополнительно защитить паролем.", + "about.close": "Понятно", "brand.tagline": "Упакуй. Отправь. Исчезни.", "create.eyebrow": "Безопасная передача", "create.title": "Упакуй. Отправь. Исчезни.", diff --git a/app/templates/base.html b/app/templates/base.html index 31c4c30..ad80589 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -36,47 +36,45 @@ +
{% block content %}{% endblock %}
-
+ + + + {% block scripts %}{% endblock %} diff --git a/app/version.py b/app/version.py index ddc85dc..4e2612a 100644 --- a/app/version.py +++ b/app/version.py @@ -26,8 +26,8 @@ def _read_version_file(path: Path) -> str | None: @lru_cache def get_app_version() -> str: for path in ( - _VERSION_FILE, Path("/app/VERSION"), + _VERSION_FILE, Path.cwd() / "VERSION", ): found = _read_version_file(path) @@ -45,6 +45,7 @@ def get_app_version() -> str: def get_app_version_label() -> str: + clear_version_cache() return f"v{get_app_version()}" diff --git a/docker-compose.yml b/docker-compose.yml index 7c4554c..54327c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,7 @@ services: condition: service_completed_successfully volumes: - ./app:/app/app:ro + - ./VERSION:/app/VERSION:ro command: > sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/helm/wrapped/Chart.yaml b/helm/wrapped/Chart.yaml index 8e7bb5e..88aab9b 100644 --- a/helm/wrapped/Chart.yaml +++ b/helm/wrapped/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: wrapped description: Zero-knowledge one-time encrypted drop (Wrapped) type: application -version: 0.1.1 -appVersion: "0.1.1" +version: 0.1.2 +appVersion: "0.1.2" diff --git a/helm/wrapped/values.yaml b/helm/wrapped/values.yaml index 7fdb91d..fc2e967 100644 --- a/helm/wrapped/values.yaml +++ b/helm/wrapped/values.yaml @@ -2,7 +2,7 @@ replicaCount: 1 image: repository: inecs/wrapped - tag: "0.1.1" + tag: "0.1.2" pullPolicy: IfNotPresent service: diff --git a/pyproject.toml b/pyproject.toml index 1f6a44c..ecd5afa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrapped" -version = "0.1.1" +version = "0.1.2" description = "Zero-knowledge one-time encrypted drop service" readme = "README.md" requires-python = ">=3.12"