From bb77643c9f6077ed25354eb206a3727abc9e7f4f Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 30 Jun 2026 16:38:50 +0200 Subject: [PATCH 01/22] fix: seed script --- README.md | 81 ++++++++++++++++++++---- infra/.env.dev.enc | 106 +++++++++++++++---------------- infra/.env.local.enc | 109 ++++++++++++++++---------------- infra/dev.sh | 25 ++++++++ infra/docker-compose.dev.yml | 7 +- server/kf/oidc.server.ts | 2 + server/kf/provisionLocalUser.ts | 22 +++++-- server/seed.ts | 107 +++++++++++++++++++++++++++++++ server/sequelize.ts | 7 ++ 9 files changed, 339 insertions(+), 127 deletions(-) create mode 100644 server/seed.ts diff --git a/README.md b/README.md index 2f9b52c320..7e198749bd 100755 --- a/README.md +++ b/README.md @@ -26,28 +26,85 @@ At the moment, PubPub isn't particularly well setup for outside contributors. Ho User-facing documentation is a work in progress, and can be found at https://help.pubpub.org. If you're interested in helping contribute to documentation, we'd love to hear from you. Please send a note to [hello@pubpub.org](mailto:hello@pubpub.org?subject=Documentation%20Contribution) introducing yourself and describing how you'd like to contribute. -## To Install - -``` +## Local Development + +### Prerequisites + +- **Docker & Docker Compose** +- **pnpm** (see `packageManager` in `package.json`; `corepack enable` to activate) +- **SOPS + age** for decrypting environment secrets: + ``` + brew install sops age + ``` +- **Age key** — ask a team member for the private key, then place it at: + ``` + ~/.config/sops/age/keys.txt + ``` +- **pandoc + poppler** (macOS): + ``` + brew install pandoc poppler + ``` + (See `Aptfile` for equivalent Debian packages) + +### Quick Start + +PubPub requires [KF Auth](https://github.com/knowledgefutures/kf-auth-v3) for authentication. Start it first: + +```bash +# Terminal 1: Start KF Auth +cd ../kf-auth-v3 pnpm install +pnpm dev +``` +```bash +# Terminal 2: Start PubPub +pnpm install +pnpm dev ``` -To run locally on a Mac, use [Homebrew](https://brew.sh/) to install a handful of dependencies: +On first run, `pnpm dev` will: +1. Auto-decrypt `infra/.env.local.enc` into `infra/.env.local` (requires age key) +2. Start PostgreSQL, RabbitMQ, app server, and worker via Docker +3. Seed a demo community if the database is empty -``` -brew install pandoc poppler -``` +Navigate to `http://localhost:9876` -(See `Aptfile` for a list of equivalent Debian packages to install) +### Default Credentials -## To Run Dev Mode +Sign in at http://localhost:9876 using the KF Auth seed admin account: +- **Email:** `admin@kfauth.org` +- **Password:** `admin123` -``` -pnpm dev +### Useful URLs + +| URL | Service | +|-----|---------| +| http://localhost:9876 | PubPub | +| http://localhost:3000 | KF Auth (sign-in) | +| http://localhost:3001 | KF Auth account dashboard | +| http://localhost:9999 | Inbucket (email inbox for dev) | + +### Secrets + +Environment secrets are encrypted with SOPS + age. Decryption happens automatically +on `pnpm dev`, but you can also run manually: + +```bash +pnpm secrets:decrypt:local # infra/.env.local.enc → infra/.env.local +pnpm secrets:encrypt:local # infra/.env.local → infra/.env.local.enc (after changes) ``` -Navigate to `localhost:9876` +### Troubleshooting + +**`env file infra/.env.local not found`** +Auto-decrypt may have failed. Ensure your age key is at `~/.config/sops/age/keys.txt`, then run `pnpm secrets:decrypt:local`. + +**Every page returns 404** +The demo community may not have been seeded. Check Docker logs for `[seed]` messages. To reset from scratch: `docker compose -f infra/docker-compose.dev.yml down -v && pnpm dev` + +**Cannot log in / redirect loop** +Make sure KF Auth is running on port 3000. PubPub expects the OIDC issuer at `http://localhost:3000`. ## Fonts diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index 30595f8676..b35003d39c 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,61 +1,61 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:h7qbTJxJeUHu8b1cfCrCBK8M/LJfaxEozxdQVILgr3tb4VggkzsPpbr4KNOtK8pRBDXft4dxfdvTTcckk/KzwQ==,iv:Qv5WoB8JJ44uNf44/Zhbn9NamsM4b3zBCYesDM16BwE=,tag:LlxtNGy64+eVqsf0/RKqcw==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:wdeZ4XbAsrqx+7K/LKXj5oeco1Mx4P/PAKeYMuOgJXyrd6G6kuy0vnJkrCr/0NpqIybvQhT6hi9C0mxkL2rm1g==,iv:kI4o3TaKl5vGzQe1qypmMXCuxO5NnuWsVh3/n1PU4x8=,tag:aBmIjqFdB+MtW/BZifh5RQ==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:j5xHwp86kVe+Q43O2nM4mI9PqEU=,iv:E1rhuegAEZMFwgLJi1wlq8Sl0SsCxZL5BvgZK9lfBpE=,tag:ZI82fECFJ77UyXMPcLFp5w==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:QUJkXAnMIbLkb07T1zekSQRX8sc=,iv:gUkgc1oU1rPHUXQrhj58qxkfde0jJdmQxBTZs5u/6bM=,tag:QJmtOzeH67JVn5tr4PHEiQ==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:eu7hz2BeK5ww+90nphpbFB2rxQs/hAgGAQZVMioDvqFymeoAJuaN7Q==,iv:AVpEozYwF0tQqv7suBwuvC6d+e5n1GUXp85xutajZ78=,tag:/3cIpBaVHWMTIHxMHKpSeQ==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:nIwH+UU2+F5l9047iQPRkEqB7A2h4mMFYKfem66L6uDKoouvKV9BWA==,iv:60GYLILKK11erR1eVLgW1H4NNiAligZaE9D1tSLd9jg=,tag:EH7sgJR2vkLZlCY51wnBBQ==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:5sQqL/w7ZpDC122QJCSumSDjh4bPSq5wVAvZXs2qI67dVKvBHeDoMZdyFr0=,iv:Z3NwyZH1tDQTeys+YnR5yt89FQ5yeKTong0JsC5Ek2Q=,tag:Tiezth3nfwje7jSH73V3lA==,type:str] -CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:Rlm1/xO2Brfw/Ori0ERKtRhuvt4ljMh8p2RzO9nUDQUEwcLtHJ2LqcZxqq/xyuQXIJ61grw=,iv:FMnKOZDde8L90W+7nN/yG5sbLYppVWYv0c33py04ubc=,tag:T2V5DmNP1iazPxQjNd3A+w==,type:str] -CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:g6kJke1ZjaaxB/yY2PF+B1yrL5SKOoUIxFuE3t2K+RCQtI96e8EjAluI8/IzbG3dYzOsQFs=,iv:PuIHMwELLVGzv3/znFe/qs4HPgoc1sED7yvsW5DbbwA=,tag:Jqy/VfP9KePPf3KQpzw8IA==,type:str] -CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:uCGuHryb2PlYtRHkQUwrm7EPf75JRBXh4KWj6iiHqcs=,iv:udBUrPTyMntEluWHwp+iKLmLt1RqXoUOXGQg4sg7a6E=,tag:0dgxRDzYHjXS4rETNFocBw==,type:str] -CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:mwYtebAqKsnihje7S67mrm5iXokE8xUKe5Oc/0RJ9rg=,tag:40SWkzDbjXDvaumVHofFSw==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:I1XJzlgkaK8+vzwXOlPxAS4KCpxlh9di1cB+PjowKgNE9S2B2QnXwKRERMw=,iv:ZTd+wHr9D6S0l9J6quPkMDepZFc2Tq5r1g2Z79eFtA8=,tag:UZBDsm2jEaTYn4jhZpwX2A==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:V5UsSaEeBwniXO7gcuZgRkGBzQS+wXTHzz8SqV2FDLvhPA==,iv:nCZkem7NJcJF4AYorqTtRh0WRx6kmccxojGfhj+wd9c=,tag:PQBxwom9tFR0BsuHLQXMyw==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:aNSNmRff,iv:Iq+j9h+N5H0JGZ99Rk4I50opWPgAlCAwkMgXzNHVId8=,tag:bjtqMU2Ng6iu9TjlfJ1Exw==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:7O636rpkjtDE09mcsBTNFD4HtiE=,iv:7wTPpEaTGyV6J2NGFmQMqaSgMT24qSIs/TP9waArXWs=,tag:KRdUZT+IjFHbXIDy7YZhTg==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:eIo9vFwqznuI/2zDZv/8P9CMWg0XvNVebycPYaM/xVdxmfj3ASUqMTY=,iv:i0FWp1P4GUCTJEziwBV90VS518otmu7ojvmbV7HdA7s=,tag:8iM8GU4bwGQxDVdIiZP1yQ==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:BTg92mvw6xmYQHpFlqLbtHFO7QeL5ayJgaqo71Jlr1A=,iv:lzr1hhomKMx9q+K7uDnuFr9W4mJFMHkG2sBj2+wi+80=,tag:o0wm6ws9zkC/RjX/aMjDvQ==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:LmtEdR7CieGtOFPGkIYSNvoHj/aM9Q==,iv:ORwDwVXHMCCQ82AwNlRHICkCAXzW00ckMET8wlUjkFo=,tag:b23qbLhlm9zBBV2dB0u+xg==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:d/IlvuqRqxjxgUXyHE71+qsi3brjh8M7y6W+xvGe9No=,tag:r9fzNRKVxrwtEY1chk1smw==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:pGhSBw==,iv:88vE4NAY3P9UtI4Jal9TzFgfc90J3r0VDvLR8Dj9PxM=,tag:vJm3ej/3DKZXa7KUVgJdmA==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:F47NxQuvKoO9/Ylu9KpFmlSxzGdRcjdn5EiBSDwStYidyPdYqGJsr44nUtOvCpIEZR1NnCAbccGtJlUgdxkYKRA0WqBVeQhkgA2DWN6J2Gwt8hFLrJJ0981USz+nTNiEjalmN8ebwOKmm6Zu25zL6kOUZI+nndeW9QBFW2ce1PLi3TXc4GrTrJrRDdOXHiCSpUeDmKxC0MM0Ieai9DvBURQL8LrfHDkCx5mgFO7E3o55aS82MZngdpqT3zwBgC++gbpXI//PV6Dql0RqM1ffHUtPxye+bDvvjFzTrbRnSOtCa8OGmhZO5be1notI+gGZVNPyc4x9g+VzFwgZa6VUNr5s+1JCpvSwsttYscXIB82OTMso3XIbiYIxH886/V2Y+kxlzS37sXrebDF+ptjjFAZc3VPpFERWeR1TuMZCFn+OSyvPfpMKO33f9NGOnYAs+UnVOeuemW3QnM4XvOTl+4+Ln8HHN5Lc1spJGnmGdLVkeJhwYOVyvsgMrY5TbN56DHqz9D2eSqiZ1KPM4i2ElGMA7OJ2P0BoUd9YXpUrx9SWcY7UFuwpgURr9uNrORK83RI07o7sFlwFM2/qBKz2D3LX9Jugx29z+P1CNj3q/209NM8sneNBaz5TgNoYJ6nm7+F6/krBmIeoB6Jp9YPeLLA6vk8rEFjm6ezwcpkCIYbzVfWZcojxlLF1IKPu5uLPxrzoV4k5ev2fo9Sya8x0mxPOEm30u2dSw5o2FDJjQmx3QCulpWIYlMZXUNE8ZeN3Ugh2AWpEqGfEDG5OaUXytKYHAmJ7aSJN8DyM/TJlgiLMssNGT/LGT3Zff+CTOBLI3d0nD/3g5yL1zdk1V4SdJp95inMJaywZ3cK6iJubaqicFe7ymqlJgglVjcwbWOsQB1rR+LU6ImWY+20ZmudVCJfBgvOY0kXE/zahG6dyr4Vxicj06NYg4IFu4faNVWLN/G6M4hEL1zqwbsoi+y3Ar0Pf2olPrr6jqGbcZRvUd0QtlWr5mP0kwJ+kSmOj+6qoVAF/WziWPEmZJwBYLPJEqXZUKOygAoxVU2NpbAzDR12muUyATEdTSDOilF7jaJ/epQDk6gOfBoptUcp1h9tGEx6ZBO+waOGJSmZpt0qMqsJg4hELfFdzOFnlDgAOiodEV9iU8HqXE4w90z2FxXmuNpfIg5AhgeO96tm3X6CIxanawvc/XWioal7N/7JZd/ZAxsNnqRsx/TU/TtTalinrJvWcfFzLrcPIwU3uWr8MCsVJWiFcE75J0CZBDizc7VlakDykjLD2h7xuDvH+4+mI3ENIALb/Q4/mTUaz0eW3LZdC67YULiCc5Ap3tBbCT8yTbJGrV7Ur7AiRkwum5ffrVA==,iv:NCxgW7WAv5AggPhBC+Fw/r2Yuu+7ByrQ8j1qxKhSPg4=,tag:Je7/Of8STALswBOTnXSn7w==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:ftsngh2Qpdrs1JLDSXfWqRl96Ch6yhFxXBfuOhHyM+tslyXm,iv:Ej+WO4Tr60wcykHyzhMa0nPeD5woOkLFO5WvGJ/4/DM=,tag:vVCzLVzOt4QD7twvUOuq5w==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:mz9UnNUJs9cDYHkZVUyXl4a5MCL7Xn77ZJ+ZD/SLjU5VjF6q,iv:4dWbkQd8cgCd9hTBySyeHaMhUPsOQm0Ze7xvdfiGO0g=,tag:VsXL3xVWnHe/fiWMyVgr9w==,type:str] -NODE_ENV=ENC[AES256_GCM,data:TtZv0z18OJO89A==,iv:QpcprV2W9l9kttjA+smPgo5xu1ENopcMJnwJEwkCl+4=,tag:uXFoaihKBLpZCFEI1paqdg==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:tx87aZS1vv+I/hBUkPybzDkjA3A=,iv:IlYOJwByvJIS7vc/BTd0wt/HTFs/BbVM9X6Jo1g/qQY=,tag:UJuHx1XgeKSorMVY1BwpNA==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:I5nxBWNEkxFLkPA=,iv:O8J67Ekfi0N1hf1NrcJVghPvko+kYFucjTMICtLVWAw=,tag:1Q3pJlDN3ls6OHlBfoLiMg==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:TJAUdaPjFETxSUfxllGZSwnOwNOpdlUoRapJJpVywnDSCL8=,iv:ZnQQcvv2L9JMQQh9SJMmEQYRYtcRGVz7VOq01gWDmgc=,tag:FwPrlOCS3f+KcLfN6KepbQ==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:iqtDRv9nwW0dBzRZV+sDnOfpec6v6I41xGJwu4eNZh2dvAfTi36LRA==,iv:EGtyqD/J0vq6hWejFUvHSPxyGCLn2ge0W8aFl179W/0=,tag:G4xp6MIfm6S/hSYf96CL9w==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:ywVghraC+mTNLZw1IYutAjpgWqWfV8fNSibYxLGuXAKspdM/tM6RAMf+de9G1+NwQK64+aqdRuBOXIsHndDI7ppQPksJXfrSCBXnz5V+KE7ulIq9gK27jnLJZz7AefXZ/iRdsDCM6ebsO78obWhVULrYghM/5lENUSsMY6PwRbwyyqiiRkPG7Rf3CiEFpCrAx57/dvCb+Lqi/Odhtu5tQym2eJZnve6AXFL2anxiuymeR5n9eNDrBx7+qg==,iv:k9ty58qA8NLOPYeDrRaed6PaN7yz7bQanDpeCY1U79M=,tag:qe7g2mGO9lG6uQPTkvaWLg==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:karx,iv:rOJAiUiXF9JqruqfkkT5+piujdQiTugmmk7v1ebO9Es=,tag:nrio3OXjezRpToALGropMw==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:SQI=,iv:2Lkoi0oqfIc0zmjN9Vhnx2VzY1wrJRkSqBlvNp+Uo94=,tag:DZBh8W2H7bUiw9DxM+cPDA==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:NfNjTbF1osVIAEF6Y1TPDpTlPkHBCKInPeZ1Rs3PCim2hXshPsQ5QHcwW8g0pUHo/2CnWja/8TBO24NyPHDjzntRZTQ/tOC6eev3nqJ6Pg==,iv:Ot3Gpm9vIKRTindhjT3wqAUQStwUnKV625Inj6pmrII=,tag:arLmEwlueAif36JhDnBTAQ==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:e3DJ1gpIzxtGVXHf6n2PP8UkX5rZL1uRRcfMlXveDXj+SA==,iv:9MQ41GRAEAAmxWNyWOYSoyVeQmBFBNKoeFkson18YkM=,tag:LaDIH6q7pzp0PShuXMQtpg==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:rG7Xp9JfDixk1GChjZQ1jqHc0q3dHo7E9Qrp/omKqPL4G49enesqxpaQOuk=,iv:by4sItUvFc2joqEFFA+XXviMYrzMBtTtYHE6uQALYJQ=,tag:FTSnskt6fXIT813q8ma7tA==,type:str] -SMTP_USER=ENC[AES256_GCM,data:4rwX5MnYhjkkmZI2S4uP8YGsF88=,iv:kVTS2/QYw9pB3lAdXwe1aamGyIY9uDL1YjG+690f5Vw=,tag:+ndWvP7bbOx0uZ8TSNYX7A==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:HQ0WvuyNpLPePrhxW+amDBAdhAY=,iv:t4j9BlVkk6ORUKs15+yRq+yG29iOjZ4ALbYxpXfPGPA=,tag:aLjAvQdOQIU6tcNivGb1Jw==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:yyEB00EblTeQaetIki+HkkJD9Qc=,iv:rB7LgTZ+njQyIgweksvYE4N5wBp3oFdiD7REp8dE34k=,tag:b3O0JZ4is6njdayVSDZXjw==,type:str] -#ENC[AES256_GCM,data:q+qpSfYB6Syd2xwtZ4eiA3T89SI=,iv:UJm2dIhU0FtVLqKLhhT4vdrUBaMg5sKw4px0N+hcebU=,tag:kqaThaRFZwMC3kdi18nrQQ==,type:comment] -OIDC_ISSUER_URL=ENC[AES256_GCM,data:V/vxb+Sfye9R5mhdi5XrGeOZNx+XgUhXekeY3+LRrGIlKb0n4A==,iv:LVGeobkyOMACijrNhyzyDszeGBtY7vSVB7eyJFTLWso=,tag:hlnA7HE6z9j4XfMyg61fMw==,type:str] -OIDC_CLIENT_ID=ENC[AES256_GCM,data:VohyOKTn+u7Z,iv:rWl4sR7LoP41EEKnc9KXaYtoHU+xqZxABSllCnEapjY=,tag:M6/SZ4PzGfB3IVihVtdXSQ==,type:str] -OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:BrW90K5Uql3VB0Ebpl41mEOTAUCvmuJP2KqYu6KfxLmIuziogR0BlzeeWSkFUtAY4LgLBc5F0ln5UCikv9FIKQ==,iv:DYXu2L1t/FISiZ6c5Ys8Ejnxv1w6jIITUeE6KzJOuYk=,tag:4ZavC2+jtmpxYTAUoE/9+g==,type:str] -AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:ZrCI90bZIuiqxSa5xowJctgM89ctasZ4ebuoXGAmPQo8EXEn2QSnkcLVUhGzQFU4pp9A9ulqa8IGnNIGuZ0FXw==,iv:GVlcG3D1oiheEfuYBeXj3Sb8K8nEQ8xFiqOqE6deFGs=,tag:I/+ae413+vRpCHXIc+kcaQ==,type:str] -OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:e4loM3VLHL/1JoJKg7sHoO2wm+uNmpdxx/YdIBcUYjFhZMlOusRmnw==,iv:B+K0Q3vgMDJu192TJSV223XUxbbL4+EyWZQUmNBna0E=,tag:MW/LJJ2tApiKjmWCySJy1A==,type:str] -APP_URL=ENC[AES256_GCM,data:IouJCKBAaOvbiVQIZVP5tiPBC90CEg==,iv:RUkURLLViwqSLzDm3ImyZ0BRpXOlpKUVm8eUvkj/NdI=,tag:ohj0zEZGJ0DmVOBMNJfLFA==,type:str] -SESSION_SECRET=ENC[AES256_GCM,data:sqDkEZf705q66q+H0PT3W9baJbBuBmMmwP11IoXiQWR8LNoUqSN68KmwAyeKOp7xcApO+xUoUBGDQGORvrbYV3zflRDpQx/7b1cFw0WScY3npVgkpdttXvOZRvEc/Vdd8OEgNinSsrWNo0qaQGCCeg2HJm2VIpfjMbuuEepLjH8=,iv:FWsQPSXfC7eKDnHIj0QcMxygEal7sJGEh/agUfv1z6o=,tag:udi4h2X2m3995oJYPlYPaA==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuTmNnZ3JFb2RZMDVISFZE\nQ2Q1ZUFrWDZvSUdJSkRKRU9Mb0FST1o3VDJFCklTNW84TzN5TUlWU0FBUVVjVENG\naUQ5SzBLYUFiZEIxWTh1VWloRG9MUkkKLS0tIG9GalM0bkl0NU4wU2R2bE1qTitB\nU0ZYdlF5S3pWVytNSk9TUnY4UFMxWUEKA/bBNRcTmd6mpLrDCz2irehIZZolTXLj\n3y+et9IKE57/xAfNB1n1OHvtQMKEG7VORnccz3zIx7a+y+WH4AixEw==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:Skf6rST1VkXVfLvh6ADI06GQoHfAkgs2QXTAgBzqLhduqQAwkskr6/gS0mq2v6H1S1tyKNiuNoH1YGGJ/KkAIA==,iv:PTnPQzw8/KWXTe8q1LeD42unD3y3k5Sss5z6g054BGU=,tag:iLe+7YKU5u0rdetLDlQzIQ==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:YcMqtgnjnkF+7mpOImIR47aT/OcaQgNDyc/u3buSI9Z5bKcm7KrmfU0lB73t2S9LkyxrqJZnklV9fKgUAMlkWg==,iv:ib75yJzK3CjuioQ18UuEklL59tLCJeHLVk3TA3/FEoU=,tag:iS5QerYNNb8A2QZZNq5Avw==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:eJpQ9Q5RpA5bSeUpgxRTjGeQbo4=,iv:Zl6HTDnamdTpAGhom9TvsROYUi5/B8i8tJG5GCgUwSU=,tag:32R5NKO80rh6sdARGwygHQ==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:gvU7zgL4/H2ETahrI26JgrODGyM=,iv:Z2OiSgVbi2uvmcLWYxdm7fn+0iutO3M2agoEX83qJ10=,tag:gvKs3xTUj36rlAD0y7zs2Q==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:IxWAsH9xzBe4z8xbqRGrbe3EaQZhO6/53QR19n5XASoXneZb1Wna6w==,iv:XeNjcTFDouvGc+pRK4e9wI9t1Crknv5rguQmHE95FXI=,tag:5htuK08kgsu0QXcLHoUlkQ==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:zkpFdxPSSEyY90EJmVrxMTo0tCWF1ZEWt1Nj04VW38qImBypbwV4Yw==,iv:Gc1qOi8eGpHlVPqeghh5OlIDucAhJpugPLHPTOGRTt8=,tag:Agaf6vW1Q8L9IskO85miNA==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:enmXVchNQRJOb27EDgm2G53uqvtGlADZE7D0J4SvpSnff5ogBb7FG/5usOI=,iv:HjsEPOZUUoEkWV0w5OLDssKDH6Jm+TEsFHa6IHrhRl4=,tag:VMUjCfPRRVHXMpD0uliRhA==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:H2voX0qslKuhk40r1KHdlCaM6PoSFZkggPmwOlE4ZOrIy2tiwra+lZMTOaHCj9CvPef53fs=,iv:HQ9vAiovq2Bv36TUj23hAXCfeJcXROOoV661lSbJ6/U=,tag:AtnhOaCKsuDFJg30GJGAmA==,type:str] +CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:rB0Ie2+CQyVg36bRLZorOygdxbvfXjof+rCqDmcNM0465znpy1uNBUexdcnlhCCgcQVtteI=,iv:5izGQX9KsRcBDwGhCVrWdrKIyivJXvd6924WnisvL9k=,tag:7GbH65+kTa6Yd7TEilkIOQ==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:1bRjmbA8WbxPzCQ5s34gysDdRnEmz6NDXDXftYIBlHI=,iv:Y49xBzHiCDGa2r397ym/rzyVADmGBjJd4U3QyBoT54k=,tag:6z+egpaRK0Y4TMWPfhG47A==,type:str] +CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:BwcT8A7p5JBs1f3AWeQ4e/qku20CT9h+sCDhR0h6k64=,tag:12XUMLig2DgGba7pmXqIrw==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:6qwsuHTeh+dxdtEfIEsAQ2A/DedleWpXUikp1YuKM3125m8Ek0K+X47zrns=,iv:byvKYrmieggKuS3VHd890rEMD2QqthOEWZmkrjeWBEM=,tag:g1wOw6Laus0XVnpK1HMw9w==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:lN/GOuBOAzIgAALsQIDEAnaHc4xAC7QqKzBEG2rFHb6XLw==,iv:YYvvj1g70RM1p4Hcmw+Yns4N9c20FOTLvcpnEm/b3XY=,tag:TKOn8ZclQZA2CPh/pR6gyA==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:wxgdZrj8,iv:RERoIn2h7lwokm3z+e/8IkUUroauASPbwewEG953TfE=,tag:84cZejzkYLhB62UkM8/Twg==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:rn9l68U/VbtCJmNgedCNsaLJTls=,iv:mHs8jUoR9001W5Zu3TDnA/hlRWtFxgzNoMEs/81Rq20=,tag:IbYRpSUegv7qMASVfwvzeQ==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:kfzEf4bOFjoRzZAY/iQXXePlECoZ4r2I8uV+GI/2IWIn6uVT5vr2gIA=,iv:UMZPTgY8u9nc4x5D/E3o7dWlMUuPWKkjp2H4zum+Kj4=,tag:iWlyUrQGe2h9M3gA+/fY/w==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:iTAGTSFuC6gnL+U/bCMdt7gLqxr0gMXLGz+azIVrTrs=,iv:U/H0ltYtr7Mlw8b59Qbbameg2dSz8KQfzmWqqBBRKus=,tag:3iSWjwtIqCZKeeM3HOnG1g==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:kApyy+9FYzNexiMUrZYlNihI9FODbg==,iv:/+Kb80LlYa5hSuW9PrrwpVtj5eEGhhs42Hu+Ej+G4y8=,tag:2D+7rElE5EF2Z8xkH/J6rw==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:cff8wloo537XpBTfJ00rslrrQSDTh8VtCe1J57wCEuI=,tag:KZ2wBysKNvUJ/mSGHhl33g==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:Qy50Dg==,iv:14X1YiDZm4muSLshXaP6IIj7NzOH1MBKRLCYUDBRu08=,tag:1hETsuqWxcQ0VetKsHrbtg==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:nFq5t81ZxDVLgVQr6CijqM/5bN4q4DZO/qn3v8fi0le7e+fFApK+p3qMbVS0Gmn/g31FI3f0TOX3QOi2A2ag6ojcTjrUM1tLKB6spBczi/X1C7VFQ5Efq3T5ddG+kHtlyIPW2ghzM/K9wdbk1YDfh9tnH3PO3hkEC3jDRIao9HMp127gZAkfSlqparSPWSaX7kGX3G1cPekyrv2dP/IfJ/9jgGWe3zhOla62h6fYAbLcZPAtoDYFzLsW2N1oh+VWd6hQLzrFEjhPqQND2ZUqxsnwVeLBjKAGhGbvLQf4JLvRx4FBR4bB0shpa7X6gnGZMimRSrDuTkycBwGw1et/7YDJva+3JZk+ZCbilyEYklrUoaPP+qHvkLrzz60mUva5GOEpiKv2sgNYxs2FLY0g8uHqkM9p6N0pr0Bu2XmVmi4py3CZ4I0igp6MdscJ7dBK3Tm0c6zuHLPb6iMraludV6sm74dt0dzVN4qQPDuc2UvuExI6vTSP1gTLvSOE8/h/pFGd4T7rtl2pOnL/sITx+E+8UZ/FSGoGZzZspljA5CW3z3E30S+M1srIYCljj9MKl/5ni4GC1Mcwy2Q0i2SPuwX4VsOOsE+tNaWC74LQ6FHNxtBaRzwB8rFKPI0oi6Zcz0D+QiDneJy14IUfXsdqjlxWWM2+dMJdO/ASBSBYGBlPQAyhiUwYi8yysHI8jrUCTjiOX9HNCXVHzehsFMM/qYWq7TpLrgZ2LWC8I6ER3wAs/2Qrwjrj+jfdX9Pt2/yWPqusQWMfmremgIh57yZVx72QBkqi5tiIbqj7Bdou5vfCQYr79Ua7ZySDhb2B9+hVP/kHmV+gDCWJdqZPHhFATV+gwAw0gnXqRe2/+637RAM/tp07aIjZTC0JdBXjgfNVaGRtmTXCdI2Vvyl3JbuQ7i0dWki4pV7ccO0Hzwfa9WMBG572EyZJ8dVkvhhwFaE1CG/zWU3tX/5Ex5L+dvYGLkuNxpdbS9SfP8Qoo8Wsmz4XRFzcLyFgSXXSbjoW+bWBQjgc5e3bVX3ZqpZatJvInz5cnvEOzuDKn+WUh9MQJQUXN0siU9lv2T39xudjlVq32HmXPleo3EJdozVCTekwsVL5c79fdhZzW/lZiaBDwGgQ8Y/B+i4nU0pe/Y7neJ8IJZaP3M0KTT7ZcSL8ZOT9i4aRduoFT59zeZJSg7GE0KYS3/8RqRP5pIXHFl3DBm/FuMeQo6xzLI+bPtbMg6ziZWuifCBVvK51PJgl8H0HPZOBWi7TsuJHf0GD9+akdf2ZaNBKu8MVNci3a+q6YXW3/crY7el9Pnv0L7QOZ78B+JtwD/IREqEabjHeiuhspce3m+ynyr57iuMwCzqi0P3s3g==,iv:yPV7FD+2fMRRV9paP+kGtzXuHgXHxMe8cHVVIFRr8q8=,tag:vM4VB0nsANTORCPyoLl64Q==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:ODM1nUKgknWMV7LddZRAxpvssTtUe9uxwNJ8gUxAuW9STi4A,iv:WZ1ZXF41CAUJ7xOnoxf6gLwIMt4jcMzzJT51cG6Gkec=,tag:utbIbwJL84ceo216fkBL6A==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:KNyyq2VdQn7HuKOBKsQE86ZB8RU60pyK0hzwFnX9C8nCYqA1,iv:TaZ2e9Y44YoBwjH5HHjZGKuPZ10gES15w9CT+14UuiI=,tag:/3RP82hmY1tTI3LDf9uw+g==,type:str] +NODE_ENV=ENC[AES256_GCM,data:nht12jlGAxD47Q==,iv:D03Pwebb4HwRp1Peu+Aa+4yJYNF8f+zalbS5NlX8qdI=,tag:c62Oy1vC7TfqkNP66c2sIA==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:bfpXfWo3ZiDEfCoo2RyM04sEK+A=,iv:WCYgZ35vXxorg0SwN0Q6UdP+iO9+dSIb//Ntr4vPAi8=,tag:CqWwzfzfNxFlPdDDkQ2X4w==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:22Z86QffqBqATHI=,iv:4n4USsj/gHtETE/CBYIubabgNU4uA6RHMMUmdkXLNFg=,tag:SNzeGOiLO7YeUwWiM59o/Q==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:yETo7W/FKIXE5tdEFy+oRq66BPS+bXlmG2Abk3K5l9RdK4k=,iv:1vezjHO0+wJui6aEZjsICqiMdGotIOg3C1OEEgNbih4=,tag:V3rZQXP0Ip5L6vwy9rZehA==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:SZR7cbwFEsU3K8Wk9Co41LJYE2jscU2kt5TrSLHCDSuezsnI1iZc3w==,iv:6VIijZC9YYzh36b2dKs0rlIU64bgECSVzCNrl0N4kYU=,tag:uG3Tjaah/hZuaK6mdSqQrQ==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:gCyw8+NmxCE11YgfAoCSaIAzcco5NDZSabHttPB9g06DF7166AtMWv4PChSfaqX7smMpnxy9ApyVSrf43mgvRGmhxakZ4nyFRBdCI4rMkIwbPO1CaPjeV5S5vJ3fzKvZuWN59z8rmjp59b/4MdJ8adEZxmyP8Tf4LtJboEtUWgnxx0q3v1ecmQ3e/49x2XoPIVswzMI/I8/cTqUixod2R4guZ94HZyk15JDxO0JU5jtfVvo+/tZiuTVOwQ==,iv:FdsjQPH/7zZW5kWrwapRSDPdby5WJ7clvwgPKne8Xos=,tag:tlP/jRO/aJSL45SLCw9tcg==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:jxHx,iv:ZI3vEsdlpqeotoaFpBDdggAvO5x/Ed8UkGYAACCgQ+E=,tag:CxLC7wypRBAdjUuP/+nypg==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:2/o=,iv:pWYBBzeV4OYQ4SHUKo3oWqir4P7WdJm4Qx/5e6lRtvw=,tag:CRKap4n+9FJZrI5Oo9+kYA==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:BXJkEzQlhI2zL8eFCAHW4HRn4LDddTmoHu1qzHNKnaWhWVTSEthSJVv9jtc+yOxI1TGoZqtZ45vvH+x6JK0cYrBwuY9DeGXR1msQdF8wVg==,iv:V3tvUsH7rDJTRU6IvTTIsryt6M6fk2TRieNzxAIN91w=,tag:lRsXNw8Hi9XvNnlHnVX4mg==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:tByL2zWL6TPB7+ojollGt2pj9ipRWLmBFaOPE3vGCZl7/A==,iv:FVj53D4LwcX6vG4RgfwIR3lZaAkQklKogP7ghi3Vk4s=,tag:BdJ7ezHy7dHU427BsKtm+A==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:mpuCXCuY0R/wssAHc19lxYa18JRxvuOxD2wfux+vbfIzXujCcrchZDqNcFo=,iv:/AxDsCibwkRG4oGbe/akki9HutMrpyouEBcwjkdfPFk=,tag:gOpP3N73SZr2HQl9+1m63Q==,type:str] +SMTP_USER=ENC[AES256_GCM,data:Ska29jb1o65Sc+pfpululp9ETwo=,iv:xtSRgC2/0kMBCcxrfF0Z5qQ0JRV4rED76pKXexYfJs0=,tag:/KiShM5lcEz8DsOEtrojig==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:R3NbZm4rjZQlx4jqQy1uyAJnPCs=,iv:uXxQePczIntAl+Nwi8TKJ98nVP+7jmEf8SDVmra0qsE=,tag:fx+j/ImJiOAPpmN4xjrtGA==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:t4xsyBiKJ/jWiqJP0hvYtoJfOhE=,iv:zoUHkCaUl/vSVpcJQ/MG4UCUcHwEQeUNg7BpskRUprw=,tag:DkZrZYIqYFf19narFGQCXw==,type:str] +#ENC[AES256_GCM,data:cY/nVvAHMjvZ1Mfcajt6WQ+sWAM=,iv:zhaaZ8j3ddxMOYOoNm0ryt/heoob8dZ0G2PK6kwgdD0=,tag:aycyeLSTjqkYKZubE0wV/g==,type:comment] +OIDC_ISSUER_URL=ENC[AES256_GCM,data:cIt1iptJoQaM2wfyjb5/irqUVDiZQcQkHEOcIEey/jZAGe1Ekw==,iv:VOU0trQm0ubuIWYBVIkDTf14iXv8ErW+SwXVT2I3Fys=,tag:T5+QeNBhc/KGvN74CCMMCA==,type:str] +OIDC_CLIENT_ID=ENC[AES256_GCM,data:6Qo2X8W7x0fw,iv:Am2sZ2LvzI9Q667xOadhWN+Poo+7svrEMt4Tvh45Y3I=,tag:Ou/vbfRYs0vWl/wFmaSbHA==,type:str] +OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:P2CA9+arfxuVoVaxw1m/iLYH7AfrYUhpUUQgCs96PhLm6pPGZ/Xo6Ce94ZxbEuLisdCCVxa4tyjU8hURdDI+CQ==,iv:ldDvC+QpetRrKtTfFhtZd6xFoxJMf80OcGoWnC5BUg8=,tag:MbHWAe/hLTU13gNa5CDnDg==,type:str] +AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:coTXT7yMV8kxOZGyLSEOjTKVzzz/Hq4rlqyD3uuW3mTfvQsNZDcgkEJS9fHWW6olyptekObFRu7846tKy5+hHQ==,iv:yX5Sh6eVePk2jOIn4IKRKMVzBWUqLupQytSnrR4crBk=,tag:nlLASLl9+NzA6ktmasRC2g==,type:str] +OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:wDRQRU7xAtr84gnoYjQBCVDO/8CuB6DDO7sed4MdGP+zWfS74OnNbA==,iv:OFcgSRwOutI6HDXU1e8cenODp0QfntwnMDi/65TreLg=,tag:+hnITjmlEf4NJNXcKgXU+w==,type:str] +APP_URL=ENC[AES256_GCM,data:MntMJr95AprVjW3RVuzv84kBJcp+4g==,iv:VIwN5xfwwlp/PinXJOBcTd9C1D390ySGFdrQA3DLUlM=,tag:iNPEmT1t4DzfWN/i5Kj6Og==,type:str] +SESSION_SECRET=ENC[AES256_GCM,data:dB4YueNFG3V2JKpmyHWTQXbmpMR+xVbYnWQQqT76RgST6L3KjTi9mSUSeIuNBJNJw4jPkZFiDa9pFLcvTC7KRD/gbaoDHIbaZB7+6hyphvKiHBN/uEf3nrg/Zz9iWVHd9GVVu33f5/OpiZgpCzxX0zRQx8qga24GVbSpMF8lUoU=,iv:gOWWyeltR6C+LSPH1CA2KsBysKl3/CoaGWgheyqRxl4=,tag:XihIHSmMi3KbrGyCxr2DrA==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEaFBBSVVqM3BobGx5NEhr\nZUViVDdFME1nd3Q2bk8vSjRNaFRUMlU2dXdnCjRPaGkwbktlM1gzKzIvNGlyMGNY\nb1ZrNWRHQWpWSDNxNlZHMW9ZSUk1RHcKLS0tIDBXSElVbnpqTUJMeDNtR0dmdE5p\nY0RBbVl3V3ZmdUVTY3J6YW5kMUVMSTAKpOez2o7MN9llqbfnVKCAzYK1fjbt1lv2\nADq3qBR7BRZJxFGyo2z8DC0nUynRvPdXlsCLTXdbRLuvIe9ZJq3eKA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1MzNsdjlwR080VGxyMld2\nRGZQWExFb0hkNk9NRThsUlFWYTNxWGszOEFVCnZYOU9qMHBKM0VCVHEvZXNPVXhy\nbGcrYnBkMmVRTU1iNFV2dEh5eVBzNU0KLS0tIDlEN0hnTHZhWGlWNVV2c1hwVTc1\nTmZsbExVT0F0WUhnMW15OTNVakp5cDgK7aP/iuRWRxG3IGxkLefUuJpiTGhnjWqz\nQ3lj13+JZoRsyhQ5PAamp5Gk9lwz0HKImcVciRak9dtbA93jh1B3Tw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwR05ZRHVKdER0c0lGQklZ\nV0JtZ2QzbTk4U1pOUlVIQTJQdGEwdmVVbkJZClcvMGJqMVoweXpSNDFhRzFIN2Zi\neFBBUTlQQ1lWUXNQSEllWmNRdTF5MGcKLS0tIGtFS0lUQ2gvbXZlRWpSdlllWXEr\nYldkUkQrZFpabFl6U1BYZWlEU2wzMWsKuPZ94tlboasIPrXYdWuHo3Hd7tOwqufv\n0MPPupgJ079AEqoFqJBKSxdtY1D/huT5TCteymHwuJhWegbyMxOKjw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYNDBvTjVPaXVnejhkMFV4\nTmxaVTBCSWhoWjZOMjVmYWptU2lrYTNLd0RjCmJMZ09sWVk0QmZ4TjdqQ2t6ZllP\nenIzTE13enNmUG9LSjhmQUk1UEdpZkEKLS0tIHFHVjBYNStQRXlZSXFwMkJpUUhU\ncm9WZ2QzeVdRWlBocXczZW5nazdIQzAK/tPMt5LtxsdWfkeiqyFZRhh1iOmdl40l\nurpDJagyjIxQwIwA5kF5JhyVcND2LdNFyqnfW2BCbMJpRRNleIeW0A==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnNDVOMFBEbDBZTDcxTWc4\neDVHOUJUZm9zWkkwVngxTWhvNEppR2RUa1d3CmVpWXcxM3A0NE9PZ0NFRTJJamNE\nS1JENGZMTTR5OXlIT1piSldJL3B1MUkKLS0tIFN3dTdFb1dndExVVVhzT3RHRXhn\nOUw2Y0JFSjRmNEVQNGcyS3plWXVaM1UKSFEDkq4GMagCinxYHmMulWXvGuQB6bVY\n+YFDZJCmlv4AgUvWBnJZ8fs3vcp7/yk6xj/qFBwvPmJeQRwal0Mqgg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0ME9La0xTTlRuRHVPcE5a\nR1RWYnM1ZmNSNGJ4NGJzd1JVNFpZaWY5MFRRCnIybkl4ZmE2bGdUSmdLYkNhSkhr\nTjJoMmUyZXBkVXZ3VXl4bmRCOExpZU0KLS0tIEh4MlhjTVp5dWNNckdhNGx4S3Zh\nQlp2d3lsbTJHbk9GeS94TFdRSkYrQ1UKLsR70qm9Nca0TrL66vEsowx4K09jaCmf\n1keF5VRVIwNNtTgMsWM2UyjeqjOcXyExcqXhu6Khvrqs5qGVV+TzPQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPeXI1ZFd0blNnUnFnSk82\nV3VWcytoeXRuclB4QjdxRzBwaVJ4Z2ZDQldjCmVYU3VaT3ZTUElRZFpwRStRR1o3\nTEVvUC9GYVUwMkZxZVN6RWpoU0lGUlEKLS0tIG1xajJqbW5ZK282S2xOT2U5VDh2\nd0htL3BZaC9OWVppMENkV3NqZWtnOXcK+jicBIm3J9qv4sdFfwPCj4rlM4F5nQot\nXaV68Vi9v6q7XC6txj4HUEgk6PpJFC0JMwZEqIu55vEU/0mZzpNFVw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaZVVSUWNXRGVhSnh6YW1y\nU244MEFCa2lNYTNTcnNPaUd1ZGlvR2txZGc0CmpjblNzalJzYllxRlNPUXEveThB\nSk15SllUanZNL3VVbFViOU1KeitzQ2MKLS0tICtkNEpjVS9VenNKY3NUcDdPSnpr\neEwwNGlGSEdudFZxdjZKTG1BWG5HMFEK0Ai4UkVQK8r7QiEtqRNVwr9T599NuHAA\ney6dypbqPfZx8SfbS+6v4wN/AQpIqyPcRQpWbwaQTdkp8ysKDkEoHg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZNDhYR3oyTkxmMkNKTkk1\ncmtXcWk0WHBvTDRDOG9qQkJPRElsUGZqSWxJCnh2K0NaUTArMTR4eHJTMHd5MXpW\naTBpVGJ1dnVmZUlHTnhmSUxqeWFsOVUKLS0tIGlzVkVHdEtVd3N2RktCRmRZSTZi\nRUZpSnBRblZuaUtvdUl2Qm9kamlsSlkKTcTX6SWObX3XdMr7DPc9gVvgyBZiDVYI\nUcMlzz6AbITzSvFPzynW4MtoTCsLngpTPP35Jju33LIGw8Thk/kZpA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqMzJtVGpaZVFtTkpTZEM3\nWnc5TWM2ZlM2MU9MZllQVVJOM0hBV3pCM2kwCk40K1VNbk1lVXJnQXRDT2Y2OHJM\nTVpENzhQdnAzT0NadkpvMWFXR2RKZkUKLS0tIE5OeitiQytZR1ZIelNBeHJDZzRP\neTZxUzh4RHZubktSMVpUNHo1dGdMaGcKK14ecFe+vus+/2PFBZOt6lYmBkAPV5SN\nbtaiRm1ruoGXdCISidvI2EGy/gJqVl6u/QaHlDqlP6PxG5TVF05x5Q==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBydWFabHg3dTFQbHlvRi9t\nTDR6T1lCUDM5SGNSUGgvRVh6L1hBZ2JMalMwCkJTMURaUTBLaGxES3duSCs1VHlv\nYkZIU094TmRETGFabHlvOTR4TFJlTWMKLS0tIERQMzJHR1ZBdEl5eVE1cVJ5R0l6\nemZpdlM3THo4ZEV0MmtUc2pIeVNhYTQKSuxc1oCbbKGUZoLPwCGE+u3OiKT5voo3\nGACrlJy8UBamVG8X8ukqvYFN6k5LILHyXSoawF+r+5dLR4sMrb2QLQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-06-08T12:56:27Z -sops_mac=ENC[AES256_GCM,data:NzDoKEj+SWiRJgyseSMHUbs88DXUpOuwJAGjZrN9GU50yp/NuXyIxce9q7bCRluSzsvO77udMcd9+9IFgu2WQvRL6R5Fq6hrtU3JsvzInNmijfBZYYnee/44zaAB/UqvFsDazGHv6GuIDXV1KA0lzmIGpipB4+qL+wSIEKUP56s=,iv:uXwM8uddU2Tg07JS8cDPfmFuALu8TFAMn09wl7y6kxY=,tag:JiRhZhSSgYJ56OSGQLPhGw==,type:str] +sops_lastmodified=2026-06-30T14:13:37Z +sops_mac=ENC[AES256_GCM,data:oQLL+0JjhWdjtT3c8L/Ji68tNF8tcRHxgCVtNe4yRfPBViunJjJ2jBqmA6RwNFPnPTOuvr55hzBd5rtBkVEwKBbPASOfk4b9WyM0O1BwlQ3zxyW4SsAz4NNqSI/iVd6CxSl/VLCV5rkKAdXSekcUurpgsojuT5yvRavtvtJAEJc=,iv:xbtbGDBmaijt2eBo3/AwR+c9THdWbQVCn0eAETKcs+c=,tag:WcugnnCPWTAF+iJvuSsK+Q==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.12.1 diff --git a/infra/.env.local.enc b/infra/.env.local.enc index 7c15703ede..fda856c183 100644 --- a/infra/.env.local.enc +++ b/infra/.env.local.enc @@ -1,61 +1,62 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:vIrqh66O0vIjl5yZufSBw4N0iFFa3aa8Ge4/7fsQaFu7FIaNJngDW9rY0Kssx3MeMTeTSRsQUYDKgEeuguUwWQ==,iv:U1HAoyGkJvJzsxWCvDZC+oasHj85wBFLO/KVQ/L3sEg=,tag:qkbB7uUlIYBLPtpq/oQ5wQ==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:4OfsN3DoUJW/S/IjG2u1CD6pVVCuimRUC1RLeWV3I+x2XLFVNVFMabRpSdWYi7A0D7Hemk6LDph3NXwrSu/yzg==,iv:ig3JqrQJ1k8QM8TEttythWE2eAbD7UAfSz+D7EWgePE=,tag:vHpOGbKI+a9Uyw8BAoboJg==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:go7ZUa8BGf7XHwS9hg3ASw4bRt4=,iv:xY1SlZedf3zJPI5APuWkVAlElVVKY2AoLh19GjNSq78=,tag:mOakCQGa2m51we4/HId5VQ==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:ux+kgdwZ89ob5NrOjX8gg6IFWEk=,iv:zEsH0RZF1oWkb8SAct1T6lLp9B0iPDs3IimN4h1wp0w=,tag:V/abHX/etKgXkq32VKbHWg==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:YTTl7jZpgj4zrkKBFa9TFQmUs2s5EBtUPrVmSJwhCYDAPEgG5cMvaQ==,iv:k2FkkDGsRO4ZBaIi8jC1KWHk9EWv1KjZ7i1INOXTmIw=,tag:1yeyZq+tmarD9Rh1ygwPrg==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:vQF57pHMG3xzLmyMmH0SSSaN/GHHB6e1Ys3GW6FEkK6pKMffwQVvOA==,iv:MIHd9fQG3jP3J67yGscANL249U/ZsGRoJ8zKvH1CXp0=,tag:LibPhBr9liC75tjinj1IbQ==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:zhX/bJBMNrEWGMvu9Cc4wRG4OIVjFU22kl1t0ryU0e5bmRx6D4u1hy9lAgA=,iv:LAyXrFZqxyYWKaz7bxTnF57V5TPFRqZTY10I+xyrEos=,tag:pgePnfjo+XxOHoVxYzlsdg==,type:str] -CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:DyW7eeMeX1wJ/1+0FzeN2w/Wzg1kKAZ2JBCbcSwIKdmeSYZGTHCJ7tZRDOPqoz/DRabkDAo=,iv:O39bayrEyYSIiwWKdDo4R1gz2/WC+99QMgySV/CnM2g=,tag:q+yPo2RGjQDzQ6zvwkIl2w==,type:str] -CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:cLR9rTfrs6JRgRLBz96yMYt64YB0ASDU+4QissBpdHwrBuS8LqWkUDOl97x+IUL8yh/XHQI=,iv:SemyA3qERi1lQMrk3S5zMvWJn+R+zX/ELfRjlulHLhA=,tag:6Qz+l73w3KSVqFSDnWFtaQ==,type:str] -CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:azYYUIr+jjnc08bnQQwzIVc4HgxmVwG10rLEdMhJJro=,iv:s+de5cZF4/N1QrSK3bYXvr8Wo4AQka08cMzK5W+D8vE=,tag:TdlZqgBc+Y7hdabmK1g7uw==,type:str] -CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:VOce1t4w1aH/6j78p8eOeN9ijamYZ0pgFWEkvkQBHz4=,tag:Kvc7d6lV55muFVebnbOaQg==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:M3oNL5nTiB1vUt4lxnHVkuekQVgYcORiVZI7uHPepq2cIZFKjuf2uBfHfCA=,iv:R8Oy6X06lY0sP91yZMmS2sd6MyUzIX+5P9t0LZ22bIY=,tag:qzxOtwy4Kdab8jcWf+XA5g==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:unPKZvOkD9NW5i/qu/+CJOeDw3A7/nVoV3T7Qw++059Fiw==,iv:cO9mI1M3yZ+6WD/898AU4JP2w9JAiNf6Dfuku57xygA=,tag:Osnh7hZ5O5/Vhn3vw7FKSw==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:692fnKaD,iv:YoJ43peJggSsAhXtInv3AEuHX5uCxqUR2AM0NYNu9Cc=,tag:MJD9KJPtr7MDO0IP5QuoCw==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:/zZsrJALUEVltMS/XK5n1gwn4KQ=,iv:lpRBEBlQvvg7lGuddPmqgqMCVgHaacQK3ToNwnlLzOw=,tag:7Ra1Fi3Tplq+AbC3PusNKw==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:afGWmXnlK2v3ovsNWC6LXyyTN2SZkGxyRtmIrCY+PhWdmnq/kgeehGc=,iv:H1bZ4D3DvEWrFPNtYQjD9/ES4qd0UxD+VJ5pJkqm9iM=,tag:0+IB/7bifExa0Y0M9aD5Aw==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:+o4L7IcjDsJFmSvU7TQX5hFoDHXuviZi+yiLoOw76sE=,iv:wJTcoR4hs+IS7DmsfFsPy1FV2wNZHugpbkPQ0PlA8MQ=,tag:/gpfMK/9vaDCq31uD5YKxw==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:N6oV1j+eV5/hjTO9G5Cic7kWGZJ1cw==,iv:ZLYJpjSrx5r9hYgf3g6OXAzuPKxwoaIrmWwnvkGSQcs=,tag:jrI6eQuEKniYerPGPYn9Rg==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:Us+CnNTBLETpGwtnPs/2KROsUGbPpknnu2kb8xtlMNk=,tag:SfX/hQIc8lSgvVZP3Qzsmw==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:U1kWzw==,iv:HX2tO/dJrRHNVTG7WEhDzSJ1TgdRAijUHpDfUEAZYHY=,tag:+O5bbrsOODLwaapINpvJwg==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:rzMTJ6WaNqnTKjbgE8ympwqq54uRSGhAB6XfI9kPVoCqZTgV1dROMSdgWKzPg8sb8kiAMEZW3hZXpfoMRvwi62tIBIHb2wRI5hkrkGGWEXI26pQaOpWQsHDQ/9Z6wBQnhX0TZ/vR7c2/9t1X89K86y25nE3WuI4ZJNrMD619xw4wcSkk6DycDV/KwfMitW5MmsP+YMBHtawMQcPvo6rk6N1vvroyry6pGlfXcUSaNrcYm078SnZTRP1kp2kXhybCbGvZo/aCBbvujQcBtGOd6GoZu5KbNSfhmWmEK6ox8C5uUy9QAUqfb+4k0+6WWtivN5ZXaGZNkQqUI9kyxwtq8V/8aGZ0BCsWvUocOHvUdufL/VoJhpEtgSB3TRv3OqEVTtmm7c3587ML55KP86E0kcjpiVLymVXVfDnaAyq5uhKLmea4l8j8mkpzkVWltTji3HWVWnhLEZDMRQa4xobfHyalOQOA0Hi6UW2i9PLfiYuHNO/86qi7+boChT2eAlgX9L68vC1sNXc4mTtcDtnuuLNKzb7UrJDlvG07VRidXvXmBoKjwgoOYfd/8p2PXhOklk92OhTR1ElG47y6/a69O6/YhSq0W3IoUzAuvtO6TN6BtjV7ONrOM4/HPN76nEKcaxh/DFnoacn6Ocx7Es4BoZ++L5oFSB+swTcA13BLPIXF95+D2kP7icvjOjKxazxJkgV3rsfa3+Vmk3clnynnPgU5puzgZ/mkrvz+elbpfe83DYFfYdMm28vt38uOyjBU0prytY1rMPrZRhRW8wOYj18FgKMNVOQqu1z8HOzqcJN3E151wpgoM5VhTYnJ1CUjpOcz46g+4NfZD0mY6kRuRJHh1ylojom4IOixcomB+l4GVB/jqoAyp420aclnkqc6pSue2qE29RcjiR0Gpu7VpGKd1aAKbGrfUCas7pxv8/WmPJ5oT8JoAn90KSSauAJ1ld7yJY6sd9v05EPJ4q/pY4ql5t0sK5LPHdhbbvd99r/VY9EmJ+/rDOePvyiTm3K7L4xsCwKi3XilmDDnJP5VsY/53gqlCziK4MqHaGt5bzhutqQZjt4Cmo/RBetydKQvkLHkEDiLaxwajb6JFLH7FNt412aKDepbItF901jYyN5QEDmaXQC4gywLp1BKQIcL+dfHi1uN7XZVaVSYY5F8SZ45a6DVTqas7fQpopDgSl4EHkbRVFFp0qh0RATlI0RT2OfWfxA6DSlrCqu9n8T31yZU90V8y2vqDU0A2WuizIueeXg2aorprU70ogRLaDG114J9uMJ7xyEpnkb+kz3mJGaqZfntUnNcEZ8kLeiA8qKk++9HkqXdZV9W/9QLSfxzqAFYpaTVipmmHTvFucsRzA==,iv:Oe95yrYzqKonTZC9YO5k7QXBmx3Sa4GEpyiFJzovgHw=,tag:UATqruNo2htBSIxywSIVAw==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:6NmLHYMAm31e/Oa2EIxG3s09fKrTT39nverHS0ZrbHd0ijLH,iv:AiikD3j+u1oaOty7J8fwYsWW5H/6K1+03XEgKqz4KcI=,tag:0BJWnxeCdHG0Lmrha0eTUw==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:2sN86sd16WtohlMDkjQ0yKuvGgr4j+xIuiY8ULyTA4Jcs/p/,iv:hlTl+AR6O9xtnnykGLTAuI+q3AFMr8k6aJ3Qa2FtlcM=,tag:yMsW1h//6ZlKiifXhK/Y3g==,type:str] -NODE_ENV=ENC[AES256_GCM,data:W2zO+aQ3AQedyw==,iv:V15cRHUEIHVy0Y/nVJutv05OKqjwGD9VmAG6Mt6+xe8=,tag:jPT6EgvFks3NGtKQZq8HlA==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:IMO8So3hBgfBlqCV1m6FZvmo3uY=,iv:14Bq4IQE7I6LP4XeZdu4Nq8MKkBm7/uaL47gFqIVQ5s=,tag:V7mJq7liFavpyetLuokMjA==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:qW5FvcAneutiqXg=,iv:Czx/nxOW+usXbKREDD3fXACZ4agR5CfOlvRca4hqai0=,tag:PNF6sbkagOETqfbZFQgC4g==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:qbqH8J8cJghBiNG+Rrb5CB0ltCxW1kp2I3MARAZB9Gt0bbo=,iv:aPsyr2nrMneu1ng9dEs/AlRktyXaj36SKIIGzTE+KHM=,tag:OpiH7Iparyn5X16/W33AdA==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:S3gFLILtjNIW06pT8CZOJI9VRBmNQn7FiHp/qPUSRstOZmoMRMb9sg==,iv:eJJ4g/afVUiJgGgFl31JvOs4Aw7iCD0dqyD59V4M6wA=,tag:keLFkQoMt5I6xePQDcElbQ==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:k+7bpMGRHSjjfkn4um4nj7h4Zz7ha6RZpFj2+tI8HTyoJRsmMWYwYoE7JM1tnrrkLJPX5v8Z8Gx0IyYpYlpdtQohGGzEqmV9FG0weLI+WzCOMMi4j4fHzQRLC8W/QCJYvUhY1FcSw0qTtJXkGqSwrJKMs2XSkwN8SgG6PQGhnoqQKFBG88SQWhVszkPc/X+qVslGykcMPURnUihTyIFf54UW5kLrCVpmZ3Wk/gmuKQ9YNQNaaV+EMWR5CQ==,iv:Crd3/4Ish05R3nKDd0SxW6u26rVr9SHHTGfsIS0Kd7E=,tag:HGSAd2KTUtjSyq1lBjsLyQ==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:zAGf,iv:sMPG/+O2lap1vfx+jIHRvPQ3/bGdCywABnZfvJpBAPI=,tag:DyLuuy2svsQlNz9yG7Hhcg==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:C5o=,iv:te0B+QUWnIOwnBhKq9lRpUhkzpwqm9ykOm5VKaXqEKg=,tag:6IzPC6t+VxVKt8f6vl0dig==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:PaZb6Tnx/EuLix8o1NlLXSdpXeHoRHTxaz70Nkw3vgomscx9dT45UOUqMI0Mz8sRcW29YMqr4G1LbLO10MOwlkgJpECuKsdvbG1B/uTa3Q==,iv:Qak+46OGRxwtk7br6EAuRHn3c2FxBiODugoT37htNjU=,tag:Jsfs+rGRWgA903N3EguHaw==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:/HGDBiGkHHBfuS8zmvfZqq3bFnZ1z/xiag9vlPjT2KrwCw==,iv:iwQCsrrs69jwQW0RoHXr3GT7WSU7rGlUSe4b+MLY6uQ=,tag:BTy3FvSKRhdy54Bwk4Gypw==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:PzXVffQ4YzY13mI4DZTpdaTivRKKXPoNCOKmrfUVeYwhXuWjOeRPr6xyOZ4=,iv:L3THYQEciHtKVSLeyzcUHoDjEGvUEeXQutLnNkbIxTU=,tag:lK4vvOxLg/Z1yUBJnjWrdQ==,type:str] -SMTP_USER=ENC[AES256_GCM,data:b5mxkDDZn8cHzzxqg3L9yiou4q0=,iv:e4wWIYD6HpV38wAjiEr5OtD49xcP9+hAhT9fREHJ/bY=,tag:FD62PoZxRxk/UIiP6PRFtA==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:GQ8X1rsoxzkizLXvo0dlr6AEHY0=,iv:a8fIPhKBBqFiWmYqzzK4P3nsQyghUAVjTziZV8lEfqw=,tag:O5Mu5eKl61vehsKtOERvMg==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:tP27hLtMVBtvNkp7OZwnFgnCMgk=,iv:dCfCJ14DzqvGIun3w+WkBTw4ZtXLffxdSH2+w/gkpVY=,tag:0tdEyObg+GeW7JKyGZrgGA==,type:str] -#ENC[AES256_GCM,data:qkhNcd2cpoR7xna7lSMHVYyRkwU=,iv:xuTXXggrRr1SIxoL4g8g7aHNq3Zn/eMZDPGzpoWJSPg=,tag:YrHS3JWvk6AMAaKP7T6d+g==,type:comment] -OIDC_ISSUER_URL=ENC[AES256_GCM,data:Y6ZZZg/KO46M4xN4Itrk+/RKVqch,iv:QzhlB7rzUYQ0qvDQWfpO4g3cAFr1CZv/kVWh8hkTZEo=,tag:jrqQwc6qnAFicPpCocAoDQ==,type:str] -OIDC_ISSUER_INTERNAL_URL=ENC[AES256_GCM,data:GLZtsu+CeDo3bLg+qpnPSr8Vfsxt8Q7SMkkK0HBOgR0=,iv:nLZwEdRR+Jbleut3ydLcK6dkeeXGQr19VKeOWCCMRU4=,tag:n7U5+a1PqTaB/821Tl2HHQ==,type:str] -OIDC_CLIENT_ID=ENC[AES256_GCM,data:u/QSy8R47Bkh,iv:M5UDl6iM57cWfNfbS7c77o13il4PoKmEBhughUA4b/o=,tag:4ejq5eBXuPv4pjeZslgejQ==,type:str] -OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:1owOMDVHC3wU5lISzIUvKwyHvyHkLKg36MRmctWRI78iFK7wI6tKebaQmIOwwlLdvjoVP0wk6nr8vFZHYexIDg==,iv:aN4v5vsdkH8qA9YwsQQXvqDXszqBeUkAv9OQixcqqUA=,tag:wPcjLdEeEivqaX4RgbMM+A==,type:str] -AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:VcT4eqKsqoLmYWqJU+YCQxNYjangyvFfslBePwROz8Y=,iv:GyJxeKs6ZO3ZXggu3m9Pc6GnBlXjoCU33cBb3SPrcc4=,tag:6IziFPglbwIPXmURwTeyXQ==,type:str] -OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:AfoxH2PbtJMT1YiWgVhu+zH8PeGn,iv:+n5wB8teZAQ1abEKZOpJL2/yQCW22GHglYBWMZDKju0=,tag:+EgWhvD22GWPQrE19VMCOQ==,type:str] -APP_URL=ENC[AES256_GCM,data:nzoHDC1p4LOJiT162qcS6ejrj2Zm,iv:206a8lmEpzWDQhI+5ASqwWg/a9KAs3MnWiTwjQ3YLCA=,tag:H3i1tj0xeF6rAxtR2sIVtQ==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxWXZQalUyb2JaRFZzY2lW\nMTZzN2JxdWdZa3Z6NE9mNjZhVFBXaEdIUjJzClFlTjF4bDFudHIvMHdWQVVyWDNF\nY3Zvc29JUHpRaDluazlDTVROeTBiS0EKLS0tIGRLRFpkVDBJYmhUdkhpSFBSSWdC\naGFHNk1yZUxhRUtGZkt6UFYrUGc2Nk0K8MU6bWI+2I1BFuxrgJSA0yuHzIES0XaF\nSpGQXVQ9pSuMzXVc7FuiEBEt6iQN/rfMhgeNSlDH3/6P/s7btfakpg==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:vmPU3lHU/tv4uHYWiiOKp1asX0NBe7NXD3iKgrvsuaXa0PYNIP8p5sqlwIAhpNiM7MzAtopklMGoREkeh/hfZg==,iv:XGgbkq8gwF/wVhifnzeN3PCxFioZF++pnVEadz642iY=,tag:JyVndrsGO+v/Ma/ZYSiEQw==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:ZSeXTVCkT1m71IORE69J0HIxJwcr24PrEzj7wYugnDcOYmCCnwSI2H31H2efIbzJMdkvErQeOgPJWp8A7Ra13Q==,iv:uVnT1hNDCaYjcg8wxWZQ6AlQ5VwQjlbHPKMT2t3So1o=,tag:+7d8pBswdSCdnnl193PJqg==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:LQlcHSlETlGQM/GawibTC31kCFg=,iv:60Qlzq1+IYXY9MKz7hgvvuPB9vZEQKNO6Ua4xGJWUO0=,tag:62/hCcDWy3armuCQdHIVMw==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:/GCqPMr1zwqnedfwj1WguzZSU5Q=,iv:d4oJqLqo6+Ovjj9EIosi1RqdS65DSECNdMcz/L94QD8=,tag:+mbzZ5UORYBrz1AabLnJ3Q==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:ajLV95aU2BLR1uHqBIfD6Yry7rwlw87Eo1rfoUYzGsuUvx05Wt4T1g==,iv:ivwRjFTeOOPiMMnOMMIMecG/kXDl9QaiOIeCK+oP7NE=,tag:enPLdDnhq+z4G8RDUMY/CA==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:a2J0BVfu6i3nQX4WofCragaCzc0LkfN6CQmCkgPBhfC7Mdu4hiy4JQ==,iv:8F1JwzY6cTwjrU0HC0e7R8fxdNHfKJGj+WysuX/jUAQ=,tag:ne9kOov6T/ty9BE5Y+05GQ==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:W3iyk0AN+tVd4SzlxbYQEuEMjbzMu4l2M7ua4p1SxQ8TfnxbXzosIRbvcCg=,iv:YBwhyh8Gzv6XeDmbpcBWauStQOVItviHG/R4a3UffPw=,tag:uWicrCoX5dFs2G39oBzWYg==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:ViuOqD0h8RtZqhAlCB2BbyfDpfG+x1eSVZI1PJL0E8p+J8MQID4DiAVzOJLA4GGreQSlKn0=,iv:taYaOtNe/3bHntVVIQU+nscXCG/q/uDvKLt9W8sGD9U=,tag:2yLBFZoyFNzX9OeJf8aBlA==,type:str] +CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:PZks5G2lSJJFdRtKDptHlePs2riFjUqMspmBtRr75vxm+uQVXrGF1/N56mAe5U0ZO1oJpu8=,iv:yarrf12an6PQrGCiczzw06QCmYWs0cXqrse/G+lXIUk=,tag:UQ75K4WivIGiBRxql7HCpA==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:0QlSEXUurZPpsPlfx65PrvSBOTKAdjco0LAgeyX0hdc=,iv:FqAkq3Nqdr6iERDvLGXW6Nnirp8tdANyLuOhZxfaDCA=,tag:uXXshkIjgnOXRDWXspsQkQ==,type:str] +CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:6/Nu1xWchI5W34OTvSlvHVpXZhS4GCFA8011g6Tjgzw=,tag:MyZow/MK7EewXkuAMl5Yog==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:anltPteImagViH3djsg4fN7v4xK/tIcExu9T3taE2r8sqFyHxfdYZCClgFE=,iv:9chFnkZ766oWQYA04gxC+n+7G50hqQR6H98fQ+p4gGs=,tag:JCNx1QYma/n7aYntZACXxQ==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:7amT7WAKM38jDoqwLKBK2RNOtDfPbKbOMU1JlTDXwkjXig==,iv:9GQ94roa4eTk4rOX4VjEupKUB2vX2xSNDA5k1wfEmRc=,tag:ytCFs3vumChOQ14TM6BwlA==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:iS45vatF,iv:g72dTGi2zZ0h8riCdsm/nhus3XSSLmPZ2SfmZYI9bRk=,tag:d//HnObxlUf4eprRrqOaWQ==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:XCnrlpVdq22MYxw4yI65uJA/9aA=,iv:dBY3gR8Jn/q3WS7REL2wC6L25QAYw3NNR+GqoJTb9K8=,tag:ARxg2rv7XaqSKAISG/ykBQ==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:6BEEupBJYy3C/We+v70lwem4/5NlwlICjtIFrMBITsn7w0qonA6VYYk=,iv:/djVckUZXe0zDzjbjfzJp21dZ3taKrSqwJbauKMgItE=,tag:JMVY2wd++RxgP2l5uK7HZA==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:/k3XX7eXLwU2XdTgZAuV7vNkUcAVCyA0iKTVLwThKp0=,iv:x79N4cxIg8T69ywLSOuTS13iquzK4NFzkq/FvkdboEo=,tag:o7wrOrSlwdbzHRw9mZHoJA==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:y7ktMWOsZOSibRlWug0TXZTKhG1avQ==,iv:dZI7D4xdagjh2eRbfxC/6rGI8WsnUELhXx1ML9OmW3k=,tag:lcAh7N+D+mDCTVOk00sG+Q==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:SzAGF2BAQZKKe3l/JY5fOBgHEIE+hhefulU2kVTfkPo=,tag:9XdYS2jjtVyQHocCa4y9bQ==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:xga1xg==,iv:8qKeQI/rbMFzFCzMbGDIEGo97fc79jh2nzp82VemUBM=,tag:WY7NPRQWyzJfL0u3VcLuwA==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:gW4YxR+Yda5+7cESOjtFQ2HQOv5VeI10g0DWcUzPSiOWo7/heP/ueLq0ArZYz1GJzHSOkNVhOLJ54jWfnlYnAM9Sw5siilwhPEImM96lsHQa1cnGtEtp55wbKXBNlKEOKfP7G9RoYL/7XLHwOgoSQYVpocp0ep9NuiVuaDZRQfUx8fw24TePRRfrOYJfGAxLDkid836e6iSlTaW0HKhD7JV7b8sGK28A77cc7eL7dtXvAxIAMq4NOlCcBCm396L133bTjveKoIpg+r4JDiFRTH2/TmQvn9TzJ97s6azK8IqvvoMSrXsiLKxrIA649c+0GcOd4oUGsBB5A2vkhFaGVoNf98O0FqAWT7BaJ2wm0zo0DXu46fWbvXyRU498/o2YXz7+gOhXxoFso0flNNbbxkfALSK7Oezn+FKoiZ5/RJQMkvC0rFuRSiUDhVrq7VzbFwkrAbSOVzzXJUk/XQ0FH7LGMh7YpblxLM3jSWmmvVY+jV6NHDL4oNeI09RjKNjAoh3NEjQAG/Gyl/N07mqwBavtw0jKiu5ugyF0hQdkbx3wYNfJgJzvV7gjfnMfepvGGp9s0k41usI3juCyvXELmquX/cx+hmAg5dBK3dIlIXklusildLOtdFqVQDsyfvKq+IqMhb+DiRy5tLJZnZm2MXI+1YdkFcfF7etqVKlL1Ag9rKZboB4WH5QLgsF4mmEZcAJOgJtunZItVS3YXpQqFyrAOzmzfj9bWa0mCHsz4A0CsqeEuETnPdEUbmJUHUfKN1xvHR5Ta+j9ase6FRNtOeEKtYzvSFHUSr6ane2f9joxHzR1CyU3X1xs59+8iP4nM1q1SVj+wmz7CScWGe66GgOoQahQ5sBtWlkgSnMeI/jx5mmziIK38eG+NjHCCrR6fOrIDZL6fCARTKhLjWzgrZvGvb92PIY9EXZEc7UvQU1Ni38wW9U+JhnGHmf3HBrq7SOinTrL/Hs6DN1RY6SfGBR+VPmOt3o/0q1YHn5ivSk2cqpmHxMnnw7Tq+xh9GXBAzLkFXE6N9PRkpay8OGPseaTQMx5NEYJIru6obsQ1/qk5vmvFQNJiTSF/RPcQnjgEKGi0A1mDoRaGoBf01FFJEaXU8WTDSHgS13jUIH7rGjouLqzKJrkn5d7k//K2zyfNDPU/IRw2Wr5DZOS1t7r4MpVQtUDLcbaTr3+lS6uM2QWhyjIR093GAyY8OzSCcwUppBHAQvHnaexYgKyfvlBO/vwPAeOv+pnBlfx0ZPclkLks4fbv65izODVikcyUzQZTIsYz5wvusX2PF4lnLEksJEFlu1m9bru7iRVtULmnj6eOIW/eMjdmnni6yu7qXgVVp/UvjavaFxwy03sAmhf+Q==,iv:S9IuT1vtGguCoheTPUNotQC+8KPxW4HLdQaNI+XNI6w=,tag:GMO+7SIBLkGeF09PmRpFoA==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:8vWDBg9nVQUeTLQ/DwqaAWEVP4WpryafMQC+udEA/ckgdCre,iv:L0V4mpMPe5YVEx9nrDnboCzEmYjt+S5joDxzvXKt2vo=,tag:9mL/nWL0YX1750y38LC1Dg==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:8vWxxNt1/0Hk0zo/4/oUAmeZca9WBVIF16o73MNwyehTrMcX,iv:dU2VGqct1kAxZ8NX1JSTuhmRli4bxFI3p3xylgb/nKM=,tag:HOd6h7D/jchTS77OtovAug==,type:str] +NODE_ENV=ENC[AES256_GCM,data:ekAEPWx40mAp3A==,iv:4YNIjtdQYsohOBzf9jteFr2UMJSd5SgPSl9tZmFvHIA=,tag:mRCuyTucOZ0J0/ohUnBSBg==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:q3HNHRVnTlnYwWWsBfHHRR8I9so=,iv:udzy8NbX76rs3NcT9CLUSMcZDvh64p41t4Klrd1z5Lc=,tag:dC9wdIjIHo2yvy+w41xGqA==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:cAI8xshcHw+7GHo=,iv:4AZT7NkgXw1zGe3bK+EBJJgA9BHVcvUXBibL8ZT9vGs=,tag:BS0JeE6PKD8CEnKl8aKSDw==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:rDbCz42mFtwgbWuP+VX9Uvf/PaXwuYcgtTHLA0SGjAgKS9g=,iv:vU8z+pioDmZa+oazrdnp8pgWpuvVk+Cs4IYTEvyGrP4=,tag:69e/EJn2P94p4iuQ8cfySQ==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:f9S/rmMxCu1Fcp7KBWgYQ4KtWsKujcUlxkEDV3bEm4Q0wZelSb9/kQ==,iv:gWSKIBgUkAhFiM6zE2Fb+i82cIMs15KGlBxt/ngySw4=,tag:XV1wTYeS2aEye4aL9OsJmw==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:YFeiKitANaqsXaYwQ0FHfKqQ27LxnSiXyEtdrjhfcScF8sLO60e8K1N/1hixydnUahfidcXlWxYSmPJVEgrH7mmlTwicFnY6nqM4yDqQkRm+Jm1Eb0ONY+K/pXaUBrCCJnK7cJQ7TEBCQxTqSdhoovHsSbvS1lmOKkIOwLqhdQ3XfFBf60fMMyoU4hPNhgAhf+DKlcPNjytvg8qMQxp7RZAvt60nHpy5m9yqgbtEZCTHWPEWGX2lLIfvEQ==,iv:063xsBQ6LPD1QBR5kIaZVfuiFDEdDahJKuqFgYf+pFo=,tag:6VTaldzayz3Wff25aUGmcA==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:Ywx2,iv:pcEOiwHLtBATLcNaMfAdprhPeYDa0MczJtjD6Uo5IdQ=,tag:xIm6irg5r5veeHyuwPGjhg==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:QsE=,iv:ISEnBX/SRnTyg0QjN5m+kds4ebbSBzPPBIjTx2/NpiI=,tag:n9cs27mv1mdcMStPWwfTRg==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:uKHlfM30igYadsu52+JFN7a3ByGU7mRV2CdKOQpXhS4k8LOPcl7dTid5kjQMJgEa241Kw8pQXZFcbd15Bw6UGzWOk33G1qJBwJqAmIGlmw==,iv:PkMvMsPBMRcDkkCw1ulgjdpntsGiohOJZYyB5nWPb/4=,tag:4qPFhlJgGzoGMe4Bmj5yvg==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:o0FC0O92OXGYw1G2lbgk8y4zlidO/okzLf/rJ37kROGWGQ==,iv:fjDmE+pqkn6H6dMtu1WXdNxqWFsoN1y7BDHvY2wVzSM=,tag:fmcqc2e3CAEyS+/gH1HzpA==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:We6oDAK2hzo4bZXadT18aQucA0Ov+8rYwxHvY1TcbJOSPlZhnd1ZzH4LTws=,iv:3q2lhm4Ld7VjGZuqiQqfQbSxZ5/gKX+XUlC0PhJ/bPg=,tag:DHONEQBlGvAQpAEf3TtbMQ==,type:str] +SMTP_USER=ENC[AES256_GCM,data:QOsSHPWAAcOanbCpjVjSmd+TKwA=,iv:LRNNSzeH+JWIsv9o9HavsImP9iMlEaunVzTSjoyppYM=,tag:Olj8Naipsklgux7GiorRfg==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:wR4kcAg4UXgeFeNLRmEGBgz9Ky8=,iv:5wI3hOCVQzz5p7HSd38pdXfDg9/eIr4K/wAb4JIU+uI=,tag:Se//a1wQ6MSmV6EDoShPPw==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:IqwNy0Z0srh5AkJUCFPdM3Zd5Eg=,iv:9lBDBRQDkbgBlJ7MXZvuZ2C0b/v5/MVv2SIg/SZSOng=,tag:qXQgLiFbEJ6LxcrzpqVdpg==,type:str] +#ENC[AES256_GCM,data:uOR8Tz7jBIx+wF7nSrZlMfqGDM8=,iv:5HvS2T3lm3fHuShMpdnDr73z0QJ/SdxKSrPtIgERMX0=,tag:zxISO/Tw4IZarC3UPXGWwQ==,type:comment] +OIDC_ISSUER_URL=ENC[AES256_GCM,data:PSudavCMky5eH2sZ5bYYkXY2P3rp,iv:8fix4Kghl4Jcmfbf0oE5qp0Phf0F+qTOy4IfnDGGS5k=,tag:nmFwkHD+3Nx+NuXLZ/Uyzg==,type:str] +OIDC_ISSUER_INTERNAL_URL=ENC[AES256_GCM,data:UtYj2MhDBPxpMbWxjuM0XXD0fxnLGC4IgGT0TnT935I=,iv:AhNqzyDTF7u93LqAdv/FJIZMx/72KiTF1tqxiPaIwQw=,tag:1jHCTlLCUE7KnnwrDO/IdA==,type:str] +OIDC_CLIENT_ID=ENC[AES256_GCM,data:J7NTUmrrlOg5,iv:aP4/i0H0Iln2E6PwCIx13vjV+iWYN6VVlR6bMF0AU4g=,tag:AU3vOsDFriXOtZGHMFklqg==,type:str] +OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:NO0F7AGhNXUDkUx5Nd7OpZiMWrTLv0hPmz0Fw7xrf4z4pTGZD5QE8BfDm4owWVUQfyJmuEjDcGUIlOQc26WADQ==,iv:1KqmxQsub9FOQ5w49s22ZahlXBIDYZZAd4SM1e7+VEs=,tag:kUJqiQyOuF6WjhgW6N2ECw==,type:str] +AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:vCmAiIh4fggymeHysxZrZkLFztOC+U708FP4Xj+gW0A=,iv:hJDA0TBLySjz3yrne8XTwWehg6/YOhlLIzV48TA1dHA=,tag:/G5GVsIk4pM9zdHHmhukEg==,type:str] +OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:NJbzpnlXNE6Cy7FJddJUtxP0/CJc,iv:AzNFuptMEGXCqmQIAe/1tieG2kqWhbaQiUVbpdUQbtA=,tag:NmDfy1vU/uuJhIaDjed+Qw==,type:str] +APP_URL=ENC[AES256_GCM,data:AfjtxGVno1ZqtgEGciQ9Qnhmi3I9,iv:lSwHAIcTCio3HGeZz6BVq90l+r/uzoKqHNMBYPpAm5g=,tag:wGEIy1DgtwZ7I0ZAKupz6Q==,type:str] +SESSION_SECRET=ENC[AES256_GCM,data:FTaWEB3WSsaEaA63yuxujFFK3wbHWuhKXrzVGCLqgvF5o9tlgZFFgezw84auuMwYgIcwz0ssm/20qg4KdPrMHg==,iv:lRCRQzjYdtlABUZxSHwA7rvlrpw7XAwiqVVubS/V+78=,tag:M3ApDMgeaT3kJNoJuf1ySg==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpUTRNdDRlRlE3bno5VjRi\nUVVMQjgyaE5EOVNlQTVlaHk1c25BTkVZU1V3Ck10VUlXUUd1NzZBWXQ2RkpTRDdu\nQzhIQ3NSUHJkRGh5OUkwMm1tTi9tRkkKLS0tIGdJUVNHYkREOCtycnY5akNpajMw\nNWZJckRwcHFRdXozaUsxWjh6c2MzQ2MKy+M+LmJzEgZEMSICkjKIaVknDibUVF2M\nYdz7OsyC+G8VDw6dhtd/Bg5SoGHm7puPfzSDLMaaKt9dAtKaLF8RNQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBVkFkbERML3JjK3VQcVdK\nT3JUMUdWbG1ldm5NTFRISjVSR1RlUEpMUFZvClR5K1BuREMxRmZoVVBPYTRSbm1R\nYmphWTlXdk1FMER5TkdnMEhiR3Jya00KLS0tIDhJMW1HcDBBcm94M3M5VWFVOVNs\nNHY0dFhZT1oxckNoQzR1amx4QndWYVUKgAaoyraxVST7kBjDgtJOvZfaHmP9X785\noOic81S8FsZjmDjE5d6VoxsHp62pjDkUGzyqiSGIjAjKczXiLo7ewg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBONkFWTUJ0UTkvaUJncVJw\nV1JRQWkrbGgvTTBSRWRLNE02Z1h1cEJxZjB3CjJmQ2ttZkVXZVl6M0traWovUW9s\nQXp4azNNU216UmFLY0JDTnBWQ1FDWVkKLS0tIEdYMm96bHQzNndQV2VVSTJzdTdh\nYVgwdlJCNzJQYmd1RU9WYkNPVVhMSkEK23BRpHND7ELaN3DWVhOLzW5NPumGnag4\nxkzJQObY4KOIT1cImydgGLfqIy/AmIzQEHmW8yDPAcnxa/zjBHV8gw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSdDdnM1gwR29XSVZoOGF3\nd3Y1RUhpdzllWTlZUXNDTUsyVm9HWWE3MWxJCnhTKzNJY2tKWDdyWXhzR2Mzc3c5\nVURyUE1hZjRranU3clhkVUk0ZVJhbHcKLS0tIGZXMzArS01FYTRRSU9NVmVXU0ND\nZlh3R1JaeXVQcG5ZQ0Y0SUp5NGJrVXMKe/Oo71jhPQNm3owxPHz9Xec9JbqiEjnV\nay9cV5nMINhEPfK3e/R94KbzJbKzbmPDidQPBuMunJ7b91GetspTKw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1N2h1ckZhSUUzcUFuQ2Vq\nQVRuMEVsWWxseUpnWERWb3ZlTnJYZ0daSm13CmlONDFyTlYvbjk1cjA3SkpnL0xh\nTkFsdWNzMlpxazAxVGpJR2lUMTI5T0UKLS0tIEhFaGYwbTRtMXhtYkdDbmVtZHh6\ndjhTQmtaSWZXTENNQ096c0hKM3h5OEkKAbS4e/pD+GTeLBX94qElySkbay9LFIMA\nVmyimb5Ig6MjUZec6gfUaZT4qeQUKv7hDviCjo6bmwGCtUlMC7cNJw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUVG8wZmgvcDBHNUhOUVRl\nVzRha1JRNjhxVkFCclE3YnBPT1liSmlPcHpjCk1ySDNZcEtUVEhBQ2RJNEZWaGRF\nK2VIS3UzWG82cEhJV0w2eit3b3RvVmMKLS0tIDFMUFZZZzlWZ2FrWVBlRE9ucUdI\nczB4RXliZ254ZHdPbTdlMklySnE1TU0KQKS4xxMWocaTI/7mJkoKLzxaBRq9pTKt\nzwnN4ywFYcyWuNa/qs/ZO3H3uUTZC4DAHnKz5thcTUfTrT7tc9Y8lw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBNkRXbkwxeXlXdGJocGdl\nZXN2M3N3R0NJQlBDdnU5S2NVZlk1SjB4dENrCldzL3VkRCtyWFNQb0Vhd2o3ZXFG\naXQvcitkc0RQUEdyaEtIMGFBUzNFeW8KLS0tIDlDVHZucmsrUUJOS2prQmZRei81\nR1hVdjdUTEI2KzB6bmNqWnAyekNCRncKLNRRTQZ022ziLr5XXxMo1qMXvwOdRpXr\nzewFarEj7quuHFNKLZOakb60hcBALBZlaxQsFYO+xNP78LxgtsDAIQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWL211K3h3S1NPZm1nb1BG\nRWYza2R3OWRLMGFRNmtMRFRYSjdlUVZOc3lnCll4bGxpNVN2UFZ4ZEJnbUpxSW9D\nNFU5UTZhZVk3UlVSemVvbHZVVW16TU0KLS0tIGtnTldpM0VzcmVnMk9jOEV2c1lY\naHNOR0RYTTBNR2ozRmFod2RrWS9JdVEKgjsrRHjM/07zkVOHeTrpE49/32LOYSAQ\nw4YO0oD9G+Riv0hB+bR6K/XmvJDlfu/QOkW1PgkimIDQ7ZEnl1iiPg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBZGN0anFIUDFtajd0V0lU\naFNiRkhNUGlqd0hLbndTd2FPN2MxcFdPdFEwClV4dXdWeU50ekpTaDBlTUZGRk9k\nTnFWQzJpcldkVXUwK21MYkdiQUwxY0EKLS0tIDRtVGhiR1RGOEZlZFU3UTR0OEVQ\nZnNKSlRlY1J5bmMzOHVHZzE1ZkQzU1kKXP6fhtgbu0AF6E9DVk1xToZOuQdK4qsa\n4Um9JVD7TXFyQPDY0WboY25S19Tod5ozPD+3h5NSc6fubrtBvaWjKQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsVkxWRnlxRjVqZ05kSDIv\nMlFSelhjMVJRVHpWeDlKQzQrSGxWenJmdW1rCjJiUWJZTW1yZkVUb3M1aHYwaUVI\nK0NmSGwrQ1dodzFWcXozaUFFWURuWEkKLS0tIFdteWYzQ284aWY3M2lSaDZSWVBG\ncWtxY3I5VkMwYmxYT3h1dm1yYXQyakkKW8obLgS85L7S3sSz4SNmBab34n2HPpMP\nUfwSIEBK50BwZxNikL/exYQq8N537xX/CbLR/9Z5kXjSg+aciI0cuw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLTkRWanl5RVNTTzFXV0FE\nTzFxOElCS3haSlM4QUhmenVXTmR1T0JVMm4wCnFIN2xLNktQY2dWNTJ2dGJ6SjVr\nNUxwZ0kzc3JRZml2ZWpQVVR2dTh3K0UKLS0tIDdXb1kyL1h4eFBNRjByT0hhcWNJ\nYlRvTy94cWlZRTZWQ2NkbTdsK2hqMjgKATXzpo4uJYTJu/xm3V2Vv7LHdGUhtGnD\nsR7VTx1aDeJaLqPAiuKbffOpWbKsOtAfhMrrzj21O+zjXlQoWilNIQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-05-20T17:17:29Z -sops_mac=ENC[AES256_GCM,data:uCaCv8548cz57JodHdPc7UUqGpkELBQIoaJpGYivXnLFWz42C4xHBsWB/jJ5cEdSz5JrLxIJdslIpZt+hhAe7PKry+YInIficoVfU9MIzo3FQMJxkrED9wgpNmn3NWWa/S0U00z/qp6KNVmk2J7Bqt3JB6MU/y50ApVyJ7rwtvY=,iv:W0mAYXsBM4M40mTmFrz6kRjU7DE9gBaMA7TCx3JskFQ=,tag:jM1+R5/N6ZTjR7xil3hcSQ==,type:str] +sops_lastmodified=2026-06-30T14:15:13Z +sops_mac=ENC[AES256_GCM,data:BV2KMd0AX/5nf7Kj8EDU9/K9+p3zt9TYQpkM9ISxdRmZoFZRo+q3utMSuFqtr/FOZodJ9bWd+Xb1qEoKonCTX1a+HxI5/ZcqshacDsv+xij3Y9dVT1aKwjyLDSOK4OUuw77KoNNsOFaJ3eEuK5fikbvXOfvaGoQl9kerNkyVQtc=,iv:04PE88CWFm5Fi8Q5/ceYMji/meqLsHi8qnuyx5Ib0/w=,tag:f4RIG1ekfw2ogDDZGUS1aw==,type:str] sops_unencrypted_suffix=_unencrypted -sops_version=3.11.0 +sops_version=3.12.1 diff --git a/infra/dev.sh b/infra/dev.sh index e59b54fbad..8a96ae2d06 100755 --- a/infra/dev.sh +++ b/infra/dev.sh @@ -3,6 +3,31 @@ set -euo pipefail cd "$(dirname "$0")/.." +# ── Auto-decrypt local env if needed ────────────────────────────────── +ENV_FILE="infra/.env.local" +ENC_FILE="infra/.env.local.enc" + +if [[ ! -f "$ENV_FILE" ]]; then + if [[ -f "$ENC_FILE" ]]; then + echo "Decrypting $ENC_FILE → $ENV_FILE ..." + if ! sops -d --input-type dotenv --output-type dotenv \ + --output "$ENV_FILE" "$ENC_FILE" 2>/dev/null; then + echo "" + echo " Decryption failed. Make sure:" + echo " 1. sops and age are installed (brew install sops age)" + echo " 2. Your age key is at ~/.config/sops/age/keys.txt" + echo " 3. Your age public key is listed in infra/.sops.yaml" + echo "" + exit 1 + fi + echo "Decrypted successfully." + else + echo "Error: $ENV_FILE not found and no $ENC_FILE to decrypt." + echo "Run: pnpm secrets:decrypt:local" + exit 1 + fi +fi + MODE="${1:-dev}" case "$MODE" in diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index c3511a261f..19c23f08ee 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -23,6 +23,7 @@ services: PORT: 3000 DATABASE_URL: postgres://appuser:apppassword@db:5432/appdb CLOUDAMQP_URL: amqp://appuser:apppassword@rabbitmq:5672/appvhost + PUBPUB_LOCAL_COMMUNITY: demo depends_on: - db - rabbitmq @@ -81,13 +82,11 @@ services: -c work_mem=16MB -c maintenance_work_mem=512MB volumes: - - pgdata:/var/lib/postgresql/data + - pgdata3:/var/lib/postgresql/data ports: - "${DB_PORT:-5439}:5432" networks: [appnet] - - # cron: # build: # context: .. @@ -112,5 +111,5 @@ networks: volumes: pgdata: + pgdata3: rabbitmqdata: - diff --git a/server/kf/oidc.server.ts b/server/kf/oidc.server.ts index e145d24893..06a796e55e 100644 --- a/server/kf/oidc.server.ts +++ b/server/kf/oidc.server.ts @@ -25,6 +25,7 @@ const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID ?? 'kf_pubpub'; const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET ?? ''; const OIDC_ORGS_CLAIM = process.env.OIDC_ORGS_CLAIM ?? 'https://knowledgefutures.org/orgs'; +const OIDC_ROLE_CLAIM = process.env.OIDC_ROLE_CLAIM ?? 'https://knowledgefutures.org/role'; const APP_URL = process.env.APP_URL ?? 'http://localhost:9876'; const REDIRECT_URI = `${APP_URL}/auth/callback`; @@ -416,6 +417,7 @@ export { OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ORGS_CLAIM, + OIDC_ROLE_CLAIM, OIDC_ACCOUNT_URL, APP_URL, REDIRECT_URI, diff --git a/server/kf/provisionLocalUser.ts b/server/kf/provisionLocalUser.ts index 73611768dd..c66fdc8cf6 100644 --- a/server/kf/provisionLocalUser.ts +++ b/server/kf/provisionLocalUser.ts @@ -5,6 +5,14 @@ import { Op } from 'sequelize'; import { User } from 'server/models'; import { slugifyString } from 'utils/strings'; +import { OIDC_ROLE_CLAIM } from './oidc.server'; + +type UserInfoFields = Partial< + Pick & { + [key: string]: unknown; + } +>; + /** * Look up the local PubPub `User` row that corresponds to a kf-auth subject, * auto-creating it from kf-auth userinfo on first contact. @@ -18,12 +26,17 @@ import { slugifyString } from 'utils/strings'; */ export async function provisionLocalUser( kfUserId: string, - userInfo: Partial< - Pick - >, + userInfo: UserInfoFields, ): Promise> { + const isSuperAdmin = userInfo[OIDC_ROLE_CLAIM] === 'admin'; + const existing = await User.findOne({ where: { id: kfUserId } }); - if (existing) return existing; + if (existing) { + if (existing.isSuperAdmin !== isSuperAdmin) { + await existing.update({ isSuperAdmin }); + } + return existing; + } const firstName = (userInfo.given_name || userInfo.name || 'New').trim(); const lastName = (userInfo.family_name || 'User').trim(); @@ -57,6 +70,7 @@ export async function provisionLocalUser( initials, email, avatar: userInfo.picture || null, + isSuperAdmin, hash: '', salt: '', } as any); diff --git a/server/seed.ts b/server/seed.ts new file mode 100644 index 0000000000..f5a06f3119 --- /dev/null +++ b/server/seed.ts @@ -0,0 +1,107 @@ +import crypto from 'crypto'; + +import { Community, FeatureFlag, FeatureFlagCommunity, Page, SpamTag } from './models'; + +const DEMO_SUBDOMAIN = 'demo'; + +export async function seedDevData() { + if (process.env.NODE_ENV === 'production') return; + + const existing = await Community.findOne({ + where: { subdomain: DEMO_SUBDOMAIN }, + attributes: ['id'], + }); + if (existing) return; + + console.log('[seed] Creating demo community...'); + + const communityId = crypto.randomUUID(); + const homePageId = crypto.randomUUID(); + + try { + const customFeatureFlag = await FeatureFlag.create({ + id: crypto.randomUUID(), + name: 'customScripts', + }); + + const spamTag = await SpamTag.create({ + id: crypto.randomUUID(), + status: 'confirmed-not-spam', + fields: {}, + spamScore: 0, + + spamScoreComputedAt: new Date(), + spamScoreVersion: 1, + }); + await Community.create( + { + id: communityId, + subdomain: DEMO_SUBDOMAIN, + title: 'Demo Community', + description: 'Local development community', + heroTitle: 'Demo Community', + heroText: 'Welcome to your local PubPub development environment.', + accentColorLight: '#ffffff', + accentColorDark: '#112233', + navigation: [{ type: 'page', id: homePageId }], + hideCreatePubButton: false, + spamTagId: spamTag.id, + }, + { hooks: false }, + ); + await FeatureFlagCommunity.create({ + id: crypto.randomUUID(), + featureFlagId: customFeatureFlag.id, + communityId, + enabled: true, + }); + + await Page.create({ + id: homePageId, + title: 'Home', + slug: '', + communityId, + isPublic: true, + layout: [ + { + id: crypto.randomUUID().slice(0, 8), + type: 'text', + content: { + text: { + type: 'doc', + attrs: { meta: {} }, + content: [ + { + type: 'heading', + attrs: { level: 1, fixedId: '', id: 'welcome' }, + content: [{ type: 'text', text: 'Welcome to PubPub Dev' }], + }, + { + type: 'paragraph', + attrs: { class: null }, + content: [ + { + type: 'text', + text: 'This community was automatically created for local development. Sign in with your KF Auth account to get started.', + }, + ], + }, + ], + }, + align: 'left', + title: '', + width: 'wide', + }, + }, + ], + viewHash: crypto.randomUUID().slice(0, 8), + } as any); + + console.log('[seed] Demo community created (subdomain: demo)'); + } catch (err: any) { + if (err?.name === 'SequelizeUniqueConstraintError') { + return; + } + throw err; + } +} diff --git a/server/sequelize.ts b/server/sequelize.ts index c4c2455276..f9d6767ef0 100644 --- a/server/sequelize.ts +++ b/server/sequelize.ts @@ -146,5 +146,12 @@ export const sequelizeSyncPromise: Promise = // take several minutes and would delay deploys. const { createSummaryViews } = await import('server/analytics/summaryViews'); await createSummaryViews(); + + if (process.env.NODE_ENV !== 'production') { + const { seedDevData } = await import('server/seed'); + await seedDevData().catch((err) => + console.error('[seed] Failed to seed dev data:', err), + ); + } })() : Promise.resolve(); From 6e5e2ad713beb4658e6fd8450ada77a6e01b52d8 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 30 Jun 2026 16:43:00 +0200 Subject: [PATCH 02/22] fix: remove pgdata3 --- infra/docker-compose.dev.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 19c23f08ee..50ff206dee 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -82,7 +82,7 @@ services: -c work_mem=16MB -c maintenance_work_mem=512MB volumes: - - pgdata3:/var/lib/postgresql/data + - pgdata:/var/lib/postgresql/data ports: - "${DB_PORT:-5439}:5432" networks: [appnet] @@ -111,5 +111,4 @@ networks: volumes: pgdata: - pgdata3: rabbitmqdata: From 6d93a6035c60d3c2107973f3d6893e6804d64fde Mon Sep 17 00:00:00 2001 From: gabestein Date: Tue, 30 Jun 2026 13:10:45 -0400 Subject: [PATCH 03/22] new: Adds ftp targets to superadmin --- .../FtpTargets/FtpTargets.tsx | 656 ++++++++++++++++++ .../FtpTargets/ftpTargets.scss | 94 +++ .../SuperAdminDashboard/FtpTargets/index.ts | 1 + .../containers/SuperAdminDashboard/tabs.tsx | 5 + server/community/model.ts | 9 +- server/ftpTarget/__tests__/api.test.ts | 191 +++++ server/ftpTarget/model.ts | 55 ++ server/models.ts | 3 + server/routes/superAdminDashboard.tsx | 251 ++++++- .../migrations/2026_06_30_createFtpTargets.js | 52 ++ utils/superAdmin.ts | 1 + 11 files changed, 1309 insertions(+), 9 deletions(-) create mode 100644 client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx create mode 100644 client/containers/SuperAdminDashboard/FtpTargets/ftpTargets.scss create mode 100644 client/containers/SuperAdminDashboard/FtpTargets/index.ts create mode 100644 server/ftpTarget/__tests__/api.test.ts create mode 100644 server/ftpTarget/model.ts create mode 100644 tools/migrations/2026_06_30_createFtpTargets.js diff --git a/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx b/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx new file mode 100644 index 0000000000..2f41248555 --- /dev/null +++ b/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx @@ -0,0 +1,656 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + Button, + Callout, + Checkbox, + Classes, + Dialog, + FormGroup, + HTMLSelect, + InputGroup, + Intent, + MenuItem, + NonIdealState, + Position, + Tag, +} from '@blueprintjs/core'; +import { Suggest } from '@blueprintjs/select'; + +import { apiFetch } from 'client/utils/apiFetch'; + +import './ftpTargets.scss'; + +type FtpTargetRow = { + id: string; + communityId: string | null; + ftpType: 'sftp' | 'ftps' | null; + port: number | null; + host: string | null; + filePath: string | null; + hasCredentials: boolean; + communityTitle: string; + communitySubdomain: string; +}; + +type CommunityOption = { + id: string; + title: string; + subdomain: string; +}; + +type Props = { + ftpTargets: FtpTargetRow[]; +}; + +const FTP_TYPE_OPTIONS = [{ label: 'SFTP', value: 'sftp' }]; + +const useCommunitySearch = () => { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [selected, setSelected] = useState(null); + const debounceRef = useRef>(); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + if (!query.trim()) { + setResults([]); + return; + } + debounceRef.current = setTimeout(async () => { + try { + const data = await apiFetch.get( + `/api/superadmin/communities/search?q=${encodeURIComponent(query)}`, + ); + setResults(data); + } catch { + setResults([]); + } + }, 250); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query]); + + const reset = useCallback(() => { + setQuery(''); + setResults([]); + setSelected(null); + }, []); + + return { query, setQuery, results, selected, setSelected, reset }; +}; + +const FtpTargets = (props: Props) => { + const [targets, setTargets] = useState(props.ftpTargets); + const [filterText, setFilterText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Create form + const createSearch = useCommunitySearch(); + const [createFtpType, setCreateFtpType] = useState<'sftp' | 'ftps'>('sftp'); + const [createPort, setCreatePort] = useState(''); + const [createHost, setCreateHost] = useState(''); + const [createFilePath, setCreateFilePath] = useState(''); + const [createUsername, setCreateUsername] = useState(''); + const [createPassword, setCreatePassword] = useState(''); + + // Edit dialog — undefined means "untouched", '' means "user cleared the field" + const [editTarget, setEditTarget] = useState(null); + const [editFtpType, setEditFtpType] = useState<'sftp' | 'ftps'>('sftp'); + const [editPort, setEditPort] = useState(''); + const [editHost, setEditHost] = useState(''); + const [editFilePath, setEditFilePath] = useState(''); + const [editUsername, setEditUsername] = useState(undefined); + const [editPassword, setEditPassword] = useState(undefined); + + // Copy dialog + const [copySource, setCopySource] = useState(null); + const copySearch = useCommunitySearch(); + const [copyCredentials, setCopyCredentials] = useState(true); + + // Delete confirmation + const [pendingDelete, setPendingDelete] = useState(null); + + const filteredTargets = useMemo(() => { + const q = filterText.toLowerCase().trim(); + if (!q) return targets; + return targets.filter( + (t) => + (t.host ?? '').toLowerCase().includes(q) || + (t.ftpType ?? '').toLowerCase().includes(q) || + t.communityTitle.toLowerCase().includes(q) || + t.communitySubdomain.toLowerCase().includes(q), + ); + }, [targets, filterText]); + + const handleCreate = useCallback(async () => { + if (!createSearch.selected || !createHost.trim()) { + setError('Community and host are required.'); + return; + } + if (!createUsername.trim() || !createPassword) { + setError('Username and password are required.'); + return; + } + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const result = await apiFetch.post('/api/superadmin/ftp-targets', { + communityId: createSearch.selected.id, + ftpType: createFtpType, + port: createPort.trim() ? parseInt(createPort.trim(), 10) : undefined, + host: createHost.trim(), + filePath: createFilePath.trim() || undefined, + username: createUsername.trim() || undefined, + password: createPassword || undefined, + }); + setTargets((prev) => [result, ...prev]); + createSearch.reset(); + setCreateFtpType('sftp'); + setCreatePort(''); + setCreateHost(''); + setCreateFilePath(''); + setCreateUsername(''); + setCreatePassword(''); + setSuccess(`FTP target created for "${result.communityTitle}" (${result.host}).`); + } catch (err: any) { + setError(err?.message || 'Failed to create FTP target.'); + } finally { + setIsLoading(false); + } + }, [createSearch, createFtpType, createPort, createHost, createFilePath, createUsername, createPassword]); + + const openEdit = useCallback((target: FtpTargetRow) => { + setEditTarget(target); + setEditFtpType((target.ftpType as 'sftp' | 'ftps') ?? 'sftp'); + setEditPort(target.port != null ? String(target.port) : ''); + setEditHost(target.host ?? ''); + setEditFilePath(target.filePath ?? ''); + setEditUsername(undefined); + setEditPassword(undefined); + }, []); + + const handleEdit = useCallback(async () => { + if (!editTarget) return; + if (!editTarget.hasCredentials && (!editUsername?.trim() || !editPassword)) { + setError('Username and password are required.'); + return; + } + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const body: Record = { + ftpType: editFtpType, + host: editHost.trim(), + port: editPort.trim() ? parseInt(editPort.trim(), 10) : null, + filePath: editFilePath.trim() || null, + }; + if (editUsername !== undefined) { + body.username = editUsername.trim(); + } + if (editPassword !== undefined) { + body.password = editPassword; + } + const result = await apiFetch.put( + `/api/superadmin/ftp-targets/${editTarget.id}`, + body, + ); + setTargets((prev) => prev.map((t) => (t.id === editTarget.id ? result : t))); + setEditTarget(null); + setSuccess(`FTP target for "${result.communityTitle}" updated.`); + } catch (err: any) { + setError(err?.message || 'Failed to update FTP target.'); + } finally { + setIsLoading(false); + } + }, [editTarget, editFtpType, editPort, editHost, editFilePath, editUsername, editPassword]); + + const handleDelete = useCallback(async (target: FtpTargetRow) => { + setPendingDelete(null); + setIsLoading(true); + setError(null); + setSuccess(null); + try { + await apiFetch.delete(`/api/superadmin/ftp-targets/${target.id}`); + setTargets((prev) => prev.filter((t) => t.id !== target.id)); + setSuccess(`FTP target deleted for "${target.communityTitle}" (${target.host}).`); + } catch (err: any) { + setError(err?.message || 'Failed to delete FTP target.'); + } finally { + setIsLoading(false); + } + }, []); + + const handleCopy = useCallback(async () => { + if (!copySource || !copySearch.selected) { + setError('Destination community is required.'); + return; + } + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const result = await apiFetch.post( + `/api/superadmin/ftp-targets/${copySource.id}/copy`, + { + communityId: copySearch.selected.id, + copyCredentials, + }, + ); + setTargets((prev) => [result, ...prev]); + setCopySource(null); + copySearch.reset(); + setCopyCredentials(true); + setSuccess(`FTP target copied to "${result.communityTitle}" (${result.host}).`); + } catch (err: any) { + setError(err?.message || 'Failed to copy FTP target.'); + } finally { + setIsLoading(false); + } + }, [copySource, copySearch, copyCredentials]); + + return ( +
+

FTP Targets

+ + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} + +

Create FTP Target

+
+ + + items={createSearch.results} + query={createSearch.query} + onQueryChange={createSearch.setQuery} + selectedItem={createSearch.selected} + onItemSelect={(item) => { + createSearch.setSelected(item); + createSearch.setQuery(item.title); + }} + inputValueRenderer={(item) => item.title} + itemRenderer={(item, { handleClick, modifiers }) => ( + + )} + noResults={ + createSearch.query.trim() ? ( + + ) : undefined + } + resetOnSelect={false} + inputProps={{ + placeholder: 'Search communities…', + disabled: isLoading, + }} + popoverProps={{ + minimal: true, + position: Position.BOTTOM_LEFT, + }} + /> + + + setCreateFtpType(e.target.value as 'sftp' | 'ftps')} + options={FTP_TYPE_OPTIONS} + disabled={isLoading} + /> + + + setCreateHost(e.target.value)} + disabled={isLoading} + /> + + + setCreatePort(e.target.value)} + disabled={isLoading} + /> + + + setCreateUsername(e.target.value)} + disabled={isLoading} + /> + + + setCreatePassword(e.target.value)} + disabled={isLoading} + /> + + + setCreateFilePath(e.target.value)} + disabled={isLoading} + /> + +
+ +

Manage FTP Targets

+ {targets.length === 0 ? ( + + ) : ( + <> +
+ setFilterText(e.target.value)} + /> + + {filterText && filteredTargets.length !== targets.length + ? `Showing ${filteredTargets.length} of ${targets.length}` + : `Total: ${targets.length}`}{' '} + target{targets.length !== 1 ? 's' : ''} + +
+ + + + + + + + + + + + + + {filteredTargets.map((t) => ( + + + + + + + + + + + ))} + +
CommunitySubdomainTypeHostPortCredentialsFile Path +
{t.communityTitle} + {t.communitySubdomain} + + + {t.ftpType ?? '—'} + + + {t.host ?? '—'} + {t.port ?? '—'} + + {t.hasCredentials ? '🔒 Set' : 'None'} + + + {t.filePath ?? '—'} + +
+ + )} + + {/* Edit Dialog */} + setEditTarget(null)} + title="Edit FTP Target" + > +
+

+ Community: {editTarget?.communityTitle} ( + {editTarget?.communitySubdomain}) +

+ + setEditFtpType(e.target.value as 'sftp' | 'ftps')} + options={FTP_TYPE_OPTIONS} + /> + + + setEditHost(e.target.value)} + /> + + + setEditPort(e.target.value)} + /> + + + setEditUsername(e.target.value)} + /> + + + setEditPassword(e.target.value)} + /> + + + setEditFilePath(e.target.value)} + /> + +
+
+
+ + +
+
+
+ + {/* Copy Dialog */} + { + setCopySource(null); + copySearch.reset(); + }} + title="Copy FTP Target" + > +
+
+

+ Source: {copySource?.communityTitle} ( + {copySource?.communitySubdomain}) +

+

+ Host: {copySource?.host ?? '—'} +

+

+ Type: {copySource?.ftpType ?? '—'} +

+
+ + + items={copySearch.results} + query={copySearch.query} + onQueryChange={copySearch.setQuery} + selectedItem={copySearch.selected} + onItemSelect={(item) => { + copySearch.setSelected(item); + copySearch.setQuery(item.title); + }} + inputValueRenderer={(item) => item.title} + itemRenderer={(item, { handleClick, modifiers }) => ( + + )} + noResults={ + copySearch.query.trim() ? ( + + ) : undefined + } + resetOnSelect={false} + inputProps={{ + placeholder: 'Search communities…', + }} + popoverProps={{ + minimal: true, + position: Position.BOTTOM_LEFT, + }} + /> + + {copySource?.hasCredentials && ( + + setCopyCredentials((e.target as HTMLInputElement).checked) + } + label="Copy credentials (username & password)" + /> + )} +
+
+
+ + +
+
+
+ + {/* Delete Confirmation Dialog */} + setPendingDelete(null)} + title="Delete FTP Target" + icon="warning-sign" + > +
+

+ Delete the FTP target for{' '} + {pendingDelete?.communityTitle} ( + {pendingDelete?.host})? +

+

This cannot be undone.

+
+
+
+ + +
+
+
+
+ ); +}; + +export default FtpTargets; diff --git a/client/containers/SuperAdminDashboard/FtpTargets/ftpTargets.scss b/client/containers/SuperAdminDashboard/FtpTargets/ftpTargets.scss new file mode 100644 index 0000000000..ed480cf259 --- /dev/null +++ b/client/containers/SuperAdminDashboard/FtpTargets/ftpTargets.scss @@ -0,0 +1,94 @@ +.ftp-targets-component { + .add-target-form { + display: flex; + gap: 10px; + margin-bottom: 20px; + align-items: flex-end; + flex-wrap: wrap; + + .bp3-form-group { + margin-bottom: 0; + } + } + + .filter-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + + .bp3-input-group { + flex: 0 0 400px; + } + } + + .target-count { + font-size: 13px; + color: #888; + white-space: nowrap; + } + + .targets-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + + th, + td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid #e1e1e1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + th:nth-child(1), + td:nth-child(1) { width: 20%; } + + th:nth-child(2), + td:nth-child(2) { width: 14%; } + + th:nth-child(3), + td:nth-child(3) { width: 7%; } + + th:nth-child(4), + td:nth-child(4) { width: 22%; } + + th:nth-child(5), + td:nth-child(5) { width: 7%; } + + th:nth-child(6), + td:nth-child(6) { width: 16%; } + + th:nth-child(7), + td:nth-child(7) { width: 10%; } + + th:nth-child(8), + td:nth-child(8) { width: 100px; text-align: right; overflow: visible; } + + th { + font-weight: 600; + background: #f5f5f5; + } + } + + .credential-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + + &.has-credentials { + color: #0d8050; + } + + &.no-credentials { + color: #888; + } + } + + .dialog-field { + margin-bottom: 12px; + } +} diff --git a/client/containers/SuperAdminDashboard/FtpTargets/index.ts b/client/containers/SuperAdminDashboard/FtpTargets/index.ts new file mode 100644 index 0000000000..57d1c65160 --- /dev/null +++ b/client/containers/SuperAdminDashboard/FtpTargets/index.ts @@ -0,0 +1 @@ +export { default } from './FtpTargets'; diff --git a/client/containers/SuperAdminDashboard/tabs.tsx b/client/containers/SuperAdminDashboard/tabs.tsx index cf77a25da3..72b2d64bef 100644 --- a/client/containers/SuperAdminDashboard/tabs.tsx +++ b/client/containers/SuperAdminDashboard/tabs.tsx @@ -7,6 +7,7 @@ import CommunityTemplates from './CommunityTemplates'; import CustomDomains from './CustomDomains'; import DepositTargets from './DepositTargets'; import ExploreCommunities from './ExploreCommunities'; +import FtpTargets from './FtpTargets'; import Hubs from './Hubs'; import LandingPageFeatures from './LandingPageFeatures'; import PlatformAnalytics from './PlatformAnalytics'; @@ -31,6 +32,10 @@ export const superAdminTabs: Record = { title: 'Deposit Targets', component: DepositTargets, }, + ftpTargets: { + title: 'FTP Targets', + component: FtpTargets, + }, exploreCommunities: { title: 'Explore Page', component: ExploreCommunities, diff --git a/server/community/model.ts b/server/community/model.ts index 83e562e975..a93d9b4fbd 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -29,7 +29,7 @@ import { } from 'sequelize-typescript'; import { CommunityTemplate } from '../communityTemplate/model'; -import { Collection, DepositTarget, Member, Page, Pub, ScopeSummary, SpamTag } from '../models'; +import { Collection, DepositTarget, FtpTarget, Member, Page, Pub, ScopeSummary, SpamTag } from '../models'; @DefaultScope(() => ({ attributes: { exclude: ['searchVector'] } })) @Table @@ -277,6 +277,13 @@ export class Community extends Model< }) declare depositTargets?: DepositTarget[]; + @HasMany(() => FtpTarget, { + onDelete: 'CASCADE', + as: 'ftpTargets', + foreignKey: 'communityId', + }) + declare ftpTargets?: FtpTarget[]; + @BelongsTo(() => ScopeSummary, { as: 'scopeSummary', foreignKey: 'scopeSummaryId', diff --git a/server/ftpTarget/__tests__/api.test.ts b/server/ftpTarget/__tests__/api.test.ts new file mode 100644 index 0000000000..efd8f654d0 --- /dev/null +++ b/server/ftpTarget/__tests__/api.test.ts @@ -0,0 +1,191 @@ +import { FtpTarget } from 'server/models'; +import { login, modelize, setup, teardown } from 'stubstub'; + +const models = modelize` + Community communityA { + Member { + permissions: "admin" + User communityAdmin {} + } + } + Community communityB {} + Community communityC {} + User superAdmin { + isSuperAdmin: true + } + User regularUser {} +`; + +setup(beforeAll, async () => { + await models.resolve(); +}); + +teardown(afterAll, () => {}); + +describe('/api/superadmin/ftp-targets', () => { + it('requires superadmin to create an FTP target', async () => { + const { communityA, regularUser } = models; + const agent = await login(regularUser); + await agent + .post('/api/superadmin/ftp-targets') + .send({ communityId: communityA.id, host: 'sftp.example.com', ftpType: 'sftp' }) + .expect(403); + }); + + it('requires superadmin to list via search', async () => { + const { regularUser } = models; + const agent = await login(regularUser); + await agent.get('/api/superadmin/communities/search?q=test').expect(403); + }); + + it('creates an SFTP target with credentials', async () => { + const { communityA, superAdmin } = models; + const agent = await login(superAdmin); + const { body } = await agent + .post('/api/superadmin/ftp-targets') + .send({ + communityId: communityA.id, + host: 'sftp.example.com', + ftpType: 'sftp', + port: 22, + filePath: '/uploads', + username: 'ftpuser', + password: 'secret', + }) + .expect(201); + expect(body.host).toBe('sftp.example.com'); + expect(body.ftpType).toBe('sftp'); + expect(body.port).toBe(22); + expect(body.filePath).toBe('/uploads'); + expect(body.hasCredentials).toBe(true); + expect(body.password).toBeUndefined(); + expect(body.passwordInitVec).toBeUndefined(); + }); + + it('creates an FTPS target without credentials', async () => { + const { communityB, superAdmin } = models; + const agent = await login(superAdmin); + const { body } = await agent + .post('/api/superadmin/ftp-targets') + .send({ + communityId: communityB.id, + host: 'ftps.example.com', + ftpType: 'ftps', + }) + .expect(201); + expect(body.ftpType).toBe('ftps'); + expect(body.hasCredentials).toBe(false); + }); + + it('allows multiple FTP targets for the same community', async () => { + const { communityA, superAdmin } = models; + const agent = await login(superAdmin); + const { body } = await agent + .post('/api/superadmin/ftp-targets') + .send({ communityId: communityA.id, host: 'second.example.com', ftpType: 'ftps' }) + .expect(201); + expect(body.host).toBe('second.example.com'); + expect(body.ftpType).toBe('ftps'); + }); + + it('updates host, port, and filePath', async () => { + const { communityA, superAdmin } = models; + const existing = await FtpTarget.findOne({ where: { communityId: communityA.id } }); + const agent = await login(superAdmin); + const { body } = await agent + .put(`/api/superadmin/ftp-targets/${existing!.id}`) + .send({ host: 'newhost.example.com', port: 2222, filePath: '/new/path' }) + .expect(200); + expect(body.host).toBe('newhost.example.com'); + expect(body.port).toBe(2222); + expect(body.filePath).toBe('/new/path'); + }); + + it('clears credentials when username is set to empty string', async () => { + const { communityA, superAdmin } = models; + const existing = await FtpTarget.findOne({ where: { communityId: communityA.id } }); + const agent = await login(superAdmin); + const { body } = await agent + .put(`/api/superadmin/ftp-targets/${existing!.id}`) + .send({ username: '' }) + .expect(200); + expect(body.hasCredentials).toBe(false); + }); + + it('deletes an FTP target', async () => { + const { communityB, superAdmin } = models; + const existing = await FtpTarget.findOne({ where: { communityId: communityB.id } }); + const agent = await login(superAdmin); + const { body } = await agent + .delete(`/api/superadmin/ftp-targets/${existing!.id}`) + .expect(200); + expect(body.id).toBe(existing!.id); + const gone = await FtpTarget.findByPk(existing!.id); + expect(gone).toBeNull(); + }); + + it('copies an FTP target to another community', async () => { + const { communityA, communityC, superAdmin } = models; + const source = await FtpTarget.findOne({ where: { communityId: communityA.id } }); + const agent = await login(superAdmin); + const { body } = await agent + .post(`/api/superadmin/ftp-targets/${source!.id}/copy`) + .send({ communityId: communityC.id, copyCredentials: false }) + .expect(200); + expect(body.communityId).toBe(communityC.id); + expect(body.host).toBe(source!.host); + expect(body.ftpType).toBe(source!.ftpType); + expect(body.hasCredentials).toBe(false); + }); + + it('copies credentials when copyCredentials is true', async () => { + const { communityA, communityC, superAdmin } = models; + // create a fresh target with credentials on communityA + const agent = await login(superAdmin); + const { body: created } = await agent + .post('/api/superadmin/ftp-targets') + .send({ + communityId: communityA.id, + host: 'creds.example.com', + ftpType: 'sftp', + username: 'user', + password: 'pass', + }) + .expect(201); + const { body: copied } = await agent + .post(`/api/superadmin/ftp-targets/${created.id}/copy`) + .send({ communityId: communityC.id, copyCredentials: true }) + .expect(200); + expect(copied.hasCredentials).toBe(true); + expect(copied.password).toBeUndefined(); + }); + + it('requires superadmin to update', async () => { + const { communityA, regularUser } = models; + const existing = await FtpTarget.findOne({ where: { communityId: communityA.id } }); + const agent = await login(regularUser); + await agent + .put(`/api/superadmin/ftp-targets/${existing!.id}`) + .send({ host: 'evil.example.com' }) + .expect(403); + }); + + it('validates ftpType on create', async () => { + const { communityC, superAdmin } = models; + const agent = await login(superAdmin); + await agent + .post('/api/superadmin/ftp-targets') + .send({ communityId: communityC.id, host: 'sftp.example.com', ftpType: 'invalid' }) + .expect(400); + }); + + it('returns communities with existing FTP targets in search (multiple targets allowed)', async () => { + const { communityA, superAdmin } = models; + const agent = await login(superAdmin); + const { body } = await agent + .get(`/api/superadmin/communities/search?q=${encodeURIComponent(communityA.subdomain)}`) + .expect(200); + const found = body.find((c: any) => c.id === communityA.id); + expect(found).toBeDefined(); + }); +}); diff --git a/server/ftpTarget/model.ts b/server/ftpTarget/model.ts new file mode 100644 index 0000000000..bf43080ef1 --- /dev/null +++ b/server/ftpTarget/model.ts @@ -0,0 +1,55 @@ +import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'; + +import type { SerializedModel } from 'types'; + +import { + BelongsTo, + Column, + DataType, + Default, + Model, + PrimaryKey, + Table, +} from 'sequelize-typescript'; + +import { Community } from '../community/model'; + +@Table +export class FtpTarget extends Model< + InferAttributes, + InferCreationAttributes +> { + public declare toJSON: (this: M) => SerializedModel; + + @Default(DataType.UUIDV4) + @PrimaryKey + @Column(DataType.UUID) + declare id: CreationOptional; + + @Column(DataType.UUID) + declare communityId: string | null; + + @BelongsTo(() => Community, { onDelete: 'CASCADE', as: 'community', foreignKey: 'communityId' }) + declare community?: Community; + + @Column(DataType.ENUM('sftp', 'ftps')) + declare ftpType: 'sftp' | 'ftps' | null; + + @Column(DataType.INTEGER) + declare port: number | null; + + @Column(DataType.STRING) + declare host: string | null; + + @Column(DataType.STRING) + declare filePath: string | null; + + @Column(DataType.STRING) + declare username: string | null; + + @Column(DataType.STRING) + declare password: string | null; + + @Column(DataType.TEXT) + declare passwordInitVec: string | null; +} diff --git a/server/models.ts b/server/models.ts index 19b4e2c735..da4f64e912 100644 --- a/server/models.ts +++ b/server/models.ts @@ -16,6 +16,7 @@ import { CommunityTemplate } from './communityTemplate/model'; import { CrossrefDepositRecord } from './crossrefDepositRecord/model'; import { CustomScript } from './customScript/model'; import { DepositTarget } from './depositTarget/model'; +import { FtpTarget } from './ftpTarget/model'; import { Discussion } from './discussion/model'; import { DiscussionAnchor } from './discussionAnchor/model'; import { Doc } from './doc/model'; @@ -80,6 +81,7 @@ sequelize.addModels([ HubOptOut, CustomScript, DepositTarget, + FtpTarget, Discussion, DiscussionAnchor, Doc, @@ -179,6 +181,7 @@ export { HubOptOut, CustomScript, DepositTarget, + FtpTarget, Discussion, DiscussionAnchor, EmailChangeToken, diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index c52a15ee57..48e8382603 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -26,7 +26,7 @@ import Html from 'server/Html'; // NOTE: Suggested Hubs SSR returns an empty shell; summaries are fetched client-side on mount. import { getAllHubsWithCommunityCounts } from 'server/hub/queries'; import { getLandingPageFeatures } from 'server/landingPageFeature/queries'; -import { Community, DepositTarget } from 'server/models'; +import { Community, DepositTarget, FtpTarget } from 'server/models'; import { queryCommunitiesForSpamManagement } from 'server/spamTag/communityDashboard'; import { queryUsersForSpamManagement } from 'server/spamTag/userDashboard'; import { @@ -99,6 +99,31 @@ const getTabProps = async (tabKind: SuperAdminTabKind, locationData: types.Locat })), }; } + if (tabKind === 'ftpTargets') { + const targets = await FtpTarget.findAll({ + include: [ + { + model: Community, + as: 'community', + attributes: ['id', 'title', 'subdomain'], + }, + ], + order: [['createdAt', 'DESC']], + }); + return { + ftpTargets: targets.map((t) => ({ + id: t.id, + communityId: t.communityId, + ftpType: t.ftpType, + port: t.port, + host: t.host, + filePath: t.filePath, + hasCredentials: Boolean(t.username), + communityTitle: t.community?.title ?? '(unknown)', + communitySubdomain: t.community?.subdomain ?? '(unknown)', + })), + }; + } if (tabKind === 'exploreCommunities') { return { communities: await getExploreCommunities() }; } @@ -288,6 +313,18 @@ const resolveCommunity = async (identifier: string) => { return community; }; +const sanitizeFtpTarget = (t: FtpTarget) => ({ + id: t.id, + communityId: t.communityId, + ftpType: t.ftpType, + port: t.port, + host: t.host, + filePath: t.filePath, + hasCredentials: Boolean(t.username), + communityTitle: t.community?.title ?? '(unknown)', + communitySubdomain: t.community?.subdomain ?? '(unknown)', +}); + const sanitizeDepositTarget = (t: DepositTarget) => ({ id: t.id, communityId: t.communityId, @@ -313,6 +350,7 @@ router.get('/api/superadmin/communities/search', async (req, res, next) => { } const excludeWithDepositTarget = req.query.excludeWithDepositTarget === 'true'; + const excludeWithFtpTarget = req.query.excludeWithFtpTarget === 'true'; const communities = await Community.findAll({ where: { @@ -326,17 +364,30 @@ router.get('/api/superadmin/communities/search', async (req, res, next) => { order: [['title', 'ASC']], }); - if (!excludeWithDepositTarget) { + if (!excludeWithDepositTarget && !excludeWithFtpTarget) { return res.json(communities); } const communityIds = communities.map((c) => c.id); - const existingTargets = await DepositTarget.findAll({ - where: { communityId: communityIds }, - attributes: ['communityId'], - }); - const idsWithTarget = new Set(existingTargets.map((t) => t.communityId)); - return res.json(communities.filter((c) => !idsWithTarget.has(c.id))); + let excludedIds = new Set(); + + if (excludeWithDepositTarget) { + const existingTargets = await DepositTarget.findAll({ + where: { communityId: communityIds }, + attributes: ['communityId'], + }); + existingTargets.forEach((t) => excludedIds.add(t.communityId)); + } + + if (excludeWithFtpTarget) { + const existingFtpTargets = await FtpTarget.findAll({ + where: { communityId: communityIds }, + attributes: ['communityId'], + }); + existingFtpTargets.forEach((t) => excludedIds.add(t.communityId)); + } + + return res.json(communities.filter((c) => !excludedIds.has(c.id))); } catch (err) { return handleErrors(req, res, next)(err); } @@ -536,6 +587,190 @@ router.post('/api/superadmin/deposit-targets/:id/copy', async (req, res, next) = } }); +// ── FTP Targets API ──────────────────────────────────────────────────────── + +router.post('/api/superadmin/ftp-targets', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + if (!initialData.loginData.isSuperAdmin) { + throw new ForbiddenError(); + } + + const { communityId, ftpType, port, host, filePath, username, password } = req.body; + if (!communityId || !host) { + throw new BadRequestError(new Error('communityId and host are required')); + } + if (!ftpType || !['sftp', 'ftps'].includes(ftpType)) { + throw new BadRequestError(new Error('ftpType must be "sftp" or "ftps"')); + } + + const community = await resolveCommunity(communityId); + + const createData: Record = { + communityId: community.id, + ftpType, + host: String(host).trim(), + port: port != null ? Number(port) : null, + filePath: filePath ? String(filePath).trim() : null, + }; + + if (username && password) { + const { encryptedText, initVec } = aes256Encrypt(password, env.AES_ENCRYPTION_KEY!); + createData.username = username; + createData.password = encryptedText; + createData.passwordInitVec = initVec; + } + + const target = await FtpTarget.create(createData as any); + const reloaded = await FtpTarget.findByPk(target.id, { + include: [ + { model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] }, + ], + }); + + return res.status(201).json(sanitizeFtpTarget(reloaded!)); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + +router.put('/api/superadmin/ftp-targets/:id', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + if (!initialData.loginData.isSuperAdmin) { + throw new ForbiddenError(); + } + + const target = await FtpTarget.findByPk(req.params.id); + if (!target) { + throw new NotFoundError(new Error('FTP target not found')); + } + + const { ftpType, port, host, filePath, username, password } = req.body; + const updates: Record = {}; + + if (ftpType !== undefined) { + if (!['sftp', 'ftps'].includes(ftpType)) { + throw new BadRequestError(new Error('ftpType must be "sftp" or "ftps"')); + } + updates.ftpType = ftpType; + } + if (host !== undefined) { + updates.host = String(host).trim(); + } + if (port !== undefined) { + updates.port = port !== null ? Number(port) : null; + } + if (filePath !== undefined) { + updates.filePath = filePath ? String(filePath).trim() : null; + } + + if (username !== undefined) { + if (username === '') { + updates.username = null; + updates.password = null; + updates.passwordInitVec = null; + } else { + updates.username = username; + if (password) { + const { encryptedText, initVec } = aes256Encrypt( + password, + env.AES_ENCRYPTION_KEY!, + ); + updates.password = encryptedText; + updates.passwordInitVec = initVec; + } + } + } else if (password) { + const { encryptedText, initVec } = aes256Encrypt(password, env.AES_ENCRYPTION_KEY!); + updates.password = encryptedText; + updates.passwordInitVec = initVec; + } + + await target.update(updates); + const reloaded = await FtpTarget.findByPk(target.id, { + include: [ + { model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] }, + ], + }); + + return res.json(sanitizeFtpTarget(reloaded!)); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + +router.delete('/api/superadmin/ftp-targets/:id', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + if (!initialData.loginData.isSuperAdmin) { + throw new ForbiddenError(); + } + + const target = await FtpTarget.findByPk(req.params.id); + if (!target) { + throw new NotFoundError(new Error('FTP target not found')); + } + + await target.destroy(); + return res.json({ id: req.params.id }); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + +router.post('/api/superadmin/ftp-targets/:id/copy', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + if (!initialData.loginData.isSuperAdmin) { + throw new ForbiddenError(); + } + + const source = await FtpTarget.findByPk(req.params.id); + if (!source) { + throw new NotFoundError(new Error('Source FTP target not found')); + } + + const { communityId, copyCredentials } = req.body; + if (!communityId) { + throw new BadRequestError(new Error('communityId is required')); + } + + const destCommunity = await resolveCommunity(communityId); + + const createData: Record = { + communityId: destCommunity.id, + ftpType: source.ftpType, + port: source.port, + host: source.host, + filePath: source.filePath, + }; + + if (copyCredentials && source.username && source.password && source.passwordInitVec) { + const plaintext = aes256Decrypt( + source.password, + env.AES_ENCRYPTION_KEY!, + source.passwordInitVec, + ); + const { encryptedText, initVec } = aes256Encrypt(plaintext, env.AES_ENCRYPTION_KEY!); + createData.username = source.username; + createData.password = encryptedText; + createData.passwordInitVec = initVec; + } + + const newTarget = await FtpTarget.create(createData as any); + const reloaded = await FtpTarget.findByPk(newTarget.id, { + include: [ + { model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] }, + ], + }); + + return res.json(sanitizeFtpTarget(reloaded!)); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + // JSON API for lazy-loading suggested-hubs sidebar summaries router.get('/api/superadmin/suggested-hubs-summaries', async (req, res, next) => { try { diff --git a/tools/migrations/2026_06_30_createFtpTargets.js b/tools/migrations/2026_06_30_createFtpTargets.js new file mode 100644 index 0000000000..451ea7bcda --- /dev/null +++ b/tools/migrations/2026_06_30_createFtpTargets.js @@ -0,0 +1,52 @@ +module.exports = async ({ Sequelize, sequelize }) => { + await sequelize.queryInterface.createTable('FtpTargets', { + id: { + type: Sequelize.UUID, + primaryKey: true, + defaultValue: Sequelize.UUIDV4, + allowNull: false, + }, + communityId: { + type: Sequelize.UUID, + allowNull: true, + references: { model: 'Communities', key: 'id' }, + onDelete: 'CASCADE', + }, + ftpType: { + type: Sequelize.ENUM('sftp', 'ftps'), + allowNull: true, + }, + port: { + type: Sequelize.INTEGER, + allowNull: true, + }, + host: { + type: Sequelize.STRING, + allowNull: true, + }, + filePath: { + type: Sequelize.STRING, + allowNull: true, + }, + username: { + type: Sequelize.STRING, + allowNull: true, + }, + password: { + type: Sequelize.STRING, + allowNull: true, + }, + passwordInitVec: { + type: Sequelize.TEXT, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); +}; diff --git a/utils/superAdmin.ts b/utils/superAdmin.ts index a571bfc107..a1fa59b0ea 100644 --- a/utils/superAdmin.ts +++ b/utils/superAdmin.ts @@ -2,6 +2,7 @@ export const superAdminTabKinds = [ 'analytics', 'customDomains', 'depositTargets', + 'ftpTargets', 'exploreCommunities', 'landingPageFeatures', 'hubs', From 552d64c4bb9fbd9355d9f5fc9e96331fd3ab8578 Mon Sep 17 00:00:00 2001 From: gabestein Date: Tue, 30 Jun 2026 13:44:19 -0400 Subject: [PATCH 04/22] feat: Test sftp connection on creation or save --- package.json | 2 + pnpm-lock.yaml | 81 ++++++++++++++++++++++++++ server/ftpTarget/__tests__/api.test.ts | 29 +++++++++ server/routes/superAdminDashboard.tsx | 19 +++++- server/utils/sftp.ts | 64 ++++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 server/utils/sftp.ts diff --git a/package.json b/package.json index 363c55ff07..00ab32484d 100644 --- a/package.json +++ b/package.json @@ -243,6 +243,7 @@ "sequelize-typescript-generator": "^10.1.2", "sitemap": "^6.2.0", "slowdance": "0.0.2", + "ssh2-sftp-client": "^12.1.1", "stickybits": "^3.6.5", "strip-indent": "^3.0.0", "striptags": "^3.2.0", @@ -322,6 +323,7 @@ "@types/rss": "^0.0.30", "@types/sanitize-html": "^2.9.0", "@types/sinon": "^7.5.2", + "@types/ssh2-sftp-client": "^9.0.6", "@types/supertest": "^2.0.12", "@types/unidecode": "^0.1.1", "@types/uuid": "^3.4.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 778ab5e698..c83537f0fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,6 +580,9 @@ importers: slowdance: specifier: 0.0.2 version: 0.0.2(@types/react@16.14.69) + ssh2-sftp-client: + specifier: ^12.1.1 + version: 12.1.1 stickybits: specifier: ^3.6.5 version: 3.7.11 @@ -794,6 +797,9 @@ importers: '@types/sinon': specifier: ^7.5.2 version: 7.5.2 + '@types/ssh2-sftp-client': + specifier: ^9.0.6 + version: 9.0.6 '@types/supertest': specifier: ^2.0.12 version: 2.0.16 @@ -4186,6 +4192,9 @@ packages: '@types/node@16.18.126': resolution: {integrity: sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@24.11.0': resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} @@ -4328,6 +4337,12 @@ packages: '@types/source-list-map@0.1.6': resolution: {integrity: sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g==} + '@types/ssh2-sftp-client@9.0.6': + resolution: {integrity: sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==} + + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/styled-components@5.1.36': resolution: {integrity: sha512-pGMRNY5G2rNDKEv2DOiFYa7Ft1r0jrhmgBwHhOMzPTgCjO76bCot0/4uEfqj7K0Jf1KdQmDtAuaDk9EAs9foSw==} @@ -5191,6 +5206,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + builtin-status-codes@3.0.0: resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} @@ -5561,6 +5580,10 @@ packages: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + concurrently@4.1.2: resolution: {integrity: sha512-Kim9SFrNr2jd8/0yNYqDTFALzUX1tvimmwFWxmp/D4mRI+kbqIIwE2RkBDrxS2ic25O1UgQMI5AtBqdtX3ynYg==} engines: {node: '>=6.0.0'} @@ -5694,6 +5717,10 @@ packages: resolution: {integrity: sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw==} engines: {node: '>=8'} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + cpy-cli@3.1.1: resolution: {integrity: sha512-HCpNdBkQy3rw+uARLuIf0YurqsMXYzBa9ihhSAuxYJcNIrqrSq3BstPfr0cQN38AdMrQiO9Dp4hYy7GtGJsLPg==} engines: {node: '>=8'} @@ -10707,6 +10734,14 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh2-sftp-client@12.1.1: + resolution: {integrity: sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==} + engines: {node: '>=18.20.4'} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + sshpk@1.18.0: resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} engines: {node: '>=0.10.0'} @@ -11364,6 +11399,9 @@ packages: undefsafe@2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -16933,6 +16971,10 @@ snapshots: '@types/node@16.18.126': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@24.11.0': dependencies: undici-types: 7.16.0 @@ -17094,6 +17136,14 @@ snapshots: '@types/source-list-map@0.1.6': {} + '@types/ssh2-sftp-client@9.0.6': + dependencies: + '@types/ssh2': 1.15.5 + + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@types/styled-components@5.1.36': dependencies: '@types/hoist-non-react-statics': 3.3.7(@types/react@16.14.69) @@ -18161,6 +18211,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + builtin-status-codes@3.0.0: {} bundle-name@4.1.0: @@ -18663,6 +18716,13 @@ snapshots: readable-stream: 2.3.8 typedarray: 0.0.6 + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + concurrently@4.1.2: dependencies: chalk: 2.4.2 @@ -18818,6 +18878,12 @@ snapshots: nested-error-stacks: 2.1.1 p-event: 4.2.0 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.25.0 + optional: true + cpy-cli@3.1.1: dependencies: cpy: 8.1.2 @@ -24987,6 +25053,19 @@ snapshots: sprintf-js@1.0.3: {} + ssh2-sftp-client@12.1.1: + dependencies: + concat-stream: 2.0.0 + ssh2: 1.17.0 + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.25.0 + sshpk@1.18.0: dependencies: asn1: 0.2.6 @@ -25733,6 +25812,8 @@ snapshots: undefsafe@2.0.5: {} + undici-types@5.26.5: {} + undici-types@7.16.0: {} unfetch@4.2.0: {} diff --git a/server/ftpTarget/__tests__/api.test.ts b/server/ftpTarget/__tests__/api.test.ts index efd8f654d0..2f6be3a990 100644 --- a/server/ftpTarget/__tests__/api.test.ts +++ b/server/ftpTarget/__tests__/api.test.ts @@ -1,6 +1,16 @@ +import { vi } from 'vitest'; + import { FtpTarget } from 'server/models'; import { login, modelize, setup, teardown } from 'stubstub'; +const SftpClient = vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockResolvedValue('d'), + end: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('ssh2-sftp-client', () => ({ default: SftpClient })); + const models = modelize` Community communityA { Member { @@ -170,6 +180,25 @@ describe('/api/superadmin/ftp-targets', () => { .expect(403); }); + it('rejects create when SFTP connection fails', async () => { + const { communityC, superAdmin } = models; + SftpClient.mockImplementationOnce(() => ({ + connect: vi.fn().mockRejectedValue(new Error('Authentication failed')), + end: vi.fn().mockResolvedValue(undefined), + })); + const agent = await login(superAdmin); + await agent + .post('/api/superadmin/ftp-targets') + .send({ + communityId: communityC.id, + host: 'sftp.example.com', + ftpType: 'sftp', + username: 'user', + password: 'wrongpassword', + }) + .expect(400); + }); + it('validates ftpType on create', async () => { const { communityC, superAdmin } = models; const agent = await login(superAdmin); diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index 48e8382603..7cd1d97493 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -38,6 +38,7 @@ import { BadRequestError, ForbiddenError, handleErrors, NotFoundError } from 'se import { getInitialData } from 'server/utils/initData'; import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; import { aes256Decrypt, aes256Encrypt } from 'utils/crypto'; +import { testSftpConnection } from 'server/utils/sftp'; import { getSuperAdminTabUrl, isSuperAdminTabKind, @@ -615,6 +616,12 @@ router.post('/api/superadmin/ftp-targets', async (req, res, next) => { }; if (username && password) { + await testSftpConnection( + { host: createData.host, port: createData.port, username, password }, + createData.filePath, + ).catch((err) => { + throw new BadRequestError(new Error(err.message)); + }); const { encryptedText, initVec } = aes256Encrypt(password, env.AES_ENCRYPTION_KEY!); createData.username = username; createData.password = encryptedText; @@ -671,8 +678,17 @@ router.put('/api/superadmin/ftp-targets/:id', async (req, res, next) => { updates.password = null; updates.passwordInitVec = null; } else { - updates.username = username; if (password) { + const effectiveHost = updates.host ?? target.host; + const effectivePort = port !== undefined ? updates.port : target.port; + const effectivePath = + filePath !== undefined ? updates.filePath : target.filePath; + await testSftpConnection( + { host: effectiveHost, port: effectivePort, username, password }, + effectivePath, + ).catch((err) => { + throw new BadRequestError(new Error(err.message)); + }); const { encryptedText, initVec } = aes256Encrypt( password, env.AES_ENCRYPTION_KEY!, @@ -680,6 +696,7 @@ router.put('/api/superadmin/ftp-targets/:id', async (req, res, next) => { updates.password = encryptedText; updates.passwordInitVec = initVec; } + updates.username = username; } } else if (password) { const { encryptedText, initVec } = aes256Encrypt(password, env.AES_ENCRYPTION_KEY!); diff --git a/server/utils/sftp.ts b/server/utils/sftp.ts new file mode 100644 index 0000000000..52be6891e4 --- /dev/null +++ b/server/utils/sftp.ts @@ -0,0 +1,64 @@ +import SftpClient from 'ssh2-sftp-client'; + +export type SftpConnectionParams = { + host: string; + port?: number | null; + username: string; + password: string; +}; + +/** + * Tests that an SFTP connection can be established with the given credentials. + * Optionally verifies that filePath is accessible on the remote server. + * Throws a descriptive Error on failure; caller should wrap in BadRequestError. + */ +export const testSftpConnection = async ( + params: SftpConnectionParams, + filePath?: string | null, +): Promise => { + const client = new SftpClient(); + try { + await client.connect({ + host: params.host, + port: params.port ?? 22, + username: params.username, + password: params.password, + readyTimeout: 10000, + }); + if (filePath) { + const exists = await client.exists(filePath); + if (!exists) { + throw new Error(`Remote path does not exist: ${filePath}`); + } + } + } catch (err: any) { + const message = err?.message ?? 'Unknown error'; + throw new Error(`SFTP connection test failed: ${message}`); + } finally { + await client.end().catch(() => {}); + } +}; + +/** + * Uploads a file buffer to a remote path via SFTP. + * Intended for use by background worker tasks. + */ +export const uploadFileViaSftp = async ( + params: SftpConnectionParams, + remotePath: string, + content: Buffer, +): Promise => { + const client = new SftpClient(); + try { + await client.connect({ + host: params.host, + port: params.port ?? 22, + username: params.username, + password: params.password, + readyTimeout: 10000, + }); + await client.put(content, remotePath); + } finally { + await client.end().catch(() => {}); + } +}; From 263747cb3b2ec34e9064f9a3fca5365e324ad3cd Mon Sep 17 00:00:00 2001 From: gabestein Date: Tue, 30 Jun 2026 21:18:38 -0400 Subject: [PATCH 05/22] feat: Zip and deposit collection bundles --- .../CollectionSettings/CollectionSettings.tsx | 34 +- .../ExportCollectionButton.tsx | 327 ++++++++++++++++++ .../exportCollectionButton.scss | 26 ++ server/collection/api.ts | 42 +++ server/collection/permissions.ts | 4 + server/collection/queries.ts | 10 +- server/routes/dashboardSettings.tsx | 24 +- server/utils/workers.ts | 2 +- utils/api/contracts/collection.ts | 23 ++ workers/tasks/collectionExport.ts | 217 ++++++++++++ workers/worker.ts | 2 + 11 files changed, 706 insertions(+), 5 deletions(-) create mode 100644 client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx create mode 100644 client/containers/DashboardSettings/CollectionSettings/exportCollectionButton.scss create mode 100644 workers/tasks/collectionExport.ts diff --git a/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx b/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx index cb057e9b6f..52d6574144 100644 --- a/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx +++ b/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx @@ -15,16 +15,34 @@ import CommunityOrCollectionLevelPubSettings from '../CommunitySettings/Communit import DashboardSettingsFrame, { type Subtab } from '../DashboardSettingsFrame'; import CollectionDetailsEditor from './CollectionDetailsEditor'; import CollectionMetadataEditor from './CollectionMetadataEditor'; +import { ExportCollectionButton } from './ExportCollectionButton'; + +type PastExport = { + id: string; + createdAt: string; + isProcessing: boolean; + output: { downloadUrl: string; ftpUploaded: boolean } | null; + error: string | null; +}; + +type FtpTargetOption = { + id: string; + host: string; + filePath: string | null; + ftpType: string; +}; type Props = { settingsData: { depositTarget?: DepositTarget | null; + collectionExports?: PastExport[] | null; + ftpTargets?: FtpTargetOption[] | null; }; }; const CollectionSettings = (props: Props) => { const { - settingsData: { depositTarget }, + settingsData: { depositTarget, collectionExports, ftpTargets }, } = props; const { communityData, @@ -131,6 +149,20 @@ const CollectionSettings = (props: Props) => { />, ], }, + collection.kind !== 'tag' && { + id: 'export', + title: 'Export', + icon: 'export', + hideSaveButton: true, + sections: [ + + + , + ], + }, ]); return ( diff --git a/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx new file mode 100644 index 0000000000..ef50b36b5b --- /dev/null +++ b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx @@ -0,0 +1,327 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { Button, Callout, Collapse, HTMLSelect, HTMLTable, Spinner, Tag } from '@blueprintjs/core'; + +import { apiFetch } from 'client/utils/apiFetch'; +import { usePageContext } from 'utils/hooks'; + +import './exportCollectionButton.scss'; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const POLL_INTERVAL_MS = 3000; + +type PastExport = { + id: string; + createdAt: string; + isProcessing: boolean; + output: { downloadUrl: string; ftpUploaded: boolean; skippedPubs?: string[] } | null; + error: string | null; +}; + +const normalizeError = (err: unknown): string => { + if (!err) return ''; + if (typeof err === 'string') return err; + if (typeof err === 'object') { + const msg = (err as any).message ?? (err as any).error; + if (msg) return String(msg); + const json = JSON.stringify(err); + return json === '{}' ? 'An unknown error occurred.' : json; + } + return String(err); +}; + +type FtpTargetOption = { + id: string; + host: string; + filePath: string | null; + ftpType: string; +}; + +type Props = { + pastExports: PastExport[]; + ftpTargets: FtpTargetOption[]; +}; + +export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets }: Props) => { + const { + communityData, + scopeData: { + elements: { activeCollection }, + }, + } = usePageContext(); + + const [isRequesting, setIsRequesting] = useState(false); + const [activeWorkerTaskId, setActiveWorkerTaskId] = useState(null); + const [exports, setExports] = useState(initialExports ?? []); + const [error, setError] = useState(null); + const [warning, setWarning] = useState(null); + const [selectedFtpTargetId, setSelectedFtpTargetId] = useState(''); + const [historyOpen, setHistoryOpen] = useState(false); + const pollRef = useRef | null>(null); + const pollErrorCountRef = useRef(0); + + const clearPoll = () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + useEffect(() => { + const inProgress = initialExports.find((e) => e.isProcessing); + if (inProgress) { + setActiveWorkerTaskId(inProgress.id); + } + }, []); + + useEffect(() => { + if (!activeWorkerTaskId) { + clearPoll(); + return; + } + pollErrorCountRef.current = 0; + pollRef.current = setInterval(async () => { + try { + const task = await apiFetch( + `/api/workerTasks?workerTaskId=${activeWorkerTaskId}`, + ); + pollErrorCountRef.current = 0; + if (!task.isProcessing) { + clearPoll(); + setActiveWorkerTaskId(null); + const errMsg = task.error ? normalizeError(task.error) : null; + if (errMsg) { + setError(errMsg); + } else { + const skipped: string[] = task.output?.skippedPubs ?? []; + if (skipped.length > 0) { + setWarning( + `${skipped.length} pub${skipped.length === 1 ? ' was' : 's were'} skipped because no export files could be found: ${skipped.join(', ')}`, + ); + } + } + setExports((prev) => + prev.map((e) => + e.id === activeWorkerTaskId + ? { + ...e, + isProcessing: false, + output: task.output ?? null, + error: errMsg, + } + : e, + ), + ); + } + } catch (e: any) { + // Stop polling if task is definitively not found + if (e?.error === 'WorkerTask not found') { + clearPoll(); + setActiveWorkerTaskId(null); + setError('Export task not found. The export may have failed to start.'); + setExports((prev) => + prev.map((exp) => + exp.id === activeWorkerTaskId + ? { ...exp, isProcessing: false, error: 'Task not found' } + : exp, + ), + ); + return; + } + // Stop after 5 consecutive transient failures + pollErrorCountRef.current += 1; + if (pollErrorCountRef.current >= 5) { + clearPoll(); + setActiveWorkerTaskId(null); + setError('Lost connection while waiting for export. Please refresh the page.'); + } + } + }, POLL_INTERVAL_MS); + return clearPoll; + }, [activeWorkerTaskId]); + + const startExport = useCallback(async () => { + if (!activeCollection) return; + setIsRequesting(true); + setError(null); + setWarning(null); + try { + const body: Record = { collectionId: activeCollection.id }; + if (selectedFtpTargetId) body.ftpTargetId = selectedFtpTargetId; + const response = await apiFetch('/api/collections/export', { + method: 'POST', + body: JSON.stringify(body), + }); + const newExport: PastExport = { + id: response.workerTaskId, + createdAt: new Date().toISOString(), + isProcessing: true, + output: null, + error: null, + }; + setExports((prev) => [newExport, ...prev]); + setActiveWorkerTaskId(response.workerTaskId); + } catch (e: any) { + const msg = + e instanceof Error ? e.message : 'Something went wrong, please try again later.'; + setError(msg); + } finally { + setIsRequesting(false); + } + }, [activeCollection, selectedFtpTargetId]); + + const hasInProgress = exports.some((e) => e.isProcessing); + const isButtonDisabled = isRequesting || hasInProgress || !activeCollection; + + return ( +
+
+ {ftpTargets.length > 0 && ( + setSelectedFtpTargetId(e.target.value)} + > + + {ftpTargets.map((t) => ( + + ))} + + )} + +
+ + {error && ( + + {error} + + )} + {warning && ( + + {warning} + + )} + + {exports.length > 0 && ( +
+ + + + + + Date + Status + FTP + + + + + {exports.map((exportItem) => { + const createdAt = new Date(exportItem.createdAt); + const expiresAt = new Date( + createdAt.getTime() + SEVEN_DAYS_MS, + ); + const isExpired = Date.now() > expiresAt.getTime(); + const downloadUrl = exportItem.output?.downloadUrl ?? null; + const hasUrl = + !exportItem.isProcessing && + !exportItem.error && + downloadUrl !== null; + + let status: React.ReactNode; + if (exportItem.isProcessing) { + status = ( + + Processing + + ); + } else if (exportItem.error) { + status = ( + + Error + + ); + } else if (isExpired) { + status = ( + + Expired + + ); + } else { + status = ( + + Ready + + ); + } + + return ( + + {createdAt.toLocaleString()} + {status} + + {exportItem.output?.ftpUploaded ? ( + + Uploaded + + ) : ( + '—' + )} + + + {hasUrl && !isExpired && ( + + Download + + )} + + + ); + })} + + + +
+ )} +
+ ); +}; diff --git a/client/containers/DashboardSettings/CollectionSettings/exportCollectionButton.scss b/client/containers/DashboardSettings/CollectionSettings/exportCollectionButton.scss new file mode 100644 index 0000000000..3d801cf43d --- /dev/null +++ b/client/containers/DashboardSettings/CollectionSettings/exportCollectionButton.scss @@ -0,0 +1,26 @@ +.export-collection-button-component { + .export-controls { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .export-history { + margin-top: 20px; + + .history-toggle { + background: none; + border: none; + cursor: pointer; + font-size: 13px; + font-weight: 600; + padding: 0; + color: inherit; + + &:hover { + text-decoration: underline; + } + } + } +} diff --git a/server/collection/api.ts b/server/collection/api.ts index 3a4180545b..7dc505ed0d 100644 --- a/server/collection/api.ts +++ b/server/collection/api.ts @@ -6,7 +6,9 @@ import { prepareResource, submitResource } from 'deposit/datacite/deposit'; import { transformCollectionToResource } from 'deposit/transform/collection'; import { assertCommunityApprovedForDoi } from 'server/doi/permissions'; import { generateDoi } from 'server/doi/queries'; +import { WorkerTask } from 'server/models'; import { ForbiddenError, NotFoundError } from 'server/utils/errors'; +import { addWorkerTask } from 'server/utils/workers'; import { contract } from 'utils/api/contract'; import { expect } from 'utils/assert'; import { createGetRequestIds } from 'utils/getRequestIds'; @@ -73,6 +75,46 @@ export const collectionServer = s.router(contract.collection, { ); return { status: 200, body: body.id }; }, + export: async ({ req, body }) => { + const { collectionId, ftpTargetId } = body; + const collection = await findCollection(collectionId); + if (!collection) { + throw new NotFoundError(); + } + const permissions = await getPermissions({ + userId: req.user.id, + collectionId, + communityId: collection.communityId, + }); + if (!permissions.collectionExport) { + throw new ForbiddenError(); + } + + const runningTask = await WorkerTask.findOne({ + where: { + type: 'collectionExport', + input: { collectionId }, + isProcessing: true, + }, + }); + if (runningTask) { + return { + status: 200, + body: { workerTaskId: runningTask.id, message: 'Export already in progress' }, + }; + } + + const workerTask = await addWorkerTask({ + type: 'collectionExport', + input: { + collectionId, + communityId: collection.communityId, + ftpTargetId: ftpTargetId ?? null, + }, + }); + return { status: 200, body: { workerTaskId: workerTask.id } }; + }, + doi: { deposit: async ({ req, params }) => { const { collectionId } = params; diff --git a/server/collection/permissions.ts b/server/collection/permissions.ts index 14532e5f87..1e383687e5 100644 --- a/server/collection/permissions.ts +++ b/server/collection/permissions.ts @@ -31,6 +31,8 @@ export const getPermissions = async ({ loginId: userId, }); const isAuthenticated = scopeData.activePermissions.canManage; + const canAdmin = + scopeData.activePermissions.canAdmin || scopeData.activePermissions.isSuperAdmin; if (!scopeData.elements.activeCollection) { return { create: isAuthenticated }; } @@ -39,6 +41,7 @@ export const getPermissions = async ({ create: isAuthenticated, update: isAuthenticated ? collectionUpdatePremission : (false as const), destroy: isAuthenticated, + collectionExport: canAdmin, }; }; @@ -46,4 +49,5 @@ export type Permissions = { create?: boolean; update?: false | typeof collectionUpdatePremission; destroy?: boolean; + collectionExport?: boolean; }; diff --git a/server/collection/queries.ts b/server/collection/queries.ts index bcfc3b4a79..6cd508612e 100644 --- a/server/collection/queries.ts +++ b/server/collection/queries.ts @@ -3,7 +3,7 @@ import type { CollectionLayout } from 'utils/layout'; import type { collectionUpdatePremission } from './permissions'; -import { Collection, CollectionAttribution, Community, includeUserModel } from 'server/models'; +import { Collection, CollectionAttribution, Community, WorkerTask, includeUserModel } from 'server/models'; import { PubPubError } from 'server/utils/errors'; import { findAcceptableSlug, slugIsAvailable } from 'server/utils/slugs'; import { expect } from 'utils/assert'; @@ -150,3 +150,11 @@ export const findCollection = (collectionId: string) => Collection.findOne({ where: { id: collectionId }, ...findCollectionOptions }) as Promise< types.DefinitelyHas >; + +export const getCollectionExports = (collectionId: string) => + WorkerTask.findAll({ + where: { type: 'collectionExport', input: { collectionId } }, + attributes: ['id', 'createdAt', 'isProcessing', 'output', 'error'], + order: [['createdAt', 'DESC']], + limit: 20, + }); diff --git a/server/routes/dashboardSettings.tsx b/server/routes/dashboardSettings.tsx index f663539d04..8e287d90f6 100644 --- a/server/routes/dashboardSettings.tsx +++ b/server/routes/dashboardSettings.tsx @@ -5,7 +5,9 @@ import React from 'react'; import { Router } from 'express'; import { getCommunityExports } from 'server/community/queries'; +import { getCollectionExports } from 'server/collection/queries'; import { getCommunityDepositTarget } from 'server/depositTarget/queries'; +import { FtpTarget } from 'server/models'; import Html from 'server/Html'; import { ForbiddenError, handleErrors, NotFoundError } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; @@ -15,8 +17,14 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); -const getSettingsData = async (initialData: InitialData, pubSlug?: string, isAdmin?: boolean) => { - const [depositTarget, pubData, archives] = await Promise.all([ +const getSettingsData = async ( + initialData: InitialData, + pubSlug?: string, + isAdmin?: boolean, + collectionSlug?: string, +) => { + const activeCollection = initialData.scopeData.elements.activeCollection; + const [depositTarget, pubData, archives, collectionExports, ftpTargets] = await Promise.all([ getCommunityDepositTarget(initialData.communityData.id), pubSlug ? getPubForRequest({ @@ -26,10 +34,21 @@ const getSettingsData = async (initialData: InitialData, pubSlug?: string, isAdm }) : null, isAdmin ? getCommunityExports(initialData.communityData.id) : null, + isAdmin && collectionSlug && activeCollection + ? getCollectionExports(activeCollection.id) + : null, + isAdmin && collectionSlug + ? FtpTarget.findAll({ + where: { communityId: initialData.communityData.id }, + attributes: ['id', 'host', 'filePath', 'ftpType'], + }) + : null, ]); const baseSettingsData = { depositTarget, archives, + collectionExports, + ftpTargets, }; if (pubSlug) { return { @@ -67,6 +86,7 @@ router.get( initialData, req.params.pubSlug, initialData.scopeData.activePermissions.canAdmin, + req.params.collectionSlug, ); return renderToNodeStream( diff --git a/server/utils/workers.ts b/server/utils/workers.ts index 339e389c01..e03210e974 100644 --- a/server/utils/workers.ts +++ b/server/utils/workers.ts @@ -137,7 +137,7 @@ export const addWorkerTask = async ({ input, priority = getDefaultTaskPriority(), }: { - type: 'import' | 'export' | 'communityExport' | 'accountExport'; + type: 'import' | 'export' | 'communityExport' | 'accountExport' | 'collectionExport'; input: any; priority?: number; }) => { diff --git a/utils/api/contracts/collection.ts b/utils/api/contracts/collection.ts index 194755c25d..6a73a99476 100644 --- a/utils/api/contracts/collection.ts +++ b/utils/api/contracts/collection.ts @@ -189,6 +189,29 @@ export const collectionRouter = { }, }, }, + /** + * `POST /api/collections/export` + * + * Export a collection as a ZIP of PDFs and JATS XML + * + * @access You need to be **logged in** and have access to this resource. + */ + export: { + path: '/api/collections/export', + method: 'POST', + summary: 'Export a collection as a ZIP', + description: 'Export a collection as a ZIP of PDFs and JATS XML for all released pubs.', + body: z.object({ + collectionId: z.string().uuid(), + ftpTargetId: z.string().uuid().optional(), + }), + responses: { + 200: z.object({ + workerTaskId: z.string().uuid(), + message: z.string().optional(), + }), + }, + }, /** * `GET /api/collections/:collectionId/resource` * diff --git a/workers/tasks/collectionExport.ts b/workers/tasks/collectionExport.ts new file mode 100644 index 0000000000..4ac313057c --- /dev/null +++ b/workers/tasks/collectionExport.ts @@ -0,0 +1,217 @@ +import archiver from 'archiver'; +import { Op } from 'sequelize'; +import { PassThrough } from 'stream'; + +import { Collection, Community, Export, FtpTarget, Pub, Release, WorkerTask } from 'server/models'; +import { env } from 'server/env'; +import { getOrStartExportTask } from 'server/export/queries'; +import { exportsClient } from 'server/utils/s3'; +import { uploadFileViaSftp } from 'server/utils/sftp'; +import { getCollectionPubsInCollection } from 'server/utils/collectionQueries'; +import { updateWorkerTask } from 'server/workerTask/queries'; +import { aes256Decrypt } from 'utils/crypto'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const waitForExportUrl = async ( + pubId: string, + format: string, + historyKey: number, + timeoutMs = 120_000, +): Promise => { + // Prefer an export that matches the latest release's exact historyKey. + const exactMatch = await Export.findOne({ + where: { pubId, format, historyKey, url: { [Op.ne]: null } }, + }); + if (exactMatch?.url) return exactMatch.url; + + // Trigger generation for the exact historyKey (one attempt only). + const result = await getOrStartExportTask({ pubId, format, historyKey }); + if (result.url) return result.url; + + if (result.taskId) { + // Poll until the export task finishes. + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + // biome-ignore lint/performance/noAwaitInLoops: intentional polling loop + await sleep(3000); + // biome-ignore lint/performance/noAwaitInLoops: intentional polling loop + const exp = await Export.findOne({ + where: { pubId, format, historyKey, url: { [Op.ne]: null } }, + }); + if (exp?.url) return exp.url; + // biome-ignore lint/performance/noAwaitInLoops: intentional polling loop + const task = await WorkerTask.findByPk(result.taskId, { + attributes: ['isProcessing', 'error'], + }); + if (!task || task.error) break; + if (!task.isProcessing) break; + } + } + + // Generation didn't produce a result — fall back to the most recent + // completed export for this pub+format regardless of historyKey. This + // covers the case where the export was generated from the draft view at a + // historyKey that differs from the release's. + const fallback = await Export.findOne({ + where: { pubId, format, url: { [Op.ne]: null } }, + order: [['createdAt', 'DESC']], + }); + return fallback?.url ?? null; +}; + +export const collectionExportTask = async ({ + collectionId, + communityId, + ftpTargetId, + workerTaskId, +}: { + collectionId: string; + communityId: string; + ftpTargetId?: string | null; + workerTaskId?: string; +}) => { + const [collection, community] = await Promise.all([ + Collection.findByPk(collectionId, { attributes: ['id', 'slug', 'title'] }), + Community.findByPk(communityId, { attributes: ['id', 'subdomain'] }), + ]); + + if (!collection || !community) { + throw new Error(`Collection or community not found`); + } + + const collectionPubs = await getCollectionPubsInCollection(collectionId); + const pubIds = collectionPubs.map((cp) => cp.pubId); + + const pubs = await Pub.findAll({ + where: { id: pubIds }, + include: [ + { + model: Release, + as: 'releases', + attributes: ['historyKey', 'createdAt'], + separate: true, + order: [['createdAt', 'DESC']], + }, + ], + attributes: ['id', 'slug'], + }); + + const pubsWithReleases = pubs.filter((p) => p.releases && p.releases.length > 0); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const zipName = `${community.subdomain}-${collection.slug}-${timestamp}.zip`; + + const archiveStream = archiver('zip', { zlib: { level: 6 } }); + const chunks: Buffer[] = []; + + archiveStream.on('data', (chunk: Buffer) => chunks.push(chunk)); + + const archiveFinished = new Promise((resolve, reject) => { + archiveStream.on('end', resolve); + archiveStream.on('error', reject); + }); + + const skippedPubs: string[] = []; + let filesAdded = 0; + + for (const pub of pubsWithReleases) { + const latestRelease = pub.releases![0]; + const { historyKey } = latestRelease; + + const [pdfUrl, jatsUrl] = await Promise.all([ + waitForExportUrl(pub.id, 'pdf', historyKey), + waitForExportUrl(pub.id, 'jats', historyKey), + ]); + + let pubFilesAdded = 0; + + if (pdfUrl) { + try { + const response = await fetch(pdfUrl); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const buffer = Buffer.from(await response.arrayBuffer()); + archiveStream.append(buffer, { name: `${pub.slug}/${pub.slug}.pdf` }); + pubFilesAdded++; + filesAdded++; + } catch (err) { + console.error(`[collectionExport] Failed to fetch PDF for ${pub.slug}:`, err); + } + } + + if (jatsUrl) { + try { + const response = await fetch(jatsUrl); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const buffer = Buffer.from(await response.arrayBuffer()); + archiveStream.append(buffer, { name: `${pub.slug}/${pub.slug}.xml` }); + pubFilesAdded++; + filesAdded++; + } catch (err) { + console.error(`[collectionExport] Failed to fetch JATS XML for ${pub.slug}:`, err); + } + } + + if (pubFilesAdded === 0) { + skippedPubs.push(pub.slug); + } + } + + archiveStream.finalize(); + await archiveFinished; + + if (filesAdded === 0) { + const reason = + pubsWithReleases.length === 0 + ? 'This collection has no published pubs to export.' + : 'No files could be added to the archive — all pub exports were missing or failed to download.'; + throw new Error(reason); + } + + const zipBuffer = Buffer.concat(chunks); + const s3Key = `exports/collections/${collectionId}/${zipName}`; + + const passthrough = new PassThrough(); + passthrough.end(zipBuffer); + await exportsClient.uploadFileSplit(s3Key, passthrough, { queueSize: 4 }); + + const downloadUrl = await exportsClient.getPresignedUrl(s3Key); + + let ftpUploaded = false; + if (ftpTargetId) { + const ftpTarget = await FtpTarget.findByPk(ftpTargetId); + if (ftpTarget?.host && ftpTarget.username && ftpTarget.password && ftpTarget.passwordInitVec) { + const password = aes256Decrypt( + ftpTarget.password, + env.AES_ENCRYPTION_KEY!, + ftpTarget.passwordInitVec, + ); + const baseDir = ftpTarget.filePath?.replace(/\/$/, '') ?? ''; + const remotePath = baseDir ? `${baseDir}/${zipName}` : zipName; + try { + await uploadFileViaSftp( + { + host: ftpTarget.host, + port: ftpTarget.port ?? undefined, + username: ftpTarget.username, + password, + }, + remotePath, + zipBuffer, + ); + ftpUploaded = true; + } catch (err) { + console.error('[collectionExport] FTP upload failed:', err); + } + } + } + + if (workerTaskId) { + await updateWorkerTask({ + id: workerTaskId, + body: { output: { downloadUrl, ftpUploaded, skippedPubs } }, + }); + } + + return { downloadUrl, ftpUploaded, skippedPubs }; +}; diff --git a/workers/worker.ts b/workers/worker.ts index 40a1b14156..32570d6605 100644 --- a/workers/worker.ts +++ b/workers/worker.ts @@ -11,6 +11,7 @@ import { isMainThread, parentPort, workerData } from 'worker_threads'; import { sequelizeSyncPromise } from 'server/sequelize'; import { accountExportTask } from './tasks/accountExport'; +import { collectionExportTask } from './tasks/collectionExport'; import { communityExportTask } from './tasks/communityExport'; import { exportTask } from './tasks/export'; import { importTask } from './tasks/import'; @@ -25,6 +26,7 @@ const taskMap = { import: importTask, communityExport: communityExportTask, accountExport: accountExportTask, + collectionExport: collectionExportTask, }; export type TaskType = keyof typeof taskMap; From cca4d6a948405ae77790085c00bd23b8fc1f2fb2 Mon Sep 17 00:00:00 2001 From: gabestein Date: Tue, 30 Jun 2026 21:28:53 -0400 Subject: [PATCH 06/22] fix lint errors --- .../ExportCollectionButton.tsx | 25 +++-- .../FtpTargets/FtpTargets.tsx | 13 ++- server/collection/queries.ts | 8 +- server/community/model.ts | 11 ++- server/models.ts | 2 +- server/routes/dashboardSettings.tsx | 4 +- server/routes/superAdminDashboard.tsx | 4 +- workers/tasks/collectionExport.ts | 97 +++++++++++-------- 8 files changed, 102 insertions(+), 62 deletions(-) diff --git a/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx index ef50b36b5b..0654aaec10 100644 --- a/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx +++ b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx @@ -44,7 +44,6 @@ type Props = { export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets }: Props) => { const { - communityData, scopeData: { elements: { activeCollection }, }, @@ -82,9 +81,7 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets pollErrorCountRef.current = 0; pollRef.current = setInterval(async () => { try { - const task = await apiFetch( - `/api/workerTasks?workerTaskId=${activeWorkerTaskId}`, - ); + const task = await apiFetch(`/api/workerTasks?workerTaskId=${activeWorkerTaskId}`); pollErrorCountRef.current = 0; if (!task.isProcessing) { clearPoll(); @@ -199,14 +196,26 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets {isRequesting ? ( <> Starting...{' '} - + ) : hasInProgress ? ( <> Export in progress...{' '} - + @@ -249,9 +258,7 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets {exports.map((exportItem) => { const createdAt = new Date(exportItem.createdAt); - const expiresAt = new Date( - createdAt.getTime() + SEVEN_DAYS_MS, - ); + const expiresAt = new Date(createdAt.getTime() + SEVEN_DAYS_MS); const isExpired = Date.now() > expiresAt.getTime(); const downloadUrl = exportItem.output?.downloadUrl ?? null; const hasUrl = diff --git a/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx b/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx index 2f41248555..b5d981f30f 100644 --- a/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx +++ b/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx @@ -162,7 +162,15 @@ const FtpTargets = (props: Props) => { } finally { setIsLoading(false); } - }, [createSearch, createFtpType, createPort, createHost, createFilePath, createUsername, createPassword]); + }, [ + createSearch, + createFtpType, + createPort, + createHost, + createFilePath, + createUsername, + createPassword, + ]); const openEdit = useCallback((target: FtpTargetRow) => { setEditTarget(target); @@ -630,8 +638,7 @@ const FtpTargets = (props: Props) => { >

- Delete the FTP target for{' '} - {pendingDelete?.communityTitle} ( + Delete the FTP target for {pendingDelete?.communityTitle} ( {pendingDelete?.host})?

This cannot be undone.

diff --git a/server/collection/queries.ts b/server/collection/queries.ts index 6cd508612e..56e204f27e 100644 --- a/server/collection/queries.ts +++ b/server/collection/queries.ts @@ -3,7 +3,13 @@ import type { CollectionLayout } from 'utils/layout'; import type { collectionUpdatePremission } from './permissions'; -import { Collection, CollectionAttribution, Community, WorkerTask, includeUserModel } from 'server/models'; +import { + Collection, + CollectionAttribution, + Community, + includeUserModel, + WorkerTask, +} from 'server/models'; import { PubPubError } from 'server/utils/errors'; import { findAcceptableSlug, slugIsAvailable } from 'server/utils/slugs'; import { expect } from 'utils/assert'; diff --git a/server/community/model.ts b/server/community/model.ts index a93d9b4fbd..89023b8133 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -29,7 +29,16 @@ import { } from 'sequelize-typescript'; import { CommunityTemplate } from '../communityTemplate/model'; -import { Collection, DepositTarget, FtpTarget, Member, Page, Pub, ScopeSummary, SpamTag } from '../models'; +import { + Collection, + DepositTarget, + FtpTarget, + Member, + Page, + Pub, + ScopeSummary, + SpamTag, +} from '../models'; @DefaultScope(() => ({ attributes: { exclude: ['searchVector'] } })) @Table diff --git a/server/models.ts b/server/models.ts index da4f64e912..f704c5a784 100644 --- a/server/models.ts +++ b/server/models.ts @@ -16,7 +16,6 @@ import { CommunityTemplate } from './communityTemplate/model'; import { CrossrefDepositRecord } from './crossrefDepositRecord/model'; import { CustomScript } from './customScript/model'; import { DepositTarget } from './depositTarget/model'; -import { FtpTarget } from './ftpTarget/model'; import { Discussion } from './discussion/model'; import { DiscussionAnchor } from './discussionAnchor/model'; import { Doc } from './doc/model'; @@ -30,6 +29,7 @@ import { FacetBinding as FacetBindingModel } from './facets/models/facetBinding' import { FeatureFlag } from './featureFlag/model'; import { FeatureFlagCommunity } from './featureFlagCommunity/model'; import { FeatureFlagUser } from './featureFlagUser/model'; +import { FtpTarget } from './ftpTarget/model'; import { Hub } from './hub/model'; import { HubCommunity } from './hubCommunity/model'; import { HubManager } from './hubManager/model'; diff --git a/server/routes/dashboardSettings.tsx b/server/routes/dashboardSettings.tsx index 8e287d90f6..e09a17adf2 100644 --- a/server/routes/dashboardSettings.tsx +++ b/server/routes/dashboardSettings.tsx @@ -4,11 +4,11 @@ import React from 'react'; import { Router } from 'express'; -import { getCommunityExports } from 'server/community/queries'; import { getCollectionExports } from 'server/collection/queries'; +import { getCommunityExports } from 'server/community/queries'; import { getCommunityDepositTarget } from 'server/depositTarget/queries'; -import { FtpTarget } from 'server/models'; import Html from 'server/Html'; +import { FtpTarget } from 'server/models'; import { ForbiddenError, handleErrors, NotFoundError } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; import { getPubForRequest } from 'server/utils/queryHelpers'; diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index 7cd1d97493..b0f49c1272 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -36,9 +36,9 @@ import { } from 'server/utils/cloudflareCustomHostnames'; import { BadRequestError, ForbiddenError, handleErrors, NotFoundError } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; +import { testSftpConnection } from 'server/utils/sftp'; import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; import { aes256Decrypt, aes256Encrypt } from 'utils/crypto'; -import { testSftpConnection } from 'server/utils/sftp'; import { getSuperAdminTabUrl, isSuperAdminTabKind, @@ -370,7 +370,7 @@ router.get('/api/superadmin/communities/search', async (req, res, next) => { } const communityIds = communities.map((c) => c.id); - let excludedIds = new Set(); + const excludedIds = new Set(); if (excludeWithDepositTarget) { const existingTargets = await DepositTarget.findAll({ diff --git a/workers/tasks/collectionExport.ts b/workers/tasks/collectionExport.ts index 4ac313057c..db656e91b1 100644 --- a/workers/tasks/collectionExport.ts +++ b/workers/tasks/collectionExport.ts @@ -2,12 +2,12 @@ import archiver from 'archiver'; import { Op } from 'sequelize'; import { PassThrough } from 'stream'; -import { Collection, Community, Export, FtpTarget, Pub, Release, WorkerTask } from 'server/models'; import { env } from 'server/env'; import { getOrStartExportTask } from 'server/export/queries'; +import { Collection, Community, Export, FtpTarget, Pub, Release, WorkerTask } from 'server/models'; +import { getCollectionPubsInCollection } from 'server/utils/collectionQueries'; import { exportsClient } from 'server/utils/s3'; import { uploadFileViaSftp } from 'server/utils/sftp'; -import { getCollectionPubsInCollection } from 'server/utils/collectionQueries'; import { updateWorkerTask } from 'server/workerTask/queries'; import { aes256Decrypt } from 'utils/crypto'; @@ -112,48 +112,54 @@ export const collectionExportTask = async ({ archiveStream.on('error', reject); }); - const skippedPubs: string[] = []; - let filesAdded = 0; - - for (const pub of pubsWithReleases) { - const latestRelease = pub.releases![0]; - const { historyKey } = latestRelease; - - const [pdfUrl, jatsUrl] = await Promise.all([ - waitForExportUrl(pub.id, 'pdf', historyKey), - waitForExportUrl(pub.id, 'jats', historyKey), - ]); - - let pubFilesAdded = 0; - - if (pdfUrl) { - try { - const response = await fetch(pdfUrl); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const buffer = Buffer.from(await response.arrayBuffer()); - archiveStream.append(buffer, { name: `${pub.slug}/${pub.slug}.pdf` }); - pubFilesAdded++; - filesAdded++; - } catch (err) { - console.error(`[collectionExport] Failed to fetch PDF for ${pub.slug}:`, err); - } - } + const fetchFile = async (url: string, name: string) => { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const buffer = Buffer.from(await response.arrayBuffer()); + archiveStream.append(buffer, { name }); + }; + + const pubOutcomes = await Promise.allSettled( + pubsWithReleases.map(async (pub) => { + const { historyKey } = pub.releases![0]; + + const [pdfUrl, jatsUrl] = await Promise.all([ + waitForExportUrl(pub.id, 'pdf', historyKey), + waitForExportUrl(pub.id, 'jats', historyKey), + ]); + + const fileResults = await Promise.allSettled([ + pdfUrl + ? fetchFile(pdfUrl, `${pub.slug}/${pub.slug}.pdf`) + : Promise.reject(new Error('no pdf export')), + jatsUrl + ? fetchFile(jatsUrl, `${pub.slug}/${pub.slug}.xml`) + : Promise.reject(new Error('no jats export')), + ]); + + fileResults.forEach((r, i) => { + if (r.status === 'rejected') { + const label = i === 0 ? 'PDF' : 'JATS XML'; + console.error( + `[collectionExport] Failed to fetch ${label} for ${pub.slug}:`, + r.reason, + ); + } + }); - if (jatsUrl) { - try { - const response = await fetch(jatsUrl); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const buffer = Buffer.from(await response.arrayBuffer()); - archiveStream.append(buffer, { name: `${pub.slug}/${pub.slug}.xml` }); - pubFilesAdded++; - filesAdded++; - } catch (err) { - console.error(`[collectionExport] Failed to fetch JATS XML for ${pub.slug}:`, err); - } - } + return { + slug: pub.slug, + filesAdded: fileResults.filter((r) => r.status === 'fulfilled').length, + }; + }), + ); - if (pubFilesAdded === 0) { - skippedPubs.push(pub.slug); + const skippedPubs: string[] = []; + let filesAdded = 0; + for (const outcome of pubOutcomes) { + if (outcome.status === 'fulfilled') { + filesAdded += outcome.value.filesAdded; + if (outcome.value.filesAdded === 0) skippedPubs.push(outcome.value.slug); } } @@ -180,7 +186,12 @@ export const collectionExportTask = async ({ let ftpUploaded = false; if (ftpTargetId) { const ftpTarget = await FtpTarget.findByPk(ftpTargetId); - if (ftpTarget?.host && ftpTarget.username && ftpTarget.password && ftpTarget.passwordInitVec) { + if ( + ftpTarget?.host && + ftpTarget.username && + ftpTarget.password && + ftpTarget.passwordInitVec + ) { const password = aes256Decrypt( ftpTarget.password, env.AES_ENCRYPTION_KEY!, From 3ca6a1753fba64f155fc84a6c41a1061af9c78f7 Mon Sep 17 00:00:00 2001 From: gabestein Date: Wed, 1 Jul 2026 07:34:48 -0400 Subject: [PATCH 07/22] fix: init sftp client before mock hoist --- server/ftpTarget/__tests__/api.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/server/ftpTarget/__tests__/api.test.ts b/server/ftpTarget/__tests__/api.test.ts index 2f6be3a990..70b341c3b3 100644 --- a/server/ftpTarget/__tests__/api.test.ts +++ b/server/ftpTarget/__tests__/api.test.ts @@ -3,11 +3,14 @@ import { vi } from 'vitest'; import { FtpTarget } from 'server/models'; import { login, modelize, setup, teardown } from 'stubstub'; -const SftpClient = vi.fn().mockImplementation(() => ({ - connect: vi.fn().mockResolvedValue(undefined), - exists: vi.fn().mockResolvedValue('d'), - end: vi.fn().mockResolvedValue(undefined), -})); +const { SftpClient } = vi.hoisted(() => { + const SftpClient = vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockResolvedValue('d'), + end: vi.fn().mockResolvedValue(undefined), + })); + return { SftpClient }; +}); vi.mock('ssh2-sftp-client', () => ({ default: SftpClient })); From f8b10e3a06343fa81f4b80d3f3a30e2ee2701d0d Mon Sep 17 00:00:00 2001 From: gabestein Date: Wed, 1 Jul 2026 10:30:32 -0400 Subject: [PATCH 08/22] fix lint errors and remove unneeded superadmin test --- .../ExportCollectionButton.tsx | 2 +- server/ftpTarget/__tests__/api.test.ts | 223 ------------------ workers/tasks/collectionExport.ts | 2 - 3 files changed, 1 insertion(+), 226 deletions(-) delete mode 100644 server/ftpTarget/__tests__/api.test.ts diff --git a/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx index 0654aaec10..6f72c73597 100644 --- a/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx +++ b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx @@ -71,7 +71,7 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets if (inProgress) { setActiveWorkerTaskId(inProgress.id); } - }, []); + }, [initialExports]); useEffect(() => { if (!activeWorkerTaskId) { diff --git a/server/ftpTarget/__tests__/api.test.ts b/server/ftpTarget/__tests__/api.test.ts deleted file mode 100644 index 70b341c3b3..0000000000 --- a/server/ftpTarget/__tests__/api.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { vi } from 'vitest'; - -import { FtpTarget } from 'server/models'; -import { login, modelize, setup, teardown } from 'stubstub'; - -const { SftpClient } = vi.hoisted(() => { - const SftpClient = vi.fn().mockImplementation(() => ({ - connect: vi.fn().mockResolvedValue(undefined), - exists: vi.fn().mockResolvedValue('d'), - end: vi.fn().mockResolvedValue(undefined), - })); - return { SftpClient }; -}); - -vi.mock('ssh2-sftp-client', () => ({ default: SftpClient })); - -const models = modelize` - Community communityA { - Member { - permissions: "admin" - User communityAdmin {} - } - } - Community communityB {} - Community communityC {} - User superAdmin { - isSuperAdmin: true - } - User regularUser {} -`; - -setup(beforeAll, async () => { - await models.resolve(); -}); - -teardown(afterAll, () => {}); - -describe('/api/superadmin/ftp-targets', () => { - it('requires superadmin to create an FTP target', async () => { - const { communityA, regularUser } = models; - const agent = await login(regularUser); - await agent - .post('/api/superadmin/ftp-targets') - .send({ communityId: communityA.id, host: 'sftp.example.com', ftpType: 'sftp' }) - .expect(403); - }); - - it('requires superadmin to list via search', async () => { - const { regularUser } = models; - const agent = await login(regularUser); - await agent.get('/api/superadmin/communities/search?q=test').expect(403); - }); - - it('creates an SFTP target with credentials', async () => { - const { communityA, superAdmin } = models; - const agent = await login(superAdmin); - const { body } = await agent - .post('/api/superadmin/ftp-targets') - .send({ - communityId: communityA.id, - host: 'sftp.example.com', - ftpType: 'sftp', - port: 22, - filePath: '/uploads', - username: 'ftpuser', - password: 'secret', - }) - .expect(201); - expect(body.host).toBe('sftp.example.com'); - expect(body.ftpType).toBe('sftp'); - expect(body.port).toBe(22); - expect(body.filePath).toBe('/uploads'); - expect(body.hasCredentials).toBe(true); - expect(body.password).toBeUndefined(); - expect(body.passwordInitVec).toBeUndefined(); - }); - - it('creates an FTPS target without credentials', async () => { - const { communityB, superAdmin } = models; - const agent = await login(superAdmin); - const { body } = await agent - .post('/api/superadmin/ftp-targets') - .send({ - communityId: communityB.id, - host: 'ftps.example.com', - ftpType: 'ftps', - }) - .expect(201); - expect(body.ftpType).toBe('ftps'); - expect(body.hasCredentials).toBe(false); - }); - - it('allows multiple FTP targets for the same community', async () => { - const { communityA, superAdmin } = models; - const agent = await login(superAdmin); - const { body } = await agent - .post('/api/superadmin/ftp-targets') - .send({ communityId: communityA.id, host: 'second.example.com', ftpType: 'ftps' }) - .expect(201); - expect(body.host).toBe('second.example.com'); - expect(body.ftpType).toBe('ftps'); - }); - - it('updates host, port, and filePath', async () => { - const { communityA, superAdmin } = models; - const existing = await FtpTarget.findOne({ where: { communityId: communityA.id } }); - const agent = await login(superAdmin); - const { body } = await agent - .put(`/api/superadmin/ftp-targets/${existing!.id}`) - .send({ host: 'newhost.example.com', port: 2222, filePath: '/new/path' }) - .expect(200); - expect(body.host).toBe('newhost.example.com'); - expect(body.port).toBe(2222); - expect(body.filePath).toBe('/new/path'); - }); - - it('clears credentials when username is set to empty string', async () => { - const { communityA, superAdmin } = models; - const existing = await FtpTarget.findOne({ where: { communityId: communityA.id } }); - const agent = await login(superAdmin); - const { body } = await agent - .put(`/api/superadmin/ftp-targets/${existing!.id}`) - .send({ username: '' }) - .expect(200); - expect(body.hasCredentials).toBe(false); - }); - - it('deletes an FTP target', async () => { - const { communityB, superAdmin } = models; - const existing = await FtpTarget.findOne({ where: { communityId: communityB.id } }); - const agent = await login(superAdmin); - const { body } = await agent - .delete(`/api/superadmin/ftp-targets/${existing!.id}`) - .expect(200); - expect(body.id).toBe(existing!.id); - const gone = await FtpTarget.findByPk(existing!.id); - expect(gone).toBeNull(); - }); - - it('copies an FTP target to another community', async () => { - const { communityA, communityC, superAdmin } = models; - const source = await FtpTarget.findOne({ where: { communityId: communityA.id } }); - const agent = await login(superAdmin); - const { body } = await agent - .post(`/api/superadmin/ftp-targets/${source!.id}/copy`) - .send({ communityId: communityC.id, copyCredentials: false }) - .expect(200); - expect(body.communityId).toBe(communityC.id); - expect(body.host).toBe(source!.host); - expect(body.ftpType).toBe(source!.ftpType); - expect(body.hasCredentials).toBe(false); - }); - - it('copies credentials when copyCredentials is true', async () => { - const { communityA, communityC, superAdmin } = models; - // create a fresh target with credentials on communityA - const agent = await login(superAdmin); - const { body: created } = await agent - .post('/api/superadmin/ftp-targets') - .send({ - communityId: communityA.id, - host: 'creds.example.com', - ftpType: 'sftp', - username: 'user', - password: 'pass', - }) - .expect(201); - const { body: copied } = await agent - .post(`/api/superadmin/ftp-targets/${created.id}/copy`) - .send({ communityId: communityC.id, copyCredentials: true }) - .expect(200); - expect(copied.hasCredentials).toBe(true); - expect(copied.password).toBeUndefined(); - }); - - it('requires superadmin to update', async () => { - const { communityA, regularUser } = models; - const existing = await FtpTarget.findOne({ where: { communityId: communityA.id } }); - const agent = await login(regularUser); - await agent - .put(`/api/superadmin/ftp-targets/${existing!.id}`) - .send({ host: 'evil.example.com' }) - .expect(403); - }); - - it('rejects create when SFTP connection fails', async () => { - const { communityC, superAdmin } = models; - SftpClient.mockImplementationOnce(() => ({ - connect: vi.fn().mockRejectedValue(new Error('Authentication failed')), - end: vi.fn().mockResolvedValue(undefined), - })); - const agent = await login(superAdmin); - await agent - .post('/api/superadmin/ftp-targets') - .send({ - communityId: communityC.id, - host: 'sftp.example.com', - ftpType: 'sftp', - username: 'user', - password: 'wrongpassword', - }) - .expect(400); - }); - - it('validates ftpType on create', async () => { - const { communityC, superAdmin } = models; - const agent = await login(superAdmin); - await agent - .post('/api/superadmin/ftp-targets') - .send({ communityId: communityC.id, host: 'sftp.example.com', ftpType: 'invalid' }) - .expect(400); - }); - - it('returns communities with existing FTP targets in search (multiple targets allowed)', async () => { - const { communityA, superAdmin } = models; - const agent = await login(superAdmin); - const { body } = await agent - .get(`/api/superadmin/communities/search?q=${encodeURIComponent(communityA.subdomain)}`) - .expect(200); - const found = body.find((c: any) => c.id === communityA.id); - expect(found).toBeDefined(); - }); -}); diff --git a/workers/tasks/collectionExport.ts b/workers/tasks/collectionExport.ts index db656e91b1..8e3454d8c6 100644 --- a/workers/tasks/collectionExport.ts +++ b/workers/tasks/collectionExport.ts @@ -35,12 +35,10 @@ const waitForExportUrl = async ( while (Date.now() - start < timeoutMs) { // biome-ignore lint/performance/noAwaitInLoops: intentional polling loop await sleep(3000); - // biome-ignore lint/performance/noAwaitInLoops: intentional polling loop const exp = await Export.findOne({ where: { pubId, format, historyKey, url: { [Op.ne]: null } }, }); if (exp?.url) return exp.url; - // biome-ignore lint/performance/noAwaitInLoops: intentional polling loop const task = await WorkerTask.findByPk(result.taskId, { attributes: ['isProcessing', 'error'], }); From b6a743822a6b40ec779812a0b812966738db7d92 Mon Sep 17 00:00:00 2001 From: gabestein Date: Wed, 1 Jul 2026 11:33:23 -0400 Subject: [PATCH 09/22] use s3 to download and add missing file warnings --- .../ExportCollectionButton.tsx | 100 +++++++++++++++++- workers/tasks/collectionExport.ts | 26 +++-- 2 files changed, 114 insertions(+), 12 deletions(-) diff --git a/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx index 6f72c73597..0866b69cd3 100644 --- a/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx +++ b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx @@ -1,6 +1,15 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Button, Callout, Collapse, HTMLSelect, HTMLTable, Spinner, Tag } from '@blueprintjs/core'; +import { + Button, + Callout, + Collapse, + HTMLSelect, + HTMLTable, + Spinner, + Tag, + Tooltip, +} from '@blueprintjs/core'; import { apiFetch } from 'client/utils/apiFetch'; import { usePageContext } from 'utils/hooks'; @@ -14,7 +23,12 @@ type PastExport = { id: string; createdAt: string; isProcessing: boolean; - output: { downloadUrl: string; ftpUploaded: boolean; skippedPubs?: string[] } | null; + output: { + downloadUrl: string; + ftpUploaded: boolean; + skippedPubs?: string[]; + partialPubs?: { slug: string; missingFormats: string[] }[]; + } | null; error: string | null; }; @@ -53,7 +67,7 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets const [activeWorkerTaskId, setActiveWorkerTaskId] = useState(null); const [exports, setExports] = useState(initialExports ?? []); const [error, setError] = useState(null); - const [warning, setWarning] = useState(null); + const [warning, setWarning] = useState(null); const [selectedFtpTargetId, setSelectedFtpTargetId] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); const pollRef = useRef | null>(null); @@ -91,9 +105,38 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets setError(errMsg); } else { const skipped: string[] = task.output?.skippedPubs ?? []; - if (skipped.length > 0) { + const partial: { slug: string; missingFormats: string[] }[] = + task.output?.partialPubs ?? []; + if (skipped.length > 0 || partial.length > 0) { setWarning( - `${skipped.length} pub${skipped.length === 1 ? ' was' : 's were'} skipped because no export files could be found: ${skipped.join(', ')}`, + <> + {skipped.length > 0 && ( +

+ + {skipped.length === 1 + ? '1 pub was skipped' + : `${skipped.length} pubs were skipped`} + {' '} + (no files found): {skipped.join(', ')} +

+ )} + {partial.length > 0 && ( +

0 ? '6px 0 0' : 0 }}> + + {partial.length === 1 + ? '1 pub is missing files' + : `${partial.length} pubs are missing files`} + + :{' '} + {partial + .map( + ({ slug, missingFormats }) => + `${slug} (missing ${missingFormats.join(', ')})`, + ) + .join('; ')} +

+ )} + , ); } } @@ -252,6 +295,7 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets Date Status FTP + Files @@ -297,6 +341,32 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets ); } + const itemSkipped = exportItem.output?.skippedPubs ?? []; + const itemPartial = exportItem.output?.partialPubs ?? []; + const hasFileWarnings = + itemSkipped.length > 0 || itemPartial.length > 0; + const fileWarningContent = hasFileWarnings ? ( + <> + {itemSkipped.length > 0 && ( +
+ Skipped (no files):{' '} + {itemSkipped.join(', ')} +
+ )} + {itemPartial.length > 0 && ( +
+ Missing files:{' '} + {itemPartial + .map( + ({ slug, missingFormats }) => + `${slug} (${missingFormats.join(', ')})`, + ) + .join('; ')} +
+ )} + + ) : null; + return ( {createdAt.toLocaleString()} @@ -310,6 +380,26 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets '—' )} + + {hasFileWarnings ? ( + + + {itemSkipped.length + itemPartial.length}{' '} + {itemSkipped.length + itemPartial.length === + 1 + ? 'pub' + : 'pubs'}{' '} + with issues + + + ) : exportItem.isProcessing || exportItem.error ? ( + '—' + ) : ( + + OK + + )} + {hasUrl && !isExpired && ( { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const buffer = Buffer.from(await response.arrayBuffer()); - archiveStream.append(buffer, { name }); + const key = new URL(url).pathname.slice(1); + const stream = await assetsClient.downloadFile(key); + archiveStream.append(stream, { name }); }; const pubOutcomes = await Promise.allSettled( @@ -145,19 +144,32 @@ export const collectionExportTask = async ({ } }); + const missingFormats = fileResults.flatMap((r, i) => + r.status === 'rejected' ? [i === 0 ? 'PDF' : 'JATS XML'] : [], + ); + return { slug: pub.slug, filesAdded: fileResults.filter((r) => r.status === 'fulfilled').length, + missingFormats, }; }), ); const skippedPubs: string[] = []; + const partialPubs: { slug: string; missingFormats: string[] }[] = []; let filesAdded = 0; for (const outcome of pubOutcomes) { if (outcome.status === 'fulfilled') { filesAdded += outcome.value.filesAdded; - if (outcome.value.filesAdded === 0) skippedPubs.push(outcome.value.slug); + if (outcome.value.filesAdded === 0) { + skippedPubs.push(outcome.value.slug); + } else if (outcome.value.missingFormats.length > 0) { + partialPubs.push({ + slug: outcome.value.slug, + missingFormats: outcome.value.missingFormats, + }); + } } } @@ -218,7 +230,7 @@ export const collectionExportTask = async ({ if (workerTaskId) { await updateWorkerTask({ id: workerTaskId, - body: { output: { downloadUrl, ftpUploaded, skippedPubs } }, + body: { output: { downloadUrl, ftpUploaded, skippedPubs, partialPubs } }, }); } From a0ca74e0da6aec6c7782699d04965b586a47f701 Mon Sep 17 00:00:00 2001 From: gabestein Date: Wed, 1 Jul 2026 11:42:27 -0400 Subject: [PATCH 10/22] fix: warn if PDF can't be downloaded --- workers/tasks/collectionExport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workers/tasks/collectionExport.ts b/workers/tasks/collectionExport.ts index 8fa2e5dc5d..6a19df6be4 100644 --- a/workers/tasks/collectionExport.ts +++ b/workers/tasks/collectionExport.ts @@ -234,5 +234,5 @@ export const collectionExportTask = async ({ }); } - return { downloadUrl, ftpUploaded, skippedPubs }; + return { downloadUrl, ftpUploaded, skippedPubs, partialPubs }; }; From ab6a61c056f12ed9b46216d2605572deefa8934b Mon Sep 17 00:00:00 2001 From: gabestein Date: Wed, 1 Jul 2026 11:51:53 -0400 Subject: [PATCH 11/22] fix: always have containing folder --- .../CollectionSettings/CollectionSettings.tsx | 11 +++++++++++ workers/tasks/collectionExport.ts | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx b/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx index 52d6574144..df61915d45 100644 --- a/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx +++ b/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx @@ -156,6 +156,17 @@ const CollectionSettings = (props: Props) => { hideSaveButton: true, sections: [ +

+ Creates a zip export for the collection containing the PDF and JATS files + for each Pub in the collection. + {ftpTargets && ( + + {` `}If an FTP target is selected, the zip file will be uploaded to + that target. + + )} +

+ p.releases && p.releases.length > 0); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const zipName = `${community.subdomain}-${collection.slug}-${timestamp}.zip`; + const folderName = `${community.subdomain}-${collection.slug}-${timestamp}`; + const zipName = `${folderName}.zip`; const archiveStream = archiver('zip', { zlib: { level: 6 } }); const chunks: Buffer[] = []; @@ -127,10 +128,10 @@ export const collectionExportTask = async ({ const fileResults = await Promise.allSettled([ pdfUrl - ? fetchFile(pdfUrl, `${pub.slug}/${pub.slug}.pdf`) + ? fetchFile(pdfUrl, `${folderName}/${pub.slug}/${pub.slug}.pdf`) : Promise.reject(new Error('no pdf export')), jatsUrl - ? fetchFile(jatsUrl, `${pub.slug}/${pub.slug}.xml`) + ? fetchFile(jatsUrl, `${folderName}/${pub.slug}/${pub.slug}.xml`) : Promise.reject(new Error('no jats export')), ]); From eb91dc7fc949f550a5db02360eeed354864e208e Mon Sep 17 00:00:00 2001 From: gabestein Date: Wed, 1 Jul 2026 12:23:32 -0400 Subject: [PATCH 12/22] fix and new: FTP connect errors and multi-upload --- .../ExportCollectionButton.tsx | 115 ++++++++++++++---- .../FtpTargets/FtpTargets.tsx | 8 +- server/collection/api.ts | 4 +- server/routes/superAdminDashboard.tsx | 22 +++- utils/api/contracts/collection.ts | 2 +- workers/tasks/collectionExport.ts | 86 +++++++------ 6 files changed, 165 insertions(+), 72 deletions(-) diff --git a/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx index 0866b69cd3..dfc4d3d0fe 100644 --- a/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx +++ b/client/containers/DashboardSettings/CollectionSettings/ExportCollectionButton.tsx @@ -3,8 +3,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Button, Callout, + Checkbox, Collapse, - HTMLSelect, HTMLTable, Spinner, Tag, @@ -25,7 +25,7 @@ type PastExport = { isProcessing: boolean; output: { downloadUrl: string; - ftpUploaded: boolean; + ftpTargetResults?: { id: string; host: string; uploaded: boolean; error?: string }[]; skippedPubs?: string[]; partialPubs?: { slug: string; missingFormats: string[] }[]; } | null; @@ -68,7 +68,7 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets const [exports, setExports] = useState(initialExports ?? []); const [error, setError] = useState(null); const [warning, setWarning] = useState(null); - const [selectedFtpTargetId, setSelectedFtpTargetId] = useState(''); + const [selectedFtpTargetIds, setSelectedFtpTargetIds] = useState>(new Set()); const [historyOpen, setHistoryOpen] = useState(false); const pollRef = useRef | null>(null); const pollErrorCountRef = useRef(0); @@ -107,7 +107,10 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets const skipped: string[] = task.output?.skippedPubs ?? []; const partial: { slug: string; missingFormats: string[] }[] = task.output?.partialPubs ?? []; - if (skipped.length > 0 || partial.length > 0) { + const failedFtp = ( + task.output?.ftpTargetResults ?? [] + ).filter((r: { uploaded: boolean }) => !r.uploaded); + if (skipped.length > 0 || partial.length > 0 || failedFtp.length > 0) { setWarning( <> {skipped.length > 0 && ( @@ -136,6 +139,29 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets .join('; ')}

)} + {failedFtp.length > 0 && ( +

0 || partial.length > 0 + ? '6px 0 0' + : 0, + }} + > + + {failedFtp.length === 1 + ? '1 FTP upload failed' + : `${failedFtp.length} FTP uploads failed`} + + :{' '} + {failedFtp + .map( + (r: { host: string; error?: string }) => + `${r.host}${r.error ? ` (${r.error})` : ''}`, + ) + .join(', ')} +

+ )} , ); } @@ -186,8 +212,11 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets setError(null); setWarning(null); try { - const body: Record = { collectionId: activeCollection.id }; - if (selectedFtpTargetId) body.ftpTargetId = selectedFtpTargetId; + const body: { collectionId: string; ftpTargetIds?: string[] } = { + collectionId: activeCollection.id, + }; + if (selectedFtpTargetIds.size > 0) + body.ftpTargetIds = Array.from(selectedFtpTargetIds); const response = await apiFetch('/api/collections/export', { method: 'POST', body: JSON.stringify(body), @@ -208,7 +237,7 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets } finally { setIsRequesting(false); } - }, [activeCollection, selectedFtpTargetId]); + }, [activeCollection, selectedFtpTargetIds]); const hasInProgress = exports.some((e) => e.isProcessing); const isButtonDisabled = isRequesting || hasInProgress || !activeCollection; @@ -217,18 +246,26 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets
{ftpTargets.length > 0 && ( - setSelectedFtpTargetId(e.target.value)} - > - +
+ Upload to FTP: {ftpTargets.map((t) => ( - + { + const checked = (e.target as HTMLInputElement).checked; + setSelectedFtpTargetIds((prev) => { + const next = new Set(prev); + if (checked) next.add(t.id); + else next.delete(t.id); + return next; + }); + }} + /> ))} - +
)}
{error && ( @@ -420,7 +420,8 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets <> {results.map((r) => (
- {r.uploaded ? '✓' : '✗'} {r.host} + {r.uploaded ? '✓' : '✗'}{' '} + {r.host} {r.error ? `: ${r.error}` : ''}
))} @@ -434,7 +435,7 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets failed.length === 0 ? 'success' : failed.length < - results.length + results.length ? 'warning' : 'danger' } @@ -449,10 +450,14 @@ export const ExportCollectionButton = ({ pastExports: initialExports, ftpTargets {hasFileWarnings ? ( - + - {itemSkipped.length + itemPartial.length}{' '} - {itemSkipped.length + itemPartial.length === + {itemSkipped.length + + itemPartial.length}{' '} + {itemSkipped.length + + itemPartial.length === 1 ? 'pub' : 'pubs'}{' '} diff --git a/client/containers/DashboardSettings/CollectionSettings/exportCollectionButton.scss b/client/containers/DashboardSettings/CollectionSettings/exportCollectionButton.scss index 3d801cf43d..4f11adee0a 100644 --- a/client/containers/DashboardSettings/CollectionSettings/exportCollectionButton.scss +++ b/client/containers/DashboardSettings/CollectionSettings/exportCollectionButton.scss @@ -6,6 +6,15 @@ flex-wrap: wrap; } + .ftp-target-selection { + margin: 10px; + } + + .ftp-target-label { + font-weight: 600; + } + + .export-history { margin-top: 20px; @@ -23,4 +32,4 @@ } } } -} +} \ No newline at end of file diff --git a/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx b/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx index 7064f2e9ae..70d4da6eaf 100644 --- a/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx +++ b/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx @@ -24,6 +24,7 @@ import './ftpTargets.scss'; type FtpTargetRow = { id: string; communityId: string | null; + name: string; ftpType: 'sftp' | 'ftps' | null; port: number | null; host: string | null; @@ -90,6 +91,7 @@ const FtpTargets = (props: Props) => { // Create form const createSearch = useCommunitySearch(); + const [createName, setCreateName] = useState(''); const [createFtpType, setCreateFtpType] = useState<'sftp' | 'ftps'>('sftp'); const [createPort, setCreatePort] = useState(''); const [createHost, setCreateHost] = useState(''); @@ -99,6 +101,7 @@ const FtpTargets = (props: Props) => { // Edit dialog — undefined means "untouched", '' means "user cleared the field" const [editTarget, setEditTarget] = useState(null); + const [editName, setEditName] = useState(''); const [editFtpType, setEditFtpType] = useState<'sftp' | 'ftps'>('sftp'); const [editPort, setEditPort] = useState(''); const [editHost, setEditHost] = useState(''); @@ -119,6 +122,7 @@ const FtpTargets = (props: Props) => { if (!q) return targets; return targets.filter( (t) => + (t.name ?? '').toLowerCase().includes(q) || (t.host ?? '').toLowerCase().includes(q) || (t.ftpType ?? '').toLowerCase().includes(q) || t.communityTitle.toLowerCase().includes(q) || @@ -141,6 +145,7 @@ const FtpTargets = (props: Props) => { try { const result = await apiFetch.post('/api/superadmin/ftp-targets', { communityId: createSearch.selected.id, + name: createName.trim() || undefined, ftpType: createFtpType, port: createPort.trim() ? parseInt(createPort.trim(), 10) : undefined, host: createHost.trim(), @@ -150,13 +155,16 @@ const FtpTargets = (props: Props) => { }); setTargets((prev) => [result, ...prev]); createSearch.reset(); + setCreateName(''); setCreateFtpType('sftp'); setCreatePort(''); setCreateHost(''); setCreateFilePath(''); setCreateUsername(''); setCreatePassword(''); - setSuccess(`FTP target created for "${result.communityTitle}" (${result.host}).`); + setSuccess( + `FTP target${result.name ? ` "${result.name}"` : ''} created for "${result.communityTitle}" (${result.host}).`, + ); } catch (err: any) { setError(err?.error ?? err?.message ?? 'Failed to create FTP target.'); } finally { @@ -164,6 +172,7 @@ const FtpTargets = (props: Props) => { } }, [ createSearch, + createName, createFtpType, createPort, createHost, @@ -174,6 +183,7 @@ const FtpTargets = (props: Props) => { const openEdit = useCallback((target: FtpTargetRow) => { setEditTarget(target); + setEditName(target.name ?? ''); setEditFtpType((target.ftpType as 'sftp' | 'ftps') ?? 'sftp'); setEditPort(target.port != null ? String(target.port) : ''); setEditHost(target.host ?? ''); @@ -193,6 +203,7 @@ const FtpTargets = (props: Props) => { setSuccess(null); try { const body: Record = { + name: editName.trim(), ftpType: editFtpType, host: editHost.trim(), port: editPort.trim() ? parseInt(editPort.trim(), 10) : null, @@ -216,7 +227,7 @@ const FtpTargets = (props: Props) => { } finally { setIsLoading(false); } - }, [editTarget, editFtpType, editPort, editHost, editFilePath, editUsername, editPassword]); + }, [editTarget, editName, editFtpType, editPort, editHost, editFilePath, editUsername, editPassword]); const handleDelete = useCallback(async (target: FtpTargetRow) => { setPendingDelete(null); @@ -315,6 +326,14 @@ const FtpTargets = (props: Props) => { }} /> + + setCreateName(e.target.value)} + disabled={isLoading} + /> + { + @@ -410,6 +430,7 @@ const FtpTargets = (props: Props) => { {filteredTargets.map((t) => ( +
Name Community Subdomain Type
{t.name || } {t.communityTitle} {t.communitySubdomain} @@ -483,6 +504,13 @@ const FtpTargets = (props: Props) => { Community: {editTarget?.communityTitle} ( {editTarget?.communitySubdomain})

+ + setEditName(e.target.value)} + /> + Community, { onDelete: 'CASCADE', as: 'community', foreignKey: 'communityId' }) declare community?: Community; diff --git a/server/routes/dashboardSettings.tsx b/server/routes/dashboardSettings.tsx index e09a17adf2..951696958b 100644 --- a/server/routes/dashboardSettings.tsx +++ b/server/routes/dashboardSettings.tsx @@ -40,7 +40,7 @@ const getSettingsData = async ( isAdmin && collectionSlug ? FtpTarget.findAll({ where: { communityId: initialData.communityData.id }, - attributes: ['id', 'host', 'filePath', 'ftpType'], + attributes: ['id', 'host', 'filePath', 'ftpType', 'name'], }) : null, ]); diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index 14bae6293f..255775289d 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -112,17 +112,7 @@ const getTabProps = async (tabKind: SuperAdminTabKind, locationData: types.Locat order: [['createdAt', 'DESC']], }); return { - ftpTargets: targets.map((t) => ({ - id: t.id, - communityId: t.communityId, - ftpType: t.ftpType, - port: t.port, - host: t.host, - filePath: t.filePath, - hasCredentials: Boolean(t.username), - communityTitle: t.community?.title ?? '(unknown)', - communitySubdomain: t.community?.subdomain ?? '(unknown)', - })), + ftpTargets: targets.map(sanitizeFtpTarget), }; } if (tabKind === 'exploreCommunities') { @@ -317,6 +307,7 @@ const resolveCommunity = async (identifier: string) => { const sanitizeFtpTarget = (t: FtpTarget) => ({ id: t.id, communityId: t.communityId, + name: t.name ?? '', ftpType: t.ftpType, port: t.port, host: t.host, @@ -597,7 +588,7 @@ router.post('/api/superadmin/ftp-targets', async (req, res, next) => { throw new ForbiddenError(); } - const { communityId, ftpType, port, host, filePath, username, password } = req.body; + const { communityId, name, ftpType, port, host, filePath, username, password } = req.body; if (!communityId || !host) { throw new BadRequestError(new Error('communityId and host are required')); } @@ -609,6 +600,7 @@ router.post('/api/superadmin/ftp-targets', async (req, res, next) => { const createData: Record = { communityId: community.id, + name: name ? String(name).trim() : '', ftpType, host: String(host).trim(), port: port != null ? Number(port) : null, @@ -656,9 +648,12 @@ router.put('/api/superadmin/ftp-targets/:id', async (req, res, next) => { throw new NotFoundError(new Error('FTP target not found')); } - const { ftpType, port, host, filePath, username, password } = req.body; + const { name, ftpType, port, host, filePath, username, password } = req.body; const updates: Record = {}; + if (name !== undefined) { + updates.name = String(name).trim(); + } if (ftpType !== undefined) { if (!['sftp', 'ftps'].includes(ftpType)) { throw new BadRequestError(new Error('ftpType must be "sftp" or "ftps"')); diff --git a/tools/migrations/2026_07_01_addNameToFtpTargets.js b/tools/migrations/2026_07_01_addNameToFtpTargets.js new file mode 100644 index 0000000000..e55f7307c0 --- /dev/null +++ b/tools/migrations/2026_07_01_addNameToFtpTargets.js @@ -0,0 +1,7 @@ +module.exports = async ({ Sequelize, sequelize }) => { + await sequelize.queryInterface.addColumn('FtpTargets', 'name', { + type: Sequelize.STRING, + allowNull: true, + defaultValue: '', + }); +}; From e95436f86c39c81147a55ca41adb7632a0ad9d54 Mon Sep 17 00:00:00 2001 From: gabestein Date: Wed, 1 Jul 2026 13:28:17 -0400 Subject: [PATCH 14/22] lint fixes --- .../CollectionSettings/CollectionSettings.tsx | 1 + .../SuperAdminDashboard/FtpTargets/FtpTargets.tsx | 11 ++++++++++- server/routes/superAdminDashboard.tsx | 8 +++++++- workers/tasks/collectionExport.ts | 5 ++++- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx b/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx index df61915d45..bd24f34011 100644 --- a/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx +++ b/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx @@ -28,6 +28,7 @@ type PastExport = { type FtpTargetOption = { id: string; host: string; + name: string | null; filePath: string | null; ftpType: string; }; diff --git a/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx b/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx index 70d4da6eaf..6e47a4b882 100644 --- a/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx +++ b/client/containers/SuperAdminDashboard/FtpTargets/FtpTargets.tsx @@ -227,7 +227,16 @@ const FtpTargets = (props: Props) => { } finally { setIsLoading(false); } - }, [editTarget, editName, editFtpType, editPort, editHost, editFilePath, editUsername, editPassword]); + }, [ + editTarget, + editName, + editFtpType, + editPort, + editHost, + editFilePath, + editUsername, + editPassword, + ]); const handleDelete = useCallback(async (target: FtpTargetRow) => { setPendingDelete(null); diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index 255775289d..4dd7a9ed14 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -34,7 +34,13 @@ import { isCloudflareConfigured, removeCustomHostname, } from 'server/utils/cloudflareCustomHostnames'; -import { BadRequestError, ForbiddenError, handleErrors, HTTPStatusError, NotFoundError } from 'server/utils/errors'; +import { + BadRequestError, + ForbiddenError, + HTTPStatusError, + handleErrors, + NotFoundError, +} from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; import { testSftpConnection } from 'server/utils/sftp'; import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; diff --git a/workers/tasks/collectionExport.ts b/workers/tasks/collectionExport.ts index edb41c7012..850f62823a 100644 --- a/workers/tasks/collectionExport.ts +++ b/workers/tasks/collectionExport.ts @@ -231,7 +231,10 @@ export const collectionExportTask = async ({ ); return { id: targetId, host: ftpTarget.host, uploaded: true }; } catch (err) { - console.error(`[collectionExport] FTP upload failed for ${ftpTarget.host}:`, err); + console.error( + `[collectionExport] FTP upload failed for ${ftpTarget.host}:`, + err, + ); return { id: targetId, host: ftpTarget.host, From 674a3e97cec0e8cfd70e7122d1d1bb35783b3e4e Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 2 Jul 2026 16:05:35 +0200 Subject: [PATCH 15/22] fix: check if targetids are valid --- server/collection/api.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/server/collection/api.ts b/server/collection/api.ts index 1ecd6249c8..4cc5c09e7f 100644 --- a/server/collection/api.ts +++ b/server/collection/api.ts @@ -104,6 +104,22 @@ export const collectionServer = s.router(contract.collection, { }; } + const safeFtpTargetIds = Array.from(new Set(ftpTargetIds ?? [])); + if (safeFtpTargetIds.length > 0) { + const { FtpTarget } = await import('server/models'); + const targets = await FtpTarget.findAll({ + where: { id: safeFtpTargetIds, communityId: collection.communityId }, + attributes: ['id'], + }); + if (targets.length !== safeFtpTargetIds.length) { + throw new ForbiddenError( + new Error( + `Invalid FTP target IDs: ${ftpTargetIds?.filter((id) => !safeFtpTargetIds.includes(id))?.join(', ')}`, + ), + ); + } + } + const workerTask = await addWorkerTask({ type: 'collectionExport', input: { From 278c0ff9cdaad2ce88cc57ad78d00a2643592a88 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 2 Jul 2026 16:22:46 +0200 Subject: [PATCH 16/22] fix: stream export --- server/utils/sftp.ts | 4 +++- workers/tasks/collectionExport.ts | 22 +++++----------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/server/utils/sftp.ts b/server/utils/sftp.ts index 52be6891e4..2c37dc270e 100644 --- a/server/utils/sftp.ts +++ b/server/utils/sftp.ts @@ -1,3 +1,5 @@ +import type { Readable } from 'stream'; + import SftpClient from 'ssh2-sftp-client'; export type SftpConnectionParams = { @@ -46,7 +48,7 @@ export const testSftpConnection = async ( export const uploadFileViaSftp = async ( params: SftpConnectionParams, remotePath: string, - content: Buffer, + content: Buffer | Readable, ): Promise => { const client = new SftpClient(); try { diff --git a/workers/tasks/collectionExport.ts b/workers/tasks/collectionExport.ts index 850f62823a..a1293b7401 100644 --- a/workers/tasks/collectionExport.ts +++ b/workers/tasks/collectionExport.ts @@ -1,6 +1,5 @@ import archiver from 'archiver'; import { Op } from 'sequelize'; -import { PassThrough } from 'stream'; import { env } from 'server/env'; import { getOrStartExportTask } from 'server/export/queries'; @@ -102,14 +101,6 @@ export const collectionExportTask = async ({ const zipName = `${folderName}.zip`; const archiveStream = archiver('zip', { zlib: { level: 6 } }); - const chunks: Buffer[] = []; - - archiveStream.on('data', (chunk: Buffer) => chunks.push(chunk)); - - const archiveFinished = new Promise((resolve, reject) => { - archiveStream.on('end', resolve); - archiveStream.on('error', reject); - }); const fetchFile = async (url: string, name: string) => { const key = new URL(url).pathname.slice(1); @@ -174,10 +165,8 @@ export const collectionExportTask = async ({ } } - archiveStream.finalize(); - await archiveFinished; - if (filesAdded === 0) { + archiveStream.abort(); const reason = pubsWithReleases.length === 0 ? 'This collection has no published pubs to export.' @@ -185,12 +174,10 @@ export const collectionExportTask = async ({ throw new Error(reason); } - const zipBuffer = Buffer.concat(chunks); const s3Key = `exports/collections/${collectionId}/${zipName}`; - const passthrough = new PassThrough(); - passthrough.end(zipBuffer); - await exportsClient.uploadFileSplit(s3Key, passthrough, { queueSize: 4 }); + archiveStream.finalize(); + await exportsClient.uploadFileSplit(s3Key, archiveStream, { queueSize: 4 }); const downloadUrl = await exportsClient.getPresignedUrl(s3Key); @@ -219,6 +206,7 @@ export const collectionExportTask = async ({ const baseDir = ftpTarget.filePath?.replace(/\/$/, '') ?? ''; const remotePath = baseDir ? `${baseDir}/${zipName}` : zipName; try { + const stream = await exportsClient.downloadFile(s3Key); await uploadFileViaSftp( { host: ftpTarget.host, @@ -227,7 +215,7 @@ export const collectionExportTask = async ({ password, }, remotePath, - zipBuffer, + stream, ); return { id: targetId, host: ftpTarget.host, uploaded: true }; } catch (err) { From 82b5945177c66a0de19ca0949015ff89410445bb Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 2 Jul 2026 18:28:15 +0200 Subject: [PATCH 17/22] fix: let pubstash run locally, add sftp container --- .gitignore | 3 +++ infra/dev.sh | 2 +- infra/docker-compose.dev.yml | 35 +++++++++++++++++++++++++++++++++++ infra/sftp/upload/.gitkeep | 0 package.json | 3 ++- pubstash/server.ts | 22 +++++++++++++++++++++- 6 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 infra/sftp/upload/.gitkeep diff --git a/.gitignore b/.gitignore index e4c1d3c863..04f839856f 100755 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ tmp/ planning/ .claude + +infra/sftp/ +!infra/sftp/upload/.gitkeep \ No newline at end of file diff --git a/infra/dev.sh b/infra/dev.sh index 8a96ae2d06..e9eb0dd188 100755 --- a/infra/dev.sh +++ b/infra/dev.sh @@ -51,4 +51,4 @@ esac trap "docker compose -f $COMPOSE_FILE down" EXIT # Start all services, but only show logs from app + worker (not db/rabbitmq noise) -docker compose -f "$COMPOSE_FILE" up --build --attach app --attach worker +docker compose -f "$COMPOSE_FILE" up --build --attach app --attach worker --attach pubstash diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 50ff206dee..36b0c62aa4 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -87,6 +87,41 @@ services: - "${DB_PORT:-5439}:5432" networks: [appnet] + sftp: + image: atmoz/sftp + volumes: + - ./sftp/upload:/home/sftpuser/upload + ports: + - "2222:22" + command: sftpuser:sftppassword:1001 + networks: [appnet] + + pubstash: + build: + context: .. + dockerfile: Dockerfile + target: dev + env_file: + - .env.local + environment: + NODE_ENV: development + PORT: "8080" + MAX_CONCURRENCY: "4" + CHROMIUM_PATH: "/usr/bin/chromium" + CLOUDAMQP_URL: "amqp://appuser:apppassword@rabbitmq:5672/appvhost" + command: ["pnpm", "run", "pubstash-dev"] + networks: [appnet] + healthcheck: + test: + - CMD-SHELL + - "curl -sf http://127.0.0.1:8080/ || exit 1" + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + volumes: + - ..:/app + # cron: # build: # context: .. diff --git a/infra/sftp/upload/.gitkeep b/infra/sftp/upload/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/package.json b/package.json index 00ab32484d..308244f714 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,9 @@ "test-import": "NODE_OPTIONS=--max-old-space-size=8192 firebase emulators:exec --only database --import .firebase/default-contents \"vitest server/pub/__tests__/api.test.ts --runInBand\"", "tools": "NODE_PATH=./client:./ tsx ./tools", "tools-prod": "NODE_PATH=./dist/server/client:./dist/server:./dist tsx ./tools", - "workers-dev": "NODE_PATH=./dist/server/client:./dist/server WORKER=true nodemon dist/server/workers/init --watch dist/server -e js,ts", + "workers-dev": "NODE_PATH=./dist/server/client:./dist/server WORKER=true nodemon dist/server/workers/init --enable-source-maps --watch dist/server -e js,ts", "workers-prod": "NODE_PATH=./dist/server/client:./dist/server WORKER=true node --enable-source-maps dist/server/workers/init", + "pubstash-dev": "NODE_PATH=./dist/server/client:./dist/server:./dist node --enable-source-maps --watch dist/server/pubstash/server.js", "pubstash-prod": "NODE_PATH=./dist/server/client:./dist/server:./dist node --enable-source-maps dist/server/pubstash/server.js", "upload-sentry-sourcemaps": "sentry-cli sourcemaps inject -p pubpub-v6 dist/server && sentry-cli releases files -p pubpub-v6 $SOURCE_VERSION upload-sourcemaps --url-prefix /app --strip-common-prefix --wait dist/server" }, diff --git a/pubstash/server.ts b/pubstash/server.ts index cde26c1893..843d4d7713 100644 --- a/pubstash/server.ts +++ b/pubstash/server.ts @@ -127,6 +127,15 @@ async function convertHtmlToPdf(html: string): Promise { // Accumulate per-page box data from paged.js events const collectedPages: PageBoxes[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warning') { + console.error(`[pubstash] browser ${msg.type()}: ${msg.text()}`); + } + }); + page.on('pageerror', (err) => { + console.error(`[pubstash] browser uncaught error:`, err.message); + }); + // Expose callbacks that paged.js will invoke from the browser context. // Playwright's exposeFunction bridges browser→Node. await page.exposeFunction('__pubstashOnPage', (pageData: any) => { @@ -158,6 +167,14 @@ async function convertHtmlToPdf(html: string): Promise { ); const polyfill = (window as any).PagedPolyfill; + if (!polyfill) { + clearTimeout(timeout); + return reject( + new Error( + 'PagedPolyfill is not defined — polyfill script may not have executed', + ), + ); + } polyfill.on('page', (pg: any) => { const mediabox = pg.element.getBoundingClientRect(); @@ -190,7 +207,10 @@ async function convertHtmlToPdf(html: string): Promise { resolve(); }); - polyfill.preview(); + polyfill.preview().catch((err: any) => { + clearTimeout(timeout); + reject(new Error(`paged.js preview() failed: ${err?.message ?? err}`)); + }); }); }); From c77ea032661892bb0ac31b0cc1445710198c7ea8 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 2 Jul 2026 18:28:28 +0200 Subject: [PATCH 18/22] fix: stream in upload correctly --- workers/tasks/collectionExport.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/workers/tasks/collectionExport.ts b/workers/tasks/collectionExport.ts index a1293b7401..5be2b8d38b 100644 --- a/workers/tasks/collectionExport.ts +++ b/workers/tasks/collectionExport.ts @@ -1,3 +1,4 @@ +import { PassThrough } from 'node:stream'; import archiver from 'archiver'; import { Op } from 'sequelize'; @@ -177,7 +178,10 @@ export const collectionExportTask = async ({ const s3Key = `exports/collections/${collectionId}/${zipName}`; archiveStream.finalize(); - await exportsClient.uploadFileSplit(s3Key, archiveStream, { queueSize: 4 }); + + const collectionExportStream = new PassThrough(); + archiveStream.pipe(collectionExportStream); + await exportsClient.uploadFileSplit(s3Key, collectionExportStream, { queueSize: 4 }); const downloadUrl = await exportsClient.getPresignedUrl(s3Key); From ba673d733a1bc656ce960f9fe8ed779d0e06e234 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 2 Jul 2026 18:29:19 +0200 Subject: [PATCH 19/22] fix: actually show ftptargets --- .../FtpTargets/ftpTargets.scss | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/client/containers/SuperAdminDashboard/FtpTargets/ftpTargets.scss b/client/containers/SuperAdminDashboard/FtpTargets/ftpTargets.scss index ed480cf259..71820d4db6 100644 --- a/client/containers/SuperAdminDashboard/FtpTargets/ftpTargets.scss +++ b/client/containers/SuperAdminDashboard/FtpTargets/ftpTargets.scss @@ -44,28 +44,31 @@ } th:nth-child(1), - td:nth-child(1) { width: 20%; } + td:nth-child(1) { width: 12%; } // Name th:nth-child(2), - td:nth-child(2) { width: 14%; } + td:nth-child(2) { width: 13%; } // Community th:nth-child(3), - td:nth-child(3) { width: 7%; } + td:nth-child(3) { width: 7%; } // Subdomain th:nth-child(4), - td:nth-child(4) { width: 22%; } + td:nth-child(4) { width: 5%; } // Type th:nth-child(5), - td:nth-child(5) { width: 7%; } + td:nth-child(5) { width: 15%; } // Host th:nth-child(6), - td:nth-child(6) { width: 16%; } + td:nth-child(6) { width: 5%; } // Port th:nth-child(7), - td:nth-child(7) { width: 10%; } + td:nth-child(7) { width: 9%; } // Credentials th:nth-child(8), - td:nth-child(8) { width: 100px; text-align: right; overflow: visible; } + td:nth-child(8) { width: 10%; } // File Path + + th:nth-child(9), + td:nth-child(9) { width: 100px; text-align: right; overflow: visible; } // Actions th { font-weight: 600; From 4bdad8838f23f623d2f0e1c1c83b6552788a934a Mon Sep 17 00:00:00 2001 From: gabestein Date: Thu, 2 Jul 2026 16:59:44 -0400 Subject: [PATCH 20/22] fix: check sftp creds on edits --- server/routes/superAdminDashboard.tsx | 56 ++++++++++++++++++++------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index 4dd7a9ed14..c77e08e632 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -682,30 +682,58 @@ router.put('/api/superadmin/ftp-targets/:id', async (req, res, next) => { updates.password = null; updates.passwordInitVec = null; } else { + // Encrypt any new password first (before the connection test). if (password) { + const { encryptedText, initVec } = aes256Encrypt( + password, + env.AES_ENCRYPTION_KEY!, + ); + updates.password = encryptedText; + updates.passwordInitVec = initVec; + } + if (username !== undefined) { + updates.username = username; + } + + // Test connection if any connection-relevant field changed and credentials exist. + const connectionFieldChanged = + host !== undefined || + port !== undefined || + filePath !== undefined || + ftpType !== undefined || + username !== undefined || + password !== undefined; + + const effectiveUsername = username ?? target.username; + const hasCredentials = + Boolean(effectiveUsername) && Boolean(password ?? target.password); + + if (connectionFieldChanged && hasCredentials) { const effectiveHost = updates.host ?? target.host; - const effectivePort = port !== undefined ? updates.port : target.port; + const effectivePort = updates.port !== undefined ? updates.port : target.port; const effectivePath = - filePath !== undefined ? updates.filePath : target.filePath; + updates.filePath !== undefined ? updates.filePath : target.filePath; + const effectivePassword = password + ? password + : aes256Decrypt( + target.password!, + env.AES_ENCRYPTION_KEY!, + target.passwordInitVec!, + ); + await testSftpConnection( - { host: effectiveHost, port: effectivePort, username, password }, + { + host: effectiveHost, + port: effectivePort, + username: effectiveUsername, + password: effectivePassword, + }, effectivePath, ).catch((err) => { throw new BadRequestError(new Error(err.message)); }); - const { encryptedText, initVec } = aes256Encrypt( - password, - env.AES_ENCRYPTION_KEY!, - ); - updates.password = encryptedText; - updates.passwordInitVec = initVec; } - updates.username = username; } - } else if (password) { - const { encryptedText, initVec } = aes256Encrypt(password, env.AES_ENCRYPTION_KEY!); - updates.password = encryptedText; - updates.passwordInitVec = initVec; } await target.update(updates); From eff8d3758e8e09a847e86d94793b9776be5264f9 Mon Sep 17 00:00:00 2001 From: gabestein Date: Thu, 2 Jul 2026 17:02:04 -0400 Subject: [PATCH 21/22] fix: copy ftp target name --- server/routes/superAdminDashboard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index c77e08e632..73fc62b496 100644 --- a/server/routes/superAdminDashboard.tsx +++ b/server/routes/superAdminDashboard.tsx @@ -795,6 +795,7 @@ router.post('/api/superadmin/ftp-targets/:id/copy', async (req, res, next) => { const createData: Record = { communityId: destCommunity.id, + name: source.name ?? '', ftpType: source.ftpType, port: source.port, host: source.host, From b031adab1c17fe20344514e4f76ac1aa1fad6767 Mon Sep 17 00:00:00 2001 From: gabestein Date: Thu, 2 Jul 2026 17:07:09 -0400 Subject: [PATCH 22/22] allow exports of tag collections --- .../DashboardSettings/CollectionSettings/CollectionSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx b/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx index bd24f34011..5d6b6c8335 100644 --- a/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx +++ b/client/containers/DashboardSettings/CollectionSettings/CollectionSettings.tsx @@ -150,7 +150,7 @@ const CollectionSettings = (props: Props) => { />, ], }, - collection.kind !== 'tag' && { + { id: 'export', title: 'Export', icon: 'export',