Repository: mvdicarlo/postybirb Branch: main Commit: 079e51f45ede Files: 1230 Total size: 4.4 MB Directory structure: gitextract_minozs1b/ ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github/ │ └── workflows/ │ ├── build.yml │ ├── ci.yml │ └── i18n.yml.disabled ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── post-merge ├── .prettierignore ├── .prettierrc ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── .yarn/ │ └── patches/ │ ├── @handlewithcare-prosemirror-inputrules-npm-0.1.3-897e37b56f.patch │ ├── @tiptap-html-npm-3.15.3-a9641901db.patch │ ├── jest-snapshot-npm-29.7.0-15ef0a4ad6.patch │ └── strong-log-transformer-npm-2.1.0-45addd9278.patch ├── .yarnrc.yml ├── Dockerfile ├── LICENSE ├── README.md ├── TRANSLATION.md ├── apps/ │ ├── client-server/ │ │ ├── .eslintrc.json │ │ ├── jest.config.ts │ │ ├── project.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── account/ │ │ │ │ │ ├── account.controller.ts │ │ │ │ │ ├── account.events.ts │ │ │ │ │ ├── account.module.ts │ │ │ │ │ ├── account.service.spec.ts │ │ │ │ │ ├── account.service.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-account.dto.ts │ │ │ │ │ │ ├── set-website-data-request.dto.ts │ │ │ │ │ │ └── update-account.dto.ts │ │ │ │ │ └── login-state-poller.ts │ │ │ │ ├── app.controller.ts │ │ │ │ ├── app.module.ts │ │ │ │ ├── app.service.ts │ │ │ │ ├── common/ │ │ │ │ │ ├── controller/ │ │ │ │ │ │ └── postybirb-controller.ts │ │ │ │ │ └── service/ │ │ │ │ │ └── postybirb-service.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── custom-shortcuts/ │ │ │ │ │ ├── custom-shortcut.events.ts │ │ │ │ │ ├── custom-shortcuts.controller.ts │ │ │ │ │ ├── custom-shortcuts.module.ts │ │ │ │ │ ├── custom-shortcuts.service.ts │ │ │ │ │ └── dtos/ │ │ │ │ │ ├── create-custom-shortcut.dto.ts │ │ │ │ │ └── update-custom-shortcut.dto.ts │ │ │ │ ├── directory-watchers/ │ │ │ │ │ ├── directory-watcher.events.ts │ │ │ │ │ ├── directory-watchers.controller.ts │ │ │ │ │ ├── directory-watchers.module.ts │ │ │ │ │ ├── directory-watchers.service.spec.ts │ │ │ │ │ ├── directory-watchers.service.ts │ │ │ │ │ └── dtos/ │ │ │ │ │ ├── check-path.dto.ts │ │ │ │ │ ├── create-directory-watcher.dto.ts │ │ │ │ │ └── update-directory-watcher.dto.ts │ │ │ │ ├── drizzle/ │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── account.entity.ts │ │ │ │ │ │ ├── custom-shortcut.entity.ts │ │ │ │ │ │ ├── database-entity.spec.ts │ │ │ │ │ │ ├── database-entity.ts │ │ │ │ │ │ ├── directory-watcher.entity.ts │ │ │ │ │ │ ├── file-buffer.entity.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── notification.entity.ts │ │ │ │ │ │ ├── post-event.entity.ts │ │ │ │ │ │ ├── post-queue-record.entity.ts │ │ │ │ │ │ ├── post-record.entity.ts │ │ │ │ │ │ ├── settings.entity.ts │ │ │ │ │ │ ├── submission-file.entity.ts │ │ │ │ │ │ ├── submission.entity.ts │ │ │ │ │ │ ├── tag-converter.entity.ts │ │ │ │ │ │ ├── tag-group.entity.ts │ │ │ │ │ │ ├── user-converter.entity.ts │ │ │ │ │ │ ├── user-specified-website-options.entity.ts │ │ │ │ │ │ ├── website-data.entity.ts │ │ │ │ │ │ └── website-options.entity.ts │ │ │ │ │ ├── postybirb-database/ │ │ │ │ │ │ ├── find-options.type.ts │ │ │ │ │ │ ├── postybirb-database.spec.ts │ │ │ │ │ │ ├── postybirb-database.ts │ │ │ │ │ │ ├── postybirb-database.util.ts │ │ │ │ │ │ └── schema-entity-map.ts │ │ │ │ │ └── transaction-context.ts │ │ │ │ ├── file/ │ │ │ │ │ ├── file.controller.ts │ │ │ │ │ ├── file.module.ts │ │ │ │ │ ├── file.service.spec.ts │ │ │ │ │ ├── file.service.ts │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── multer-file-info.ts │ │ │ │ │ │ ├── task-type.enum.ts │ │ │ │ │ │ └── task.ts │ │ │ │ │ ├── services/ │ │ │ │ │ │ ├── create-file.service.ts │ │ │ │ │ │ └── update-file.service.ts │ │ │ │ │ └── utils/ │ │ │ │ │ └── image.util.ts │ │ │ │ ├── file-converter/ │ │ │ │ │ ├── converters/ │ │ │ │ │ │ ├── file-converter.ts │ │ │ │ │ │ └── text-file-converter.ts │ │ │ │ │ ├── file-converter.module.ts │ │ │ │ │ ├── file-converter.service.spec.ts │ │ │ │ │ └── file-converter.service.ts │ │ │ │ ├── form-generator/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ └── form-generation-request.dto.ts │ │ │ │ │ ├── form-generator.controller.ts │ │ │ │ │ ├── form-generator.module.ts │ │ │ │ │ ├── form-generator.service.spec.ts │ │ │ │ │ └── form-generator.service.ts │ │ │ │ ├── image-processing/ │ │ │ │ │ ├── image-processing.module.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── sharp-instance-manager.ts │ │ │ │ ├── legacy-database-importer/ │ │ │ │ │ ├── converters/ │ │ │ │ │ │ ├── legacy-converter.ts │ │ │ │ │ │ ├── legacy-custom-shortcut.converter.spec.ts │ │ │ │ │ │ ├── legacy-custom-shortcut.converter.ts │ │ │ │ │ │ ├── legacy-tag-converter.converter.spec.ts │ │ │ │ │ │ ├── legacy-tag-converter.converter.ts │ │ │ │ │ │ ├── legacy-tag-group.converter.spec.ts │ │ │ │ │ │ ├── legacy-tag-group.converter.ts │ │ │ │ │ │ ├── legacy-user-account.converter.spec.ts │ │ │ │ │ │ ├── legacy-user-account.converter.ts │ │ │ │ │ │ ├── legacy-website-data.converter.spec.ts │ │ │ │ │ │ └── legacy-website-data.converter.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ └── legacy-import.dto.ts │ │ │ │ │ ├── legacy-database-importer.controller.ts │ │ │ │ │ ├── legacy-database-importer.module.ts │ │ │ │ │ ├── legacy-database-importer.service.ts │ │ │ │ │ ├── legacy-entities/ │ │ │ │ │ │ ├── legacy-converter-entity.ts │ │ │ │ │ │ ├── legacy-custom-shortcut.ts │ │ │ │ │ │ ├── legacy-tag-converter.ts │ │ │ │ │ │ ├── legacy-tag-group.ts │ │ │ │ │ │ ├── legacy-user-account.ts │ │ │ │ │ │ └── legacy-website-data.ts │ │ │ │ │ ├── transformers/ │ │ │ │ │ │ ├── implementations/ │ │ │ │ │ │ │ ├── bluesky-data-transformer.ts │ │ │ │ │ │ │ ├── custom-data-transformer.ts │ │ │ │ │ │ │ ├── discord-data-transformer.ts │ │ │ │ │ │ │ ├── e621-data-transformer.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── inkbunny-data-transformer.ts │ │ │ │ │ │ │ ├── megalodon-data-transformer.ts │ │ │ │ │ │ │ ├── telegram-data-transformer.ts │ │ │ │ │ │ │ └── twitter-data-transformer.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── legacy-website-data-transformer.ts │ │ │ │ │ │ └── website-data-transformer-registry.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── ndjson-parser.ts │ │ │ │ │ └── website-name-mapper.ts │ │ │ │ ├── logs/ │ │ │ │ │ ├── logs.controller.ts │ │ │ │ │ ├── logs.module.ts │ │ │ │ │ └── logs.service.ts │ │ │ │ ├── notifications/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-notification.dto.ts │ │ │ │ │ │ └── update-notification.dto.ts │ │ │ │ │ ├── notification.events.ts │ │ │ │ │ ├── notifications.controller.ts │ │ │ │ │ ├── notifications.module.ts │ │ │ │ │ ├── notifications.service.spec.ts │ │ │ │ │ └── notifications.service.ts │ │ │ │ ├── post/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── post-queue-action.dto.ts │ │ │ │ │ │ └── queue-post-record.dto.ts │ │ │ │ │ ├── errors/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── invalid-post-chain.error.ts │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── cancellable-token.ts │ │ │ │ │ │ ├── cancellation-error.ts │ │ │ │ │ │ └── posting-file.ts │ │ │ │ │ ├── post.controller.ts │ │ │ │ │ ├── post.module.ts │ │ │ │ │ ├── post.service.ts │ │ │ │ │ └── services/ │ │ │ │ │ ├── post-file-resizer/ │ │ │ │ │ │ ├── post-file-resizer.service.spec.ts │ │ │ │ │ │ └── post-file-resizer.service.ts │ │ │ │ │ ├── post-manager/ │ │ │ │ │ │ └── post-manager.controller.ts │ │ │ │ │ ├── post-manager-v2/ │ │ │ │ │ │ ├── base-post-manager.service.ts │ │ │ │ │ │ ├── file-submission-post-manager.service.spec.ts │ │ │ │ │ │ ├── file-submission-post-manager.service.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── message-submission-post-manager.service.spec.ts │ │ │ │ │ │ ├── message-submission-post-manager.service.ts │ │ │ │ │ │ └── post-manager-registry.service.ts │ │ │ │ │ ├── post-queue/ │ │ │ │ │ │ ├── post-queue.controller.ts │ │ │ │ │ │ ├── post-queue.service.spec.ts │ │ │ │ │ │ └── post-queue.service.ts │ │ │ │ │ └── post-record-factory/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── post-event.repository.ts │ │ │ │ │ ├── post-record-factory.service.spec.ts │ │ │ │ │ └── post-record-factory.service.ts │ │ │ │ ├── post-parsers/ │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── description-node/ │ │ │ │ │ │ │ ├── converters/ │ │ │ │ │ │ │ │ ├── base-converter.ts │ │ │ │ │ │ │ │ ├── bbcode-converter.ts │ │ │ │ │ │ │ │ ├── custom-converter.ts │ │ │ │ │ │ │ │ ├── html-converter.ts │ │ │ │ │ │ │ │ ├── npf-converter.spec.ts │ │ │ │ │ │ │ │ ├── npf-converter.ts │ │ │ │ │ │ │ │ └── plaintext-converter.ts │ │ │ │ │ │ │ ├── description-node-tree.ts │ │ │ │ │ │ │ ├── description-node.base.ts │ │ │ │ │ │ │ └── description-node.types.ts │ │ │ │ │ │ └── description-node.spec.ts │ │ │ │ │ ├── parsers/ │ │ │ │ │ │ ├── content-warning-parser.ts │ │ │ │ │ │ ├── description-parser.service.spec.ts │ │ │ │ │ │ ├── description-parser.service.ts │ │ │ │ │ │ ├── rating-parser.spec.ts │ │ │ │ │ │ ├── rating-parser.ts │ │ │ │ │ │ ├── tag-parser.service.spec.ts │ │ │ │ │ │ ├── tag-parser.service.ts │ │ │ │ │ │ ├── title-parser.spec.ts │ │ │ │ │ │ └── title-parser.ts │ │ │ │ │ ├── post-parsers.module.ts │ │ │ │ │ └── post-parsers.service.ts │ │ │ │ ├── remote/ │ │ │ │ │ ├── models/ │ │ │ │ │ │ └── update-cookies-remote.dto.ts │ │ │ │ │ ├── remote.controller.ts │ │ │ │ │ ├── remote.middleware.ts │ │ │ │ │ ├── remote.module.ts │ │ │ │ │ └── remote.service.ts │ │ │ │ ├── security-and-authentication/ │ │ │ │ │ └── ssl.ts │ │ │ │ ├── settings/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── update-settings.dto.ts │ │ │ │ │ │ └── update-startup-settings.dto.ts │ │ │ │ │ ├── settings.controller.ts │ │ │ │ │ ├── settings.events.ts │ │ │ │ │ ├── settings.module.ts │ │ │ │ │ ├── settings.service.spec.ts │ │ │ │ │ └── settings.service.ts │ │ │ │ ├── submission/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── apply-multi-submission.dto.ts │ │ │ │ │ │ ├── apply-template-options.dto.ts │ │ │ │ │ │ ├── create-submission.dto.ts │ │ │ │ │ │ ├── reorder-submission-files.dto.ts │ │ │ │ │ │ ├── reorder-submission.dto.ts │ │ │ │ │ │ ├── template-option.dto.ts │ │ │ │ │ │ ├── update-alt-file.dto.ts │ │ │ │ │ │ ├── update-submission-template-name.dto.ts │ │ │ │ │ │ └── update-submission.dto.ts │ │ │ │ │ ├── file-submission.controller.ts │ │ │ │ │ ├── services/ │ │ │ │ │ │ ├── file-submission.service.ts │ │ │ │ │ │ ├── message-submission.service.ts │ │ │ │ │ │ ├── submission-service.interface.ts │ │ │ │ │ │ ├── submission.service.spec.ts │ │ │ │ │ │ └── submission.service.ts │ │ │ │ │ ├── submission.controller.ts │ │ │ │ │ ├── submission.events.ts │ │ │ │ │ └── submission.module.ts │ │ │ │ ├── tag-converters/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-tag-converter.dto.ts │ │ │ │ │ │ └── update-tag-converter.dto.ts │ │ │ │ │ ├── tag-converter.events.ts │ │ │ │ │ ├── tag-converters.controller.ts │ │ │ │ │ ├── tag-converters.module.ts │ │ │ │ │ ├── tag-converters.service.spec.ts │ │ │ │ │ └── tag-converters.service.ts │ │ │ │ ├── tag-groups/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-tag-group.dto.ts │ │ │ │ │ │ └── update-tag-group.dto.ts │ │ │ │ │ ├── tag-group.events.ts │ │ │ │ │ ├── tag-groups.controller.ts │ │ │ │ │ ├── tag-groups.module.ts │ │ │ │ │ ├── tag-groups.service.spec.ts │ │ │ │ │ └── tag-groups.service.ts │ │ │ │ ├── update/ │ │ │ │ │ ├── update.controller.ts │ │ │ │ │ ├── update.events.ts │ │ │ │ │ ├── update.module.ts │ │ │ │ │ └── update.service.ts │ │ │ │ ├── user-converters/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-user-converter.dto.ts │ │ │ │ │ │ └── update-user-converter.dto.ts │ │ │ │ │ ├── user-converter.events.ts │ │ │ │ │ ├── user-converters.controller.ts │ │ │ │ │ ├── user-converters.module.ts │ │ │ │ │ ├── user-converters.service.spec.ts │ │ │ │ │ └── user-converters.service.ts │ │ │ │ ├── user-specified-website-options/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-user-specified-website-options.dto.ts │ │ │ │ │ │ └── update-user-specified-website-options.dto.ts │ │ │ │ │ ├── user-specified-website-options.controller.ts │ │ │ │ │ ├── user-specified-website-options.module.ts │ │ │ │ │ ├── user-specified-website-options.service.spec.ts │ │ │ │ │ └── user-specified-website-options.service.ts │ │ │ │ ├── utils/ │ │ │ │ │ ├── blocknote-to-tiptap.ts │ │ │ │ │ ├── coerce.util.ts │ │ │ │ │ ├── filesize.util.ts │ │ │ │ │ ├── html-parser.util.ts │ │ │ │ │ ├── select-option.util.ts │ │ │ │ │ └── wait.util.ts │ │ │ │ ├── validation/ │ │ │ │ │ ├── validation.module.ts │ │ │ │ │ ├── validation.service.spec.ts │ │ │ │ │ ├── validation.service.ts │ │ │ │ │ └── validators/ │ │ │ │ │ ├── common-field-validators.ts │ │ │ │ │ ├── datetime-field-validators.ts │ │ │ │ │ ├── description-validators.ts │ │ │ │ │ ├── file-submission-validators.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── select-field-validators.ts │ │ │ │ │ ├── tag-validators.ts │ │ │ │ │ ├── title-validators.ts │ │ │ │ │ └── validator.type.ts │ │ │ │ ├── web-socket/ │ │ │ │ │ ├── models/ │ │ │ │ │ │ └── web-socket-event.ts │ │ │ │ │ ├── web-socket-adapter.ts │ │ │ │ │ ├── web-socket-gateway.ts │ │ │ │ │ ├── web-socket.events.ts │ │ │ │ │ └── web-socket.module.ts │ │ │ │ ├── website-options/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-website-options.dto.ts │ │ │ │ │ │ ├── preview-description.dto.ts │ │ │ │ │ │ ├── update-submission-website-options.dto.ts │ │ │ │ │ │ ├── update-website-options.dto.ts │ │ │ │ │ │ └── validate-website-options.dto.ts │ │ │ │ │ ├── website-options.controller.ts │ │ │ │ │ ├── website-options.module.ts │ │ │ │ │ ├── website-options.service.spec.ts │ │ │ │ │ └── website-options.service.ts │ │ │ │ └── websites/ │ │ │ │ ├── commons/ │ │ │ │ │ ├── post-builder.spec.ts │ │ │ │ │ ├── post-builder.ts │ │ │ │ │ ├── validator-passthru.ts │ │ │ │ │ └── validator.ts │ │ │ │ ├── decorators/ │ │ │ │ │ ├── disable-ads.decorator.ts │ │ │ │ │ ├── login-flow.decorator.ts │ │ │ │ │ ├── supports-files.decorator.ts │ │ │ │ │ ├── supports-username-shortcut.decorator.ts │ │ │ │ │ ├── website-decorator-props.ts │ │ │ │ │ └── website-metadata.decorator.ts │ │ │ │ ├── dtos/ │ │ │ │ │ └── oauth-website-request.dto.ts │ │ │ │ ├── implementations/ │ │ │ │ │ ├── artconomy/ │ │ │ │ │ │ ├── artconomy.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── artconomy-account-data.ts │ │ │ │ │ │ ├── artconomy-file-submission.ts │ │ │ │ │ │ └── artconomy-message-submission.ts │ │ │ │ │ ├── aryion/ │ │ │ │ │ │ ├── aryion.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── aryion-account-data.ts │ │ │ │ │ │ └── aryion-file-submission.ts │ │ │ │ │ ├── bluesky/ │ │ │ │ │ │ ├── bluesky.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── bluesky-file-submission.ts │ │ │ │ │ │ └── bluesky-message-submission.ts │ │ │ │ │ ├── cara/ │ │ │ │ │ │ ├── cara.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── cara-account-data.ts │ │ │ │ │ │ ├── cara-file-submission.ts │ │ │ │ │ │ └── cara-message-submission.ts │ │ │ │ │ ├── custom/ │ │ │ │ │ │ ├── custom.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── custom-file-submission.ts │ │ │ │ │ │ └── custom-message-submission.ts │ │ │ │ │ ├── default/ │ │ │ │ │ │ └── default.website.ts │ │ │ │ │ ├── derpibooru/ │ │ │ │ │ │ ├── derpibooru.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ └── derpibooru-file-submission.ts │ │ │ │ │ ├── deviant-art/ │ │ │ │ │ │ ├── deviant-art-description-converter.ts │ │ │ │ │ │ ├── deviant-art.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── deviant-art-account-data.ts │ │ │ │ │ │ ├── deviant-art-file-submission.ts │ │ │ │ │ │ └── deviant-art-message-submission.ts │ │ │ │ │ ├── discord/ │ │ │ │ │ │ ├── discord.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── discord-file-submission.ts │ │ │ │ │ │ └── discord-message-submission.ts │ │ │ │ │ ├── e621/ │ │ │ │ │ │ ├── e621.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ └── e621-file-submission.ts │ │ │ │ │ ├── firefish/ │ │ │ │ │ │ └── firefish.website.ts │ │ │ │ │ ├── friendica/ │ │ │ │ │ │ └── friendica.website.ts │ │ │ │ │ ├── fur-affinity/ │ │ │ │ │ │ ├── fur-affinity.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── fur-affinity-account-data.ts │ │ │ │ │ │ ├── fur-affinity-categories.ts │ │ │ │ │ │ ├── fur-affinity-file-submission.ts │ │ │ │ │ │ ├── fur-affinity-message-submission.ts │ │ │ │ │ │ ├── fur-affinity-species-options.ts │ │ │ │ │ │ └── fur-affinity-themes.ts │ │ │ │ │ ├── furbooru/ │ │ │ │ │ │ ├── furbooru.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ └── furbooru-file-submission.ts │ │ │ │ │ ├── gotosocial/ │ │ │ │ │ │ └── gotosocial.website.ts │ │ │ │ │ ├── hentai-foundry/ │ │ │ │ │ │ ├── hentai-foundry.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── hentai-foundry-account-data.ts │ │ │ │ │ │ ├── hentai-foundry-categories.ts │ │ │ │ │ │ ├── hentai-foundry-file-submission.ts │ │ │ │ │ │ └── hentai-foundry-message-submission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── inkbunny/ │ │ │ │ │ │ ├── inkbunny.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ └── inkbunny-file-submission.ts │ │ │ │ │ ├── instagram/ │ │ │ │ │ │ ├── instagram-api-service/ │ │ │ │ │ │ │ └── instagram-api-service.ts │ │ │ │ │ │ ├── instagram-blob-service/ │ │ │ │ │ │ │ └── instagram-blob-service.ts │ │ │ │ │ │ ├── instagram.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ └── instagram-file-submission.ts │ │ │ │ │ ├── itaku/ │ │ │ │ │ │ ├── itaku.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── itaku-account-data.ts │ │ │ │ │ │ ├── itaku-file-submission.ts │ │ │ │ │ │ ├── itaku-message-submission.ts │ │ │ │ │ │ └── itaku-user-info.ts │ │ │ │ │ ├── ko-fi/ │ │ │ │ │ │ ├── ko-fi.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── ko-fi-account-data.ts │ │ │ │ │ │ ├── ko-fi-file-submission.ts │ │ │ │ │ │ └── ko-fi-message-submission.ts │ │ │ │ │ ├── manebooru/ │ │ │ │ │ │ ├── manebooru.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ └── manebooru-file-submission.ts │ │ │ │ │ ├── mastodon/ │ │ │ │ │ │ └── mastodon.website.ts │ │ │ │ │ ├── megalodon/ │ │ │ │ │ │ ├── megalodon-api-service.ts │ │ │ │ │ │ ├── megalodon.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── megalodon-file-submission.ts │ │ │ │ │ │ └── megalodon-message-submission.ts │ │ │ │ │ ├── misskey/ │ │ │ │ │ │ ├── misskey-api-service.ts │ │ │ │ │ │ ├── misskey.website.ts │ │ │ │ │ │ └── models/ │ │ │ │ │ │ ├── misskey-file-submission.ts │ │ │ │ │ │ └── misskey-message-submission.ts │ │ │ │ │ ├── newgrounds/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── newgrounds-account-data.ts │ │ │ │ │ │ │ ├── newgrounds-base-submission.ts │ │ │ │ │ │ │ ├── newgrounds-file-submission.ts │ │ │ │ │ │ │ └── newgrounds-message-submission.ts │ │ │ │ │ │ └── newgrounds.website.ts │ │ │ │ │ ├── patreon/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── patreon-account-data.ts │ │ │ │ │ │ │ ├── patreon-campaign-types.ts │ │ │ │ │ │ │ ├── patreon-collection-types.ts │ │ │ │ │ │ │ ├── patreon-file-submission.ts │ │ │ │ │ │ │ ├── patreon-media-upload-types.ts │ │ │ │ │ │ │ ├── patreon-message-submission.ts │ │ │ │ │ │ │ └── patreon-post-types.ts │ │ │ │ │ │ ├── patreon-description-converter.ts │ │ │ │ │ │ └── patreon.website.ts │ │ │ │ │ ├── philomena/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── philomena-account-data.ts │ │ │ │ │ │ │ └── philomena-file-submission.ts │ │ │ │ │ │ └── philomena.website.ts │ │ │ │ │ ├── picarto/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── picarto-account-data.ts │ │ │ │ │ │ │ ├── picarto-categories.ts │ │ │ │ │ │ │ ├── picarto-file-submission.ts │ │ │ │ │ │ │ └── picarto-software.ts │ │ │ │ │ │ └── picarto.website.ts │ │ │ │ │ ├── piczel/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── piczel-account-data.ts │ │ │ │ │ │ │ └── piczel-file-submission.ts │ │ │ │ │ │ └── piczel.website.ts │ │ │ │ │ ├── pillowfort/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── pillowfort-account-data.ts │ │ │ │ │ │ │ ├── pillowfort-file-submission.ts │ │ │ │ │ │ │ └── pillowfort-message-submission.ts │ │ │ │ │ │ └── pillowfort.website.ts │ │ │ │ │ ├── pixelfed/ │ │ │ │ │ │ └── pixelfed.website.ts │ │ │ │ │ ├── pixiv/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── pixiv-account-data.ts │ │ │ │ │ │ │ └── pixiv-file-submission.ts │ │ │ │ │ │ └── pixiv.website.ts │ │ │ │ │ ├── pleroma/ │ │ │ │ │ │ └── pleroma.website.ts │ │ │ │ │ ├── provider.ts │ │ │ │ │ ├── sofurry/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── sofurry-account-data.ts │ │ │ │ │ │ │ ├── sofurry-categories.ts │ │ │ │ │ │ │ └── sofurry-file-submission.ts │ │ │ │ │ │ └── sofurry.website.ts │ │ │ │ │ ├── subscribe-star/ │ │ │ │ │ │ ├── base-subscribe-star.website.ts │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── subscribe-star-account-data.ts │ │ │ │ │ │ │ ├── subscribe-star-file-submission.ts │ │ │ │ │ │ │ └── subscribe-star-message-submission.ts │ │ │ │ │ │ ├── subscribe-star-adult.website.ts │ │ │ │ │ │ └── subscribe-star.website.ts │ │ │ │ │ ├── telegram/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── telegram-file-submission.ts │ │ │ │ │ │ │ └── telegram-message-submission.ts │ │ │ │ │ │ └── telegram.website.ts │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── test-file-submission.ts │ │ │ │ │ │ │ └── test-message-submission.ts │ │ │ │ │ │ └── test.website.ts │ │ │ │ │ ├── toyhouse/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── toyhouse-account-data.ts │ │ │ │ │ │ │ └── toyhouse-file-submission.ts │ │ │ │ │ │ └── toyhouse.website.ts │ │ │ │ │ ├── tumblr/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── tumblr-account-data.ts │ │ │ │ │ │ │ ├── tumblr-file-submission.ts │ │ │ │ │ │ │ └── tumblr-message-submission.ts │ │ │ │ │ │ └── tumblr.website.ts │ │ │ │ │ ├── twitter/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── twitter-file-submission.ts │ │ │ │ │ │ │ └── twitter-message-submission.ts │ │ │ │ │ │ ├── twitter-api-service/ │ │ │ │ │ │ │ └── twitter-api-service.ts │ │ │ │ │ │ └── twitter.website.ts │ │ │ │ │ └── weasyl/ │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── weasyl-account-data.ts │ │ │ │ │ │ ├── weasyl-categories.ts │ │ │ │ │ │ ├── weasyl-file-submission.ts │ │ │ │ │ │ └── weasyl-message-submission.ts │ │ │ │ │ └── weasyl.website.ts │ │ │ │ ├── models/ │ │ │ │ │ ├── base-website-options.spec.ts │ │ │ │ │ ├── base-website-options.ts │ │ │ │ │ ├── data-property-accessibility.ts │ │ │ │ │ ├── default-website-options.ts │ │ │ │ │ └── website-modifiers/ │ │ │ │ │ ├── file-website.ts │ │ │ │ │ ├── message-website.ts │ │ │ │ │ ├── oauth-website.ts │ │ │ │ │ ├── with-custom-description-parser.ts │ │ │ │ │ ├── with-dynamic-file-size-limits.ts │ │ │ │ │ └── with-runtime-description-parser.ts │ │ │ │ ├── website-data-manager.spec.ts │ │ │ │ ├── website-data-manager.ts │ │ │ │ ├── website-registry.service.spec.ts │ │ │ │ ├── website-registry.service.ts │ │ │ │ ├── website.events.ts │ │ │ │ ├── website.spec.ts │ │ │ │ ├── website.ts │ │ │ │ ├── websites.controller.ts │ │ │ │ └── websites.module.ts │ │ │ ├── assets/ │ │ │ │ ├── .gitkeep │ │ │ │ └── sharp-worker.js │ │ │ ├── environments/ │ │ │ │ ├── environment.prod.ts │ │ │ │ └── environment.ts │ │ │ ├── main.ts │ │ │ └── test-files/ │ │ │ └── README.md │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ └── tsconfig.spec.json │ ├── postybirb/ │ │ ├── .eslintrc.json │ │ ├── jest.config.ts │ │ ├── project.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── api/ │ │ │ │ │ └── preload.ts │ │ │ │ ├── app.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── events/ │ │ │ │ │ └── electron.events.ts │ │ │ │ └── loader/ │ │ │ │ ├── css/ │ │ │ │ │ └── style.css │ │ │ │ ├── fonts/ │ │ │ │ │ └── Mylodon-Light.otf │ │ │ │ ├── loader.html │ │ │ │ └── loader.js │ │ │ ├── environments/ │ │ │ │ ├── environment.base.ts │ │ │ │ ├── environment.prod.ts │ │ │ │ └── environment.ts │ │ │ ├── main.ts │ │ │ └── migrations/ │ │ │ ├── 0000_tough_ken_ellis.sql │ │ │ ├── 0001_noisy_kate_bishop.sql │ │ │ ├── 0002_pretty_sunfire.sql │ │ │ ├── 0003_glamorous_power_pack.sql │ │ │ ├── 0004_fuzzy_rafael_vega.sql │ │ │ ├── 0005_exotic_nebula.sql │ │ │ ├── 0006_cooing_songbird.sql │ │ │ └── meta/ │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ ├── 0006_snapshot.json │ │ │ └── _journal.json │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ └── tsconfig.spec.json │ ├── postybirb-cloud-server/ │ │ ├── .gitignore │ │ ├── host.json │ │ ├── local.settings.json │ │ ├── package.json │ │ ├── src/ │ │ │ └── functions/ │ │ │ └── upload.ts │ │ └── tsconfig.json │ └── postybirb-ui/ │ ├── .eslintrc.json │ ├── index.html │ ├── jest.config.ts │ ├── postcss.config.js │ ├── project.json │ ├── src/ │ │ ├── README.md │ │ ├── api/ │ │ │ ├── account.api.ts │ │ │ ├── base.api.ts │ │ │ ├── custom-shortcut.api.ts │ │ │ ├── directory-watchers.api.ts │ │ │ ├── file-submission.api.ts │ │ │ ├── form-generator.api.ts │ │ │ ├── legacy-database-importer.api.ts │ │ │ ├── notification.api.ts │ │ │ ├── post-manager.api.ts │ │ │ ├── post-queue.api.ts │ │ │ ├── post.api.ts │ │ │ ├── remote.api.ts │ │ │ ├── settings.api.ts │ │ │ ├── submission.api.ts │ │ │ ├── tag-converters.api.ts │ │ │ ├── tag-groups.api.ts │ │ │ ├── update.api.ts │ │ │ ├── user-converters.api.ts │ │ │ ├── user-specified-website-options.api.ts │ │ │ ├── website-options.api.ts │ │ │ └── websites.api.ts │ │ ├── app-insights-ui.ts │ │ ├── blocknote-locales.d.ts │ │ ├── components/ │ │ │ ├── confirm-action-modal/ │ │ │ │ ├── confirm-action-modal.tsx │ │ │ │ └── index.ts │ │ │ ├── dialogs/ │ │ │ │ └── settings-dialog/ │ │ │ │ ├── sections/ │ │ │ │ │ ├── app-settings-section.tsx │ │ │ │ │ ├── appearance-settings-section.tsx │ │ │ │ │ ├── data-settings-section.tsx │ │ │ │ │ ├── description-settings-section.tsx │ │ │ │ │ ├── import-settings-section.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notifications-settings-section.tsx │ │ │ │ │ ├── remote-settings-section.tsx │ │ │ │ │ ├── spellchecker-settings-section.tsx │ │ │ │ │ └── tags-settings-section.tsx │ │ │ │ ├── settings-dialog.module.css │ │ │ │ └── settings-dialog.tsx │ │ │ ├── disclaimer/ │ │ │ │ └── disclaimer.tsx │ │ │ ├── drawers/ │ │ │ │ ├── converter-drawer/ │ │ │ │ │ ├── converter-drawer.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-shortcuts-drawer/ │ │ │ │ │ ├── custom-shortcuts-drawer.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── drawers.tsx │ │ │ │ ├── file-watcher-drawer/ │ │ │ │ │ ├── file-watcher-drawer.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── notifications-drawer/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── notifications-drawer.tsx │ │ │ │ ├── schedule-drawer/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schedule-calendar.tsx │ │ │ │ │ ├── schedule-drawer.css │ │ │ │ │ ├── schedule-drawer.tsx │ │ │ │ │ └── submission-list.tsx │ │ │ │ ├── section-drawer.tsx │ │ │ │ ├── tag-converter-drawer/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── tag-converter-drawer.tsx │ │ │ │ ├── tag-group-drawer/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── tag-group-drawer.tsx │ │ │ │ └── user-converter-drawer/ │ │ │ │ ├── index.ts │ │ │ │ └── user-converter-drawer.tsx │ │ │ ├── empty-state/ │ │ │ │ ├── empty-state.tsx │ │ │ │ └── index.ts │ │ │ ├── error-boundary/ │ │ │ │ ├── error-boundary.tsx │ │ │ │ ├── index.ts │ │ │ │ └── specialized-error-boundaries.tsx │ │ │ ├── hold-to-confirm/ │ │ │ │ ├── hold-to-confirm.tsx │ │ │ │ └── index.ts │ │ │ ├── language-picker/ │ │ │ │ ├── index.ts │ │ │ │ ├── language-picker.css │ │ │ │ └── language-picker.tsx │ │ │ ├── layout/ │ │ │ │ ├── content-area.tsx │ │ │ │ ├── content-navbar.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── primary-content.tsx │ │ │ │ ├── section-panel.tsx │ │ │ │ └── side-nav.tsx │ │ │ ├── onboarding-tour/ │ │ │ │ ├── index.ts │ │ │ │ ├── mantine-tooltip.tsx │ │ │ │ ├── tour-provider.tsx │ │ │ │ └── tours/ │ │ │ │ ├── accounts-tour.tsx │ │ │ │ ├── custom-shortcuts-tour.tsx │ │ │ │ ├── file-watchers-tour.tsx │ │ │ │ ├── home-tour.tsx │ │ │ │ ├── layout-tour.tsx │ │ │ │ ├── notifications-tour.tsx │ │ │ │ ├── schedule-tour.tsx │ │ │ │ ├── submission-edit-tour.tsx │ │ │ │ ├── submissions-tour.tsx │ │ │ │ ├── tag-converters-tour.tsx │ │ │ │ ├── tag-groups-tour.tsx │ │ │ │ ├── templates-tour.tsx │ │ │ │ └── user-converters-tour.tsx │ │ │ ├── sections/ │ │ │ │ ├── accounts-section/ │ │ │ │ │ ├── account-section-header.tsx │ │ │ │ │ ├── accounts-content.tsx │ │ │ │ │ ├── accounts-section.tsx │ │ │ │ │ ├── context/ │ │ │ │ │ │ ├── accounts-context.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── custom-login-placeholder.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── use-account-actions.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── login-webview.tsx │ │ │ │ │ ├── website-account-card.tsx │ │ │ │ │ ├── website-visibility-picker.tsx │ │ │ │ │ └── webview-tag.ts │ │ │ │ ├── file-submissions-section/ │ │ │ │ │ └── hooks/ │ │ │ │ │ └── use-file-submissions.ts │ │ │ │ ├── home-section/ │ │ │ │ │ ├── account-health-panel.tsx │ │ │ │ │ ├── home-content.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── queue-control-card.tsx │ │ │ │ │ ├── recent-activity-panel.tsx │ │ │ │ │ ├── schedule-calendar-panel.tsx │ │ │ │ │ ├── stat-card.tsx │ │ │ │ │ ├── upcoming-posts-panel.tsx │ │ │ │ │ └── validation-issues-panel.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── submissions-section/ │ │ │ │ │ ├── archived-submission-list.tsx │ │ │ │ │ ├── context/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── submissions-context.tsx │ │ │ │ │ ├── file-submission-modal/ │ │ │ │ │ │ ├── file-dropzone.tsx │ │ │ │ │ │ ├── file-list.tsx │ │ │ │ │ │ ├── file-preview.tsx │ │ │ │ │ │ ├── file-submission-modal.css │ │ │ │ │ │ ├── file-submission-modal.tsx │ │ │ │ │ │ ├── file-submission-modal.utils.ts │ │ │ │ │ │ ├── image-editor.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── submission-options.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── use-global-dropzone.ts │ │ │ │ │ │ ├── use-submission-actions.ts │ │ │ │ │ │ ├── use-submission-create.ts │ │ │ │ │ │ ├── use-submission-delete.ts │ │ │ │ │ │ ├── use-submission-handlers.ts │ │ │ │ │ │ ├── use-submission-post.ts │ │ │ │ │ │ ├── use-submission-selection.ts │ │ │ │ │ │ ├── use-submission-update.ts │ │ │ │ │ │ └── use-submissions.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── post-confirm-modal/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── post-confirm-modal.tsx │ │ │ │ │ ├── resume-mode-modal/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── resume-mode-modal.tsx │ │ │ │ │ ├── submission-card/ │ │ │ │ │ │ ├── archived-submission-card.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── sortable-submission-card.tsx │ │ │ │ │ │ ├── submission-actions.tsx │ │ │ │ │ │ ├── submission-badges.tsx │ │ │ │ │ │ ├── submission-card.tsx │ │ │ │ │ │ ├── submission-quick-edit-actions.tsx │ │ │ │ │ │ ├── submission-thumbnail.tsx │ │ │ │ │ │ ├── submission-title.tsx │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── submission-edit-card/ │ │ │ │ │ │ ├── account-selection/ │ │ │ │ │ │ │ ├── account-option-row.tsx │ │ │ │ │ │ │ ├── account-select.tsx │ │ │ │ │ │ │ ├── account-selection-form.tsx │ │ │ │ │ │ │ ├── account-selection.css │ │ │ │ │ │ │ ├── form/ │ │ │ │ │ │ │ │ ├── fields/ │ │ │ │ │ │ │ │ │ ├── boolean-field.tsx │ │ │ │ │ │ │ │ │ ├── datetime-field.tsx │ │ │ │ │ │ │ │ │ ├── description-field.tsx │ │ │ │ │ │ │ │ │ ├── description-preview-panel.tsx │ │ │ │ │ │ │ │ │ ├── field-copy-button.tsx │ │ │ │ │ │ │ │ │ ├── field-label.tsx │ │ │ │ │ │ │ │ │ ├── field.css │ │ │ │ │ │ │ │ │ ├── form-field.type.ts │ │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ │ ├── input-field.tsx │ │ │ │ │ │ │ │ │ ├── radio-field.tsx │ │ │ │ │ │ │ │ │ ├── select-field.tsx │ │ │ │ │ │ │ │ │ ├── select-utils.ts │ │ │ │ │ │ │ │ │ ├── tag-field.tsx │ │ │ │ │ │ │ │ │ └── tree-select.tsx │ │ │ │ │ │ │ │ ├── form-field.tsx │ │ │ │ │ │ │ │ ├── form-fields-context.tsx │ │ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ │ ├── use-default-option.tsx │ │ │ │ │ │ │ │ │ └── use-validations.tsx │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── save-defaults-popover.tsx │ │ │ │ │ │ │ │ ├── section-layout.css │ │ │ │ │ │ │ │ ├── section-layout.tsx │ │ │ │ │ │ │ │ └── validation-alerts.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── selected-accounts-forms.tsx │ │ │ │ │ │ ├── actions/ │ │ │ │ │ │ │ ├── apply-template-action.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── save-to-many-action.tsx │ │ │ │ │ │ │ └── submission-edit-card-actions.tsx │ │ │ │ │ │ ├── body/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── submission-edit-card-body.tsx │ │ │ │ │ │ ├── context/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── submission-edit-card-context.tsx │ │ │ │ │ │ ├── defaults-form/ │ │ │ │ │ │ │ ├── defaults-form.css │ │ │ │ │ │ │ ├── defaults-form.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── file-management/ │ │ │ │ │ │ │ ├── bulk-file-editor.tsx │ │ │ │ │ │ │ ├── file-actions.tsx │ │ │ │ │ │ │ ├── file-alt-text-editor.tsx │ │ │ │ │ │ │ ├── file-management.css │ │ │ │ │ │ │ ├── file-metadata.tsx │ │ │ │ │ │ │ ├── file-preview.tsx │ │ │ │ │ │ │ ├── file-uploader.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── submission-file-card.tsx │ │ │ │ │ │ │ ├── submission-file-manager.tsx │ │ │ │ │ │ │ └── use-submission-accounts.ts │ │ │ │ │ │ ├── header/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── submission-edit-card-header.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── schedule-form/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── schedule-form.tsx │ │ │ │ │ │ ├── submission-edit-card.css │ │ │ │ │ │ └── submission-edit-card.tsx │ │ │ │ │ ├── submission-history/ │ │ │ │ │ │ ├── history-utils.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── post-history-content.tsx │ │ │ │ │ │ └── post-record-card.tsx │ │ │ │ │ ├── submission-history-drawer.tsx │ │ │ │ │ ├── submission-list.tsx │ │ │ │ │ ├── submission-section-header.tsx │ │ │ │ │ ├── submissions-content.tsx │ │ │ │ │ ├── submissions-section.css │ │ │ │ │ ├── submissions-section.tsx │ │ │ │ │ └── types.ts │ │ │ │ └── templates-section/ │ │ │ │ ├── index.ts │ │ │ │ ├── template-card.tsx │ │ │ │ ├── templates-content.tsx │ │ │ │ ├── templates-section.css │ │ │ │ └── templates-section.tsx │ │ │ ├── shared/ │ │ │ │ ├── account-picker/ │ │ │ │ │ ├── account-picker.css │ │ │ │ │ ├── account-picker.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── basic-website-select/ │ │ │ │ │ └── basic-website-select.tsx │ │ │ │ ├── copy-to-clipboard/ │ │ │ │ │ ├── copy-to-clipboard.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── description-editor/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── bubble-toolbar.tsx │ │ │ │ │ │ ├── description-toolbar.tsx │ │ │ │ │ │ ├── html-edit-modal.tsx │ │ │ │ │ │ ├── insert-media-modal.tsx │ │ │ │ │ │ └── suggestion-menu.tsx │ │ │ │ │ ├── custom-blocks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── shortcut.css │ │ │ │ │ │ └── website-only-selector.tsx │ │ │ │ │ ├── description-editor.css │ │ │ │ │ ├── description-editor.tsx │ │ │ │ │ ├── extensions/ │ │ │ │ │ │ ├── content-warning-shortcut.tsx │ │ │ │ │ │ ├── custom-shortcut.tsx │ │ │ │ │ │ ├── default-shortcut.tsx │ │ │ │ │ │ ├── indent.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── resizable-image.tsx │ │ │ │ │ │ ├── tags-shortcut.tsx │ │ │ │ │ │ ├── title-shortcut.tsx │ │ │ │ │ │ └── username-shortcut.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── external-link/ │ │ │ │ │ ├── external-link.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── multi-scheduler-modal/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── multi-scheduler-modal.css │ │ │ │ │ └── multi-scheduler-modal.tsx │ │ │ │ ├── rating-input/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rating-input.tsx │ │ │ │ ├── reorderable-submission-list/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── reorderable-submission-list.css │ │ │ │ │ └── reorderable-submission-list.tsx │ │ │ │ ├── schedule-popover/ │ │ │ │ │ ├── cron-picker.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schedule-popover.tsx │ │ │ │ ├── search-input.tsx │ │ │ │ ├── simple-tag-input/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── simple-tag-input.tsx │ │ │ │ ├── submission-picker/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── submission-picker-modal.tsx │ │ │ │ │ └── submission-picker.tsx │ │ │ │ └── template-picker/ │ │ │ │ ├── index.ts │ │ │ │ ├── template-picker-modal.tsx │ │ │ │ └── template-picker.tsx │ │ │ ├── theme-picker/ │ │ │ │ ├── index.ts │ │ │ │ └── theme-picker.tsx │ │ │ ├── update-button/ │ │ │ │ ├── index.ts │ │ │ │ ├── update-button.tsx │ │ │ │ └── update-modal.tsx │ │ │ ├── website-components/ │ │ │ │ ├── bluesky/ │ │ │ │ │ └── description-preview.tsx │ │ │ │ ├── e621/ │ │ │ │ │ ├── e621-dtext-renderer.tsx │ │ │ │ │ └── e621-tag-search-provider.tsx │ │ │ │ ├── furaffinity/ │ │ │ │ │ ├── furaffinity-bbcode-renderer.tsx │ │ │ │ │ └── furaffinity-bbcode.ts │ │ │ │ ├── index.ts │ │ │ │ ├── telegram/ │ │ │ │ │ └── telegram-format-renderer.tsx │ │ │ │ └── tumblr/ │ │ │ │ └── tumblr-npf-renderer.tsx │ │ │ └── website-login-views/ │ │ │ ├── bluesky/ │ │ │ │ ├── bluesky-login-view.tsx │ │ │ │ └── index.ts │ │ │ ├── custom/ │ │ │ │ ├── custom-login-view.tsx │ │ │ │ └── index.ts │ │ │ ├── discord/ │ │ │ │ ├── discord-login-view.tsx │ │ │ │ └── index.ts │ │ │ ├── e621/ │ │ │ │ ├── e621-login-view.tsx │ │ │ │ └── index.ts │ │ │ ├── helpers.tsx │ │ │ ├── index.ts │ │ │ ├── inkbunny/ │ │ │ │ ├── index.ts │ │ │ │ └── inkbunny-login-view.tsx │ │ │ ├── instagram/ │ │ │ │ ├── index.ts │ │ │ │ ├── instagram-login-view.tsx │ │ │ │ └── instagram-setup-guide.tsx │ │ │ ├── login-view-container.tsx │ │ │ ├── megalodon/ │ │ │ │ ├── index.ts │ │ │ │ └── megalodon-login-view.tsx │ │ │ ├── misskey/ │ │ │ │ ├── index.ts │ │ │ │ └── misskey-login-view.tsx │ │ │ ├── telegram/ │ │ │ │ ├── index.ts │ │ │ │ └── telegram-login-view.tsx │ │ │ ├── twitter/ │ │ │ │ ├── index.ts │ │ │ │ └── twitter-login-view.tsx │ │ │ └── types.ts │ │ ├── config/ │ │ │ ├── keybindings.ts │ │ │ └── nav-items.tsx │ │ ├── environments/ │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── hooks/ │ │ │ ├── index.ts │ │ │ ├── tag-search/ │ │ │ │ ├── index.ts │ │ │ │ ├── tag-search-provider.ts │ │ │ │ ├── tag-search-providers.ts │ │ │ │ └── use-tag-search.ts │ │ │ ├── use-keybindings.ts │ │ │ └── use-locale.ts │ │ ├── i18n/ │ │ │ ├── languages.tsx │ │ │ └── validation-translation.tsx │ │ ├── index.tsx │ │ ├── main.tsx │ │ ├── models/ │ │ │ └── http-error-response.ts │ │ ├── providers/ │ │ │ └── i18n-provider.tsx │ │ ├── shared/ │ │ │ └── platform-utils.ts │ │ ├── stores/ │ │ │ ├── create-entity-store.ts │ │ │ ├── create-typed-store.ts │ │ │ ├── entity/ │ │ │ │ ├── account-store.ts │ │ │ │ ├── custom-shortcut-store.ts │ │ │ │ ├── directory-watcher-store.ts │ │ │ │ ├── notification-store.ts │ │ │ │ ├── settings-store.ts │ │ │ │ ├── submission-store.ts │ │ │ │ ├── tag-converter-store.ts │ │ │ │ ├── tag-group-store.ts │ │ │ │ ├── user-converter-store.ts │ │ │ │ └── website-store.ts │ │ │ ├── index.ts │ │ │ ├── records/ │ │ │ │ ├── account-record.ts │ │ │ │ ├── base-record.ts │ │ │ │ ├── custom-shortcut-record.ts │ │ │ │ ├── directory-watcher-record.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notification-record.ts │ │ │ │ ├── settings-record.ts │ │ │ │ ├── submission-record.ts │ │ │ │ ├── tag-converter-record.ts │ │ │ │ ├── tag-group-record.ts │ │ │ │ ├── user-converter-record.ts │ │ │ │ └── website-record.ts │ │ │ ├── store-init.ts │ │ │ └── ui/ │ │ │ ├── accounts-ui-store.ts │ │ │ ├── appearance-store.ts │ │ │ ├── drawer-store.ts │ │ │ ├── locale-store.ts │ │ │ ├── navigation-store.ts │ │ │ ├── submissions-ui-store.ts │ │ │ ├── templates-ui-store.ts │ │ │ └── tour-store.ts │ │ ├── styles/ │ │ │ └── layout.css │ │ ├── styles.css │ │ ├── theme/ │ │ │ ├── css-variable-resolver.ts │ │ │ ├── theme-styles.css │ │ │ └── theme.ts │ │ ├── transports/ │ │ │ ├── http-client.ts │ │ │ └── websocket.ts │ │ ├── types/ │ │ │ ├── account-filters.ts │ │ │ ├── navigation.ts │ │ │ └── view-state.ts │ │ └── utils/ │ │ ├── class-names.ts │ │ ├── environment.ts │ │ ├── index.ts │ │ ├── notifications.tsx │ │ └── open-url.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── vite.config.ts ├── babel.config.js ├── commitlint.config.js ├── compose.yml ├── docs/ │ ├── DOCKER.md │ ├── POST_QUEUE_FLOWS.md │ ├── app-insights/ │ │ ├── APP_INSIGHTS_QUERIES.md │ │ └── APP_INSIGHTS_SETUP.md │ └── contributing/ │ └── add-a-website/ │ ├── README.md │ └── sections/ │ ├── authenticate-a-user.md │ ├── defining-submission-data.md │ ├── description-parsing.md │ ├── file-website.md │ ├── message-website.md │ └── validation.md ├── drizzle.config.ts ├── electron-builder.yml ├── entrypoint.sh ├── jest.config.ts ├── jest.preset.js ├── jest.reporter.js ├── jest.setup.ts ├── lang/ │ ├── de.po │ ├── en.po │ ├── es.po │ ├── lt.po │ ├── nl.po │ ├── pt_BR.po │ ├── ru.po │ └── ta.po ├── libs/ │ ├── database/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── project.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── database.ts │ │ │ ├── helper-types.ts │ │ │ ├── relations/ │ │ │ │ ├── index.ts │ │ │ │ └── relations.ts │ │ │ └── schemas/ │ │ │ ├── account.schema.ts │ │ │ ├── common.schema.ts │ │ │ ├── custom-shortcut.schema.ts │ │ │ ├── directory-watcher.schema.ts │ │ │ ├── file-buffer.schema.ts │ │ │ ├── index.ts │ │ │ ├── notification.schema.ts │ │ │ ├── post-event.schema.ts │ │ │ ├── post-queue-record.schema.ts │ │ │ ├── post-record.schema.ts │ │ │ ├── settings.schema.ts │ │ │ ├── submission-file.schema.ts │ │ │ ├── submission.schema.ts │ │ │ ├── tag-converter.schema.ts │ │ │ ├── tag-group.schema.ts │ │ │ ├── user-converter.schema.ts │ │ │ ├── user-specified-website-options.schema.ts │ │ │ ├── website-data.schema.ts │ │ │ └── website-options.schema.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ ├── form-builder/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── project.json │ │ ├── src/ │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── decorators/ │ │ │ │ ├── boolean-field.decorator.ts │ │ │ │ ├── date-time-field.decorator.ts │ │ │ │ ├── description-field.decorator.ts │ │ │ │ ├── index.ts │ │ │ │ ├── radio-field.decorator.ts │ │ │ │ ├── rating.decorator.ts │ │ │ │ ├── select-field.decorator.ts │ │ │ │ ├── tag-field.decorator.ts │ │ │ │ ├── text-field.decorator.ts │ │ │ │ └── title-field.decorator.ts │ │ │ ├── form-builder.spec.ts │ │ │ ├── form-builder.ts │ │ │ ├── types/ │ │ │ │ ├── field-aggregate.ts │ │ │ │ ├── field.ts │ │ │ │ ├── form-builder-metadata.ts │ │ │ │ ├── index.ts │ │ │ │ └── primitive-record.ts │ │ │ └── utils/ │ │ │ └── assign-metadata.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ ├── fs/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── project.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── directories.ts │ │ │ ├── fs.spec.ts │ │ │ └── fs.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ ├── http/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── project.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── form-file.ts │ │ │ ├── http.spec.ts │ │ │ ├── http.ts │ │ │ └── proxy.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ ├── logger/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── project.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── app-insights.ts │ │ │ ├── logger.ts │ │ │ ├── serialize-log.ts │ │ │ └── winston-appinsights-transport.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ ├── socket-events/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── project.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ └── socket-events.ts │ │ ├── tsconfig.json │ │ └── tsconfig.lib.json │ ├── translations/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── project.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ └── field-translations.ts │ │ ├── tsconfig.json │ │ └── tsconfig.lib.json │ ├── types/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── project.json │ │ ├── src/ │ │ │ ├── dtos/ │ │ │ │ ├── account/ │ │ │ │ │ ├── account.dto.ts │ │ │ │ │ ├── create-account.dto.ts │ │ │ │ │ └── update-account.dto.ts │ │ │ │ ├── custom-shortcut/ │ │ │ │ │ ├── create-custom-shortcut.dto.ts │ │ │ │ │ ├── custom-shortcut.dto.ts │ │ │ │ │ └── update-custom-shortcut.dto.ts │ │ │ │ ├── database/ │ │ │ │ │ └── entity.dto.ts │ │ │ │ ├── directory-watcher/ │ │ │ │ │ ├── create-directory-watcher.dto.ts │ │ │ │ │ ├── directory-watcher.dto.ts │ │ │ │ │ └── update-directory-watcher.dto.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notification/ │ │ │ │ │ ├── create-notification.dto.ts │ │ │ │ │ └── update-notification.dto.ts │ │ │ │ ├── post/ │ │ │ │ │ ├── post-event.dto.ts │ │ │ │ │ ├── post-queue-action.dto.ts │ │ │ │ │ ├── post-queue-record.dto.ts │ │ │ │ │ ├── post-record.dto.ts │ │ │ │ │ └── queue-post-record-request.dto.ts │ │ │ │ ├── settings/ │ │ │ │ │ ├── settings.dto.ts │ │ │ │ │ └── update-settings.dto.ts │ │ │ │ ├── submission/ │ │ │ │ │ ├── apply-multi-submission.dto.ts │ │ │ │ │ ├── create-submission.dto.ts │ │ │ │ │ ├── file-buffer.dto.ts │ │ │ │ │ ├── reorder-submission-files.dto.ts │ │ │ │ │ ├── submission-file.dto.ts │ │ │ │ │ ├── submission.dto.ts │ │ │ │ │ ├── update-alt-file.dto.ts │ │ │ │ │ ├── update-submission-template-name.dto.ts │ │ │ │ │ └── update-submission.dto.ts │ │ │ │ ├── tag/ │ │ │ │ │ ├── create-tag-converter.dto.ts │ │ │ │ │ ├── create-tag-group.dto.ts │ │ │ │ │ ├── tag-converter.dto.ts │ │ │ │ │ ├── tag-group.dto.ts │ │ │ │ │ ├── update-tag-converter.dto.ts │ │ │ │ │ └── update-tag-group.dto.ts │ │ │ │ ├── user/ │ │ │ │ │ ├── create-user-converter.dto.ts │ │ │ │ │ ├── update-user-converter.dto.ts │ │ │ │ │ └── user-converter.dto.ts │ │ │ │ ├── website/ │ │ │ │ │ ├── custom-website-route.dto.ts │ │ │ │ │ ├── form-generation-request.dto.ts │ │ │ │ │ ├── oauth-website-request.dto.ts │ │ │ │ │ ├── set-website-data-request.dto.ts │ │ │ │ │ ├── website-data.dto.ts │ │ │ │ │ └── website-info.dto.ts │ │ │ │ └── website-options/ │ │ │ │ ├── create-user-specified-website-options.dto.ts │ │ │ │ ├── create-website-options.dto.ts │ │ │ │ ├── preview-description.dto.ts │ │ │ │ ├── update-submission-website-options.dto.ts │ │ │ │ ├── update-user-specified-website-options.dto.ts │ │ │ │ ├── update-website-options.dto.ts │ │ │ │ ├── user-specified-website-options.dto.ts │ │ │ │ ├── validate-website-options.dto.ts │ │ │ │ └── website-options.dto.ts │ │ │ ├── enums/ │ │ │ │ ├── description-types.enum.ts │ │ │ │ ├── directory-watcher-import-action.enum.ts │ │ │ │ ├── file-type.enum.ts │ │ │ │ ├── index.ts │ │ │ │ ├── post-event-type.enum.ts │ │ │ │ ├── post-record-resume-mode.enum.ts │ │ │ │ ├── post-record-state.enum.ts │ │ │ │ ├── schedule-type.enum.ts │ │ │ │ ├── submission-rating.enum.ts │ │ │ │ └── submission-type.enum.ts │ │ │ ├── index.ts │ │ │ ├── models/ │ │ │ │ ├── account/ │ │ │ │ │ └── account.interface.ts │ │ │ │ ├── common/ │ │ │ │ │ └── dynamic-object.ts │ │ │ │ ├── custom-shortcut/ │ │ │ │ │ └── custom-shortcut.interface.ts │ │ │ │ ├── database/ │ │ │ │ │ ├── entity-primitive.type.ts │ │ │ │ │ └── entity.interface.ts │ │ │ │ ├── directory-watcher/ │ │ │ │ │ └── directory-watcher.interface.ts │ │ │ │ ├── file/ │ │ │ │ │ ├── file-buffer.interface.ts │ │ │ │ │ └── file-dimensions.interface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notification/ │ │ │ │ │ └── notification.interface.ts │ │ │ │ ├── post/ │ │ │ │ │ ├── post-event.interface.ts │ │ │ │ │ ├── post-queue-record.interface.ts │ │ │ │ │ ├── post-record.interface.ts │ │ │ │ │ └── post-response.type.ts │ │ │ │ ├── remote/ │ │ │ │ │ └── update-cookies-remote.type.ts │ │ │ │ ├── settings/ │ │ │ │ │ ├── settings-options.interface.ts │ │ │ │ │ ├── settings.constants.ts │ │ │ │ │ └── settings.interface.ts │ │ │ │ ├── submission/ │ │ │ │ │ ├── default-submission-file-props.ts │ │ │ │ │ ├── description-value.type.ts │ │ │ │ │ ├── file-submission/ │ │ │ │ │ │ ├── file-submission-metadata.type.ts │ │ │ │ │ │ ├── file-submission.ts │ │ │ │ │ │ └── modified-file-dimension.type.ts │ │ │ │ │ ├── message-submission/ │ │ │ │ │ │ └── message-submission.type.ts │ │ │ │ │ ├── npf-description.type.ts │ │ │ │ │ ├── submission-file-props.interface.ts │ │ │ │ │ ├── submission-file.interface.ts │ │ │ │ │ ├── submission-metadata.interface.ts │ │ │ │ │ ├── submission-metadata.type.ts │ │ │ │ │ ├── submission-schedule-info.interface.ts │ │ │ │ │ ├── submission.interface.ts │ │ │ │ │ ├── validation-result.type.ts │ │ │ │ │ └── website-form-fields.interface.ts │ │ │ │ ├── tag/ │ │ │ │ │ ├── default-tag-value.ts │ │ │ │ │ ├── tag-converter.interface.ts │ │ │ │ │ ├── tag-group.interface.ts │ │ │ │ │ ├── tag-value.type.ts │ │ │ │ │ └── tag.type.ts │ │ │ │ ├── update/ │ │ │ │ │ └── update.type.ts │ │ │ │ ├── user/ │ │ │ │ │ └── user-converter.interface.ts │ │ │ │ ├── website/ │ │ │ │ │ ├── file-website-form-fields.interface.ts │ │ │ │ │ ├── folder.type.ts │ │ │ │ │ ├── image-resize-props.ts │ │ │ │ │ ├── login-request-data.type.ts │ │ │ │ │ ├── login-response.interface.ts │ │ │ │ │ ├── login-state.class.ts │ │ │ │ │ ├── login-state.interface.ts │ │ │ │ │ ├── post-data.type.ts │ │ │ │ │ ├── website-data.interface.ts │ │ │ │ │ ├── website-info.interface.ts │ │ │ │ │ ├── website-login-type.ts │ │ │ │ │ └── website.type.ts │ │ │ │ └── website-options/ │ │ │ │ ├── user-specified-website-options.interface.ts │ │ │ │ └── website-options.interface.ts │ │ │ ├── website-modifiers/ │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-routes.ts │ │ │ │ ├── username-shortcut.ts │ │ │ │ ├── website-file-options.ts │ │ │ │ └── website-metadata.ts │ │ │ └── website-public/ │ │ │ ├── README.md │ │ │ ├── bluesky-account-data.ts │ │ │ ├── custom-account-data.ts │ │ │ ├── discord-account-data.ts │ │ │ ├── e621-account-data.ts │ │ │ ├── index.ts │ │ │ ├── inkbunny-account-data.ts │ │ │ ├── instagram-account-data.ts │ │ │ ├── megalodon-account-data.ts │ │ │ ├── misskey-account-data.ts │ │ │ ├── telegram-account-data.ts │ │ │ └── twitter-account-data.ts │ │ ├── tsconfig.json │ │ └── tsconfig.lib.json │ └── utils/ │ ├── electron/ │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── project.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── browser-window-utils.ts │ │ │ ├── postybirb-env-config.ts │ │ │ ├── remote-utils.ts │ │ │ ├── startup-options-electron.ts │ │ │ ├── utils-electron.spec.ts │ │ │ ├── utils-electron.ts │ │ │ └── utils-test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ └── file-type/ │ ├── .eslintrc.json │ ├── README.md │ ├── project.json │ ├── src/ │ │ ├── index.ts │ │ └── lib/ │ │ ├── calculate-image-resize.ts │ │ ├── get-file-type.ts │ │ ├── is-audio.ts │ │ ├── is-image.ts │ │ ├── is-text.ts │ │ ├── is-video.ts │ │ └── mime-helper.ts │ ├── tsconfig.json │ └── tsconfig.lib.json ├── lingui.config.ts ├── nx.json ├── package.json ├── packaging-resources/ │ ├── entitlements.mac.plist │ ├── icons/ │ │ └── icon.icns │ └── installer.nsh ├── scripts/ │ ├── add-website/ │ │ ├── create-file-from-template.js │ │ ├── create-website.js │ │ ├── parse-add-website-input.js │ │ └── templates/ │ │ ├── account-data.hbs │ │ ├── file-submission.hbs │ │ ├── message-submission.hbs │ │ └── website.hbs │ ├── add-website.js │ ├── inject-app-insights.js │ ├── package.json │ ├── tsconfig.json │ └── windows-signing/ │ ├── hasher.cjs │ └── pull-and-sign.ps1 └── tsconfig.base.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # Editor configuration, see http://editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true [*.md] max_line_length = off trim_trailing_whitespace = false ================================================ FILE: .eslintignore ================================================ # Add files here to ignore them in eslint node_modules *.js *.jsx *.spec.ts *.spec.tsx ================================================ FILE: .eslintrc.js ================================================ // @ts-check /** @type {import('eslint').ESLint.ConfigData} */ const config = { root: true, ignorePatterns: ['**/*'], extends: [ 'airbnb', 'airbnb-typescript', 'plugin:jest/recommended', 'plugin:@nrwl/nx/typescript', 'eslint-config-prettier', ], plugins: ['@nrwl/nx', 'jest'], parserOptions: { project: './tsconfig.base.json' }, overrides: [ { files: ['*.tsx'], plugins: ['testing-library'], extends: ['plugin:testing-library/react', 'airbnb/hooks'], }, ], rules: { '@nrwl/nx/enforce-module-boundaries': [ 'error', { enforceBuildableLibDependency: true, // We allow it because we need to use import from // postybirb-ui to make jump-to definition in the // FieldType.label allow: [ '@postybirb/form-builder', '@postybirb/translations', '@postybirb/types', ], depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }], }, ], '@typescript-eslint/lines-between-class-members': 'off', 'no-plusplus': 'off', 'no-nested-ternary': 'off', 'no-continue': 'off', 'no-await-in-loop': 'off', 'no-restricted-syntax': 'off', 'class-methods-use-this': 'off', 'import/export': 'off', 'import/no-cycle': 'off', 'import/prefer-default-export': 'off', 'react/jsx-props-no-spreading': 'off', 'react/react-in-jsx-scope': 'off', 'react/no-unescaped-entities': 'off', 'react/sort-comp': 'off', 'react/prop-types': 'off', '@typescript-eslint/ban-types': 'warn', '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-use-before-define': 'off', 'import/extension': 'off', 'import/no-extraneous-dependencies': [ 'warn', { // These dependencies will be considered // as bundled and no warning will be shown bundledDependencies: ['electron'], }, ], '@typescript-eslint/naming-convention': [ 'error', { selector: 'variable', format: ['camelCase', 'PascalCase', 'UPPER_CASE'], // Allow const { _ } = useLingui() leadingUnderscore: 'allow', }, { selector: 'function', format: ['camelCase', 'PascalCase'], }, { selector: 'typeLike', format: ['PascalCase'], }, ], }, }; module.exports = config; ================================================ FILE: .github/workflows/build.yml ================================================ name: Build/Release on: workflow_dispatch: jobs: build: name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - name: macOS (x64) os: macos-latest platform: mac arch: x64 - name: macOS (arm64) os: macos-latest platform: mac arch: arm64 - name: Linux (x64) os: ubuntu-latest platform: linux arch: x64 - name: Linux (arm64) os: ubuntu-24.04-arm platform: linux arch: arm64 - name: Windows (x64) os: windows-latest platform: win arch: x64 steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "22" - name: Enable corepack to use Yarn v4 run: | corepack enable corepack prepare --activate - name: Cache Electron binaries uses: actions/cache@v4 with: path: | ~/.cache/electron ~/.cache/electron-builder node_modules/.cache/electron key: ${{ runner.os }}-${{ matrix.arch }}-electron-cache-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-${{ matrix.arch }}-electron-cache- - name: Install dependencies run: yarn install --immutable env: CYPRESS_INSTALL_BINARY: 0 ELECTRON_CACHE: ~/.cache/electron ELECTRON_BUILDER_CACHE: ~/.cache/electron-builder # This is needed to prevent ids showing instead of non translated i18n texts in production builds - name: Extract messages run: yarn lingui:extract - name: Patch electron-builder.yml for fork repositories if: github.event.repository.fork uses: actions/github-script@v8 with: script: | const fs = require('fs'); const yaml = require('js-yaml'); const configPath = './electron-builder.yml'; const configContent = fs.readFileSync(configPath, 'utf8'); let config = yaml.load(configContent); config.publish = { provider: 'github', owner: context.repo.owner, repo: context.repo.repo }; if (config.linux && config.linux.target) { config.linux.target = [{ target:"AppImage" }] } // Remove snap configuration entirely for forks delete config.snap; // Remove deb and rpm configurations for forks delete config.deb; delete config.rpm; // Write the modified config back fs.writeFileSync(configPath, yaml.dump(config)); console.log('✅ Patched electron-builder.yml for fork repository'); console.log(`📦 Publish target: ${context.repo.owner}/${context.repo.repo}`); console.log(yaml.dump(config)) - name: Install snapcraft if: matrix.platform == 'linux' && !github.event.repository.fork run: | sudo apt-get update sudo apt-get install -y snapd sudo snap install snapcraft --classic - name: Install Ruby + fpm (system) for Linux packaging if: matrix.platform == 'linux' && !github.event.repository.fork run: | sudo apt-get update sudo apt-get install -y ruby ruby-dev build-essential rpm sudo gem install --no-document fpm # Ensure gem bindir is on PATH for subsequent steps echo "$(ruby -e 'print Gem.bindir')" >> "$GITHUB_PATH" echo "Ruby:"; ruby -v echo "fpm:"; fpm --version - name: Inject Application Insights connection string env: APP_INSIGHTS_KEY: ${{ secrets.APP_INSIGHTS_KEY }} run: corepack yarn inject:app-insights - name: Build application run: corepack yarn build:prod env: NODE_ENV: production - name: Build Electron app (fork) if: github.event.repository.fork run: corepack yarn electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish=always env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build Electron app if: (!github.event.repository.fork) run: corepack yarn electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish=always env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_IDENTITY_AUTO_DISCOVERY: true SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} SNAPCRAFT_BUILD_ENVIRONMENT: host # ✅ Use system fpm for Linux only USE_SYSTEM_FPM: ${{ matrix.platform == 'linux' && 'true' || '' }} - name: Extract version from package.json for docker tag uses: actions/github-script@v8 id: packageVersion with: script: | const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")) return pkg.version - name: Build and publish a Docker image for '${{github.repository}}' uses: macbre/push-to-ghcr@master if: matrix.platform == 'linux' && matrix.arch == 'x64' with: image_name: "${{github.repository}}" extra_args: --tag ghcr.io/${{github.repository}}:${{steps.packageVersion.outputs.result}} github_token: ${{ secrets.GITHUB_TOKEN }} dockerfile: "./Dockerfile" ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: workflow_dispatch: pull_request: push: branches: - main # Needed for nx-set-shas when run on the main branch permissions: actions: read contents: read jobs: ci: name: ${{ matrix.ci.name }} runs-on: ubuntu-latest if: github.head_ref != 'weblate-postybirb-postybirb' strategy: matrix: ci: - name: Lint command: "yarn nx affected -t lint --cache --cache-strategy content --cache-location .eslint" - name: TypeCheck command: "yarn nx affected -t typecheck" # xvfb-run is needed because electron still tries to get $DISPLAY env even if it does not launches the window # so to run tests on electron runner we need to add this command # grep filters out useless spam in console about not able to access display. # pipefail needed because otherwise grep returns exit code 0 and failed tests don't display in ci as failed - name: Test command: "set -o pipefail && xvfb-run --auto-servernum --server-args=\"-screen 0 1280x960x24\" -- yarn nx affected -t test | grep -v -E \"ERROR:viz_main_impl\\.cc\\(183\\)|ERROR:object_proxy\\.cc\\(576\ \\)|ERROR:bus\\.cc\\(408\\)|ERROR:gles2_cmd_decoder_passthrough\\\ .cc\\(1094\\)|ERROR:gl_utils\\.cc\\(431\\)|ERROR:browser_main_loop\ \\.cc\\(276\\)\"" steps: - name: Check out Git repository uses: actions/checkout@v4 with: # We need to fetch all branches and commits so that Nx affected has a base to compare against. fetch-depth: 0 - name: Use eslint, jest and tsc cache uses: actions/cache@v4 with: # out-tsc needed because we use incremental in typecheck which uses files from tsc output path: | .eslint .jest dist/out-tsc key: ${{ runner.os }}-ci # This is needed to be run before node setup, because node setup uses yarn to get cache dir - name: Enable corepack to use Yarn v4 run: corepack enable - name: Install Node.js and Yarn uses: actions/setup-node@v4 with: node-version: "22" cache: yarn # These flags needed to speed up ci - name: Install dependencies run: yarn install --immutable env: CYPRESS_INSTALL_BINARY: 0 # Skip downloading Cypress binary (not needed for CI) # Used to calculate affected - name: Setup SHAs uses: nrwl/nx-set-shas@v4 - name: Run ${{ matrix.ci.name }} run: ${{ matrix.ci.command }} timeout-minutes: 30 ================================================ FILE: .github/workflows/i18n.yml.disabled ================================================ name: i18n CI on: push: branches: - main permissions: actions: read contents: write checks: write pull-requests: write jobs: main: runs-on: ubuntu-latest if: github.head_ref != 'weblate-postybirb-postybirb' steps: - name: Check out Git repository uses: actions/checkout@v4 - name: Install Node.js, NPM and Yarn uses: actions/setup-node@v4 with: cache: yarn # Note: These flags needed to speed up ci - name: Install dependencies run: yarn install --frozen-lockfile --prefer-offline # Extracts i18n strings from code - name: Extract i18n keys run: yarn lingui:extract # It commits extracted i18n string and eslint/prettier fixes - name: Commit changes run: | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" # pull only from main branch so it will not fetch all tags git pull origin main git add lang/* # ignore when nothing to commit yarn exitzero "git commit -m 'ci: extract i18n messages'" - name: Push changes uses: ad-m/github-push-action@master with: github_token: ${{ secrets.GITHUB_TOKEN }} branch: ${{ github.ref }} ================================================ FILE: .gitignore ================================================ # See http://help.github.com/ignore-files/ for more about ignoring files. # yarn .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/sdks !.yarn/versions # compiled output /release /dist /tmp /out-tsc /test /.swc /.jest /.eslint .jest .swc # dependencies /node_modules scripts/node_modules # IDEs and editors /.idea .project .classpath .c9/ *.launch .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json # misc /.sass-cache /connect.lock /coverage /libpeerconnection.log npm-debug.log yarn-error.log testem.log /typings # System Files .DS_Store Thumbs.db # Public UI dependencies .nx/cache .nx/workspace-data ================================================ FILE: .husky/commit-msg ================================================ npx --no-install commitlint --edit "$1" ================================================ FILE: .husky/post-merge ================================================ yarn ================================================ FILE: .prettierignore ================================================ # Add files here to ignore them from prettier formatting /dist/** /coverage/** /.nx/cache/** /.nx/workspace-data/** *.hbs ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "endOfLine": "crlf" } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "nrwl.angular-console", "esbenp.prettier-vscode", "firsttris.vscode-jest-runner", "dbaeumer.vscode-eslint" ] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "Debug Node App", "type": "node", "request": "attach", "port": 9229 }, { "name": "Debug Jest Tests", "type": "node", "request": "launch", "program": "${workspaceFolder}/node_modules/.bin/jest", "args": [ "--runInBand", "--no-coverage", "--no-cache", "${relativeFile}" ], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "disableOptimisticBPs": true, "windows": { "program": "${workspaceFolder}/node_modules/jest/bin/jest" }, "sourceMaps": true, "outFiles": [ "${workspaceFolder}/**/*.js", "!**/node_modules/**" ], "skipFiles": [ "/**", "**/node_modules/**" ], "env": { "NODE_ENV": "test" } }, { "name": "Debug Current Jest Test", "type": "node", "request": "launch", "program": "${workspaceFolder}/node_modules/.bin/jest", "args": [ "--runInBand", "--no-coverage", "--no-cache", "--testNamePattern=${input:testNamePattern}", "${relativeFile}" ], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "disableOptimisticBPs": true, "windows": { "program": "${workspaceFolder}/node_modules/jest/bin/jest" }, "sourceMaps": true, "outFiles": [ "${workspaceFolder}/**/*.js", "!**/node_modules/**" ], "skipFiles": [ "/**", "**/node_modules/**" ], "env": { "NODE_ENV": "test" } } ], "inputs": [ { "id": "testNamePattern", "description": "Test name pattern", "default": ".*", "type": "promptString" } ] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.codeActionsOnSave": { "source.organizeImports": "always" } } ================================================ FILE: .yarn/patches/@handlewithcare-prosemirror-inputrules-npm-0.1.3-897e37b56f.patch ================================================ diff --git a/package.json b/package.json index 20bc41090c166b7b27756b8d8e0fb67525d48f38..a539a8e91499f831bd77b4fa43e98f3970ea57e8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "exports": { ".": { "types": "./dist/index.d.ts", + "require": "./dist/index.js", "import": "./dist/index.js" } }, ================================================ FILE: .yarn/patches/@tiptap-html-npm-3.15.3-a9641901db.patch ================================================ diff --git a/package.json b/package.json index 1d1aa65a70e7f3e898be608610693a9b98152b8c..e27af24e756e7c6b76651d504304f28e7de4e084 100644 --- a/package.json +++ b/package.json @@ -12,28 +12,6 @@ "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, - "exports": { - ".": { - "types": { - "import": "./dist/index.d.ts", - "require": "./dist/index.d.cts" - }, - "import": { - "browser": "./dist/index.js", - "node": "./dist/server/index.js", - "default": "./dist/index.js" - }, - "require": "./dist/index.cjs" - }, - "./server": { - "types": { - "import": "./dist/server/index.d.ts", - "require": "./dist/server/index.d.cts" - }, - "import": "./dist/server/index.js", - "require": "./dist/server/index.cjs" - } - }, "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", ================================================ FILE: .yarn/patches/jest-snapshot-npm-29.7.0-15ef0a4ad6.patch ================================================ diff --git a/build/InlineSnapshots.js b/build/InlineSnapshots.js index 3481ad99885c847156afdef148d3075dcc9c68ca..44c91da106e6111f95c75b37bc41e5cadebcc145 100644 --- a/build/InlineSnapshots.js +++ b/build/InlineSnapshots.js @@ -149,6 +149,7 @@ const saveSnapshotsForFile = (snapshots, sourceFilePath, rootDir, prettier) => { filename: sourceFilePath, plugins, presets, + configFile: path.join(process.cwd(),'./babel.config.js'), root: rootDir }); } catch (error) { ================================================ FILE: .yarn/patches/strong-log-transformer-npm-2.1.0-45addd9278.patch ================================================ diff --git a/lib/logger.js b/lib/logger.js index 69218a870e6022289a73c27cb2d4f166bf1691d9..8d6554bce9dd8892c945ef0740b0e9d6ba56798c 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -29,7 +29,7 @@ var formatters = { function Logger(options) { var defaults = JSON.parse(JSON.stringify(Logger.DEFAULTS)); - options = util._extend(defaults, options || {}); + options = Object.assign(defaults, options || {}); var catcher = deLiner(); var emitter = catcher; var transforms = [ ================================================ FILE: .yarnrc.yml ================================================ logFilters: # NX packages peer dependencies make a lot of warnings that does not affect code in any way - code: YN0086 level: discard # This warning doesn't make sense. This package is not referenced in code at all and wasn't updated in 2 years - code: YN0002 level: discard text: doesn't provide @mantine/utils (p2df71b), requested by @blocknote/mantine. nodeLinker: node-modules supportedArchitectures: cpu: - current - x64 - arm64 ================================================ FILE: Dockerfile ================================================ # Multi stage build to make image smaller FROM node:24-bookworm-slim AS builder # For ca-certificates RUN apt-get update && apt-get install -y curl WORKDIR /source COPY . . # Conditional build - only build if release/linux-unpacked doesn't exist RUN if [ -d "./release/linux-unpacked" ]; then \ echo "Found existing build, copying..."; \ cp -r ./release/linux-unpacked/ /app;\ else \ echo "Building from source..."; rm -rf .nx && \ CYPRESS_INSTALL_BINARY=0 corepack yarn install --inline-builds && \ corepack yarn dist:linux --dir && \ cp -r ./release/linux-unpacked/ /app;\ fi FROM node:24-bookworm-slim COPY --from=builder /app /app # Install dependencies for Electron and headless display RUN apt-get update && apt-get install -y \ libgtk-3-0 \ libnss3 \ libasound2 \ libxss1 \ libgbm-dev \ libxshmfence-dev \ libdrm-dev \ # For ca-certificates and healthcheck curl \ xvfb \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Contains database, submissions, tags etc VOLUME [ "/root/PostyBirb" ] # Contains startup options, remote config, partitions etc VOLUME [ "/root/.config/postybirb" ] ENV DISPLAY=:99 EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=5 \ CMD curl http://127.0.0.1:8080 || [ $? -eq 52 ] && exit 0 || exit 1 COPY ./entrypoint.sh . ENTRYPOINT [ "bash" ] CMD [ "entrypoint.sh" ] ================================================ FILE: LICENSE ================================================ BSD 3-Clause License Copyright (c) 2024, Michael DiCarlo All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ # Postybirb
Static Badge GitHub Downloads (all assets, latest release) Translation status GitHub Actions Workflow Status
## About PostyBirb is an application that helps artists post art and other multimedia to multiple websites more quickly.The overall goal of PostyBirb is to cut down on the time it takes to post submissions to multiple websites. ## V4 Initiative v4 sets out to be more flexible for adding new features and updates not easily supported on v3. It also aims to be more contributor friendly and ease the implementation of websites where possible. ## Looking for v3 (PostyBirb+)? You can find v3 [here](https://github.com/mvdicarlo/postybirb-plus). ## Translation ![Translation status badge](https://hosted.weblate.org/widget/postybirb/postybirb/287x66-black.png) PostyBirb uses [Weblate](https://hosted.weblate.org/projects/postybirb/postybirb/) as transltion service Learn more: [Translation guide](./TRANSLATION.md) ## Project Setup 1. Ensure your NodeJS version is 24.6.0 or higher 2. Clone project using git 3. `corepack enable` Make NodeJS use the yarn version specific to the project (from package.json) 4. `yarn install` Installs dependencies 5. If it fails with `➤ YN0009: │ better-sqlite3@npm:11.8.0 couldn't be built successfully`:
If you're on windows run ``` winget install -e --id Microsoft.VisualStudio.2022.BuildTools --override "--passive --wait --add Microsoft.VisualStudio.Workload.VCTools;includeRecommended" ``` If you're on linux or other OS please create an issue with log from the unsucessfull build. It will have instructions on which packages are required and we will add them there. But generally it should work out of box if you have C++ compiler installed
6. `yarn run setup` Installs hooks/husky 7. `yarn start` Starts app ### Recommended Plugins (VSCode) - Nx Console - Jest Runner - Prettier ## Contributing Please write clean code. Follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) To add new website [see this guide](./contributing/add-a-website) ## Primary Modules (/apps) ### Client-Server The "Back end" of the application. This houses all data models, user settings, posting logic, etc. #### Primary Technologies Used - NestJS - Drizzle (sqlite3) ### Postybirb The Electron part of the application that contains initialization logic and app setup. #### Primary Technologies Used - Electron ### PostyBirb-UI The user interface for the application that talks with Client-Server through web-socket and https. #### Primary Technologies Used - React - Blocknote/TipTap (Text editor) --- This project was generated using [Nx](https://nx.dev). ================================================ FILE: TRANSLATION.md ================================================ # Translation guide ## How to contribute to translation Go to [PostyBirb site](https://hosted.weblate.org/projects/postybirb/postybirb/), create account and suggest translation! ## Add new language To add new language you need to add [language 2-letter code](https://www.loc.gov/standards/iso639-2/php/code_list.php) to these files: - [lingui.config.ts](./lingui.config.ts) - [languages.tsx](./apps/postybirb-ui/src/app/languages.tsx) ================================================ FILE: apps/client-server/.eslintrc.json ================================================ { "extends": ["../../.eslintrc.js"], "ignorePatterns": ["!**/*"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, { "files": ["*.ts", "*.tsx"], "rules": {} }, { "files": ["*.js", "*.jsx"], "rules": {} } ] } ================================================ FILE: apps/client-server/jest.config.ts ================================================ /* eslint-disable */ export default { displayName: 'client-server', preset: '../../jest.preset.js', globals: {}, testEnvironment: 'node', moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/apps/client-server', runner: '@kayahr/jest-electron-runner/main', }; ================================================ FILE: apps/client-server/project.json ================================================ { "name": "client-server", "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/client-server/src", "projectType": "application", "targets": { "build": { "executor": "nx-electron:build", "outputs": ["{options.outputPath}"], "options": { "outputPath": "dist/apps/client-server", "main": "apps/client-server/src/main.ts", "tsConfig": "apps/client-server/tsconfig.app.json", "assets": ["apps/client-server/src/assets"] }, "configurations": { "production": { "optimization": true, "extractLicenses": true, "inspect": false, "fileReplacements": [ { "replace": "apps/client-server/src/environments/environment.ts", "with": "apps/client-server/src/environments/environment.prod.ts" } ] } } }, "serve": { "executor": "@nx/js:node", "options": { "buildTarget": "client-server:build", "inspect": true, "port": 9229 } }, "lint": { "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"] }, "test": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/apps/client-server"], "options": { "jestConfig": "apps/client-server/jest.config.ts" } }, "typecheck": { "executor": "nx:run-commands", "options": { "command": "tsc -b {projectRoot}/tsconfig.json --incremental --pretty" } } }, "tags": [] } ================================================ FILE: apps/client-server/src/app/account/account.controller.ts ================================================ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { AccountId } from '@postybirb/types'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { AccountService } from './account.service'; import { CreateAccountDto } from './dtos/create-account.dto'; import { SetWebsiteDataRequestDto } from './dtos/set-website-data-request.dto'; import { UpdateAccountDto } from './dtos/update-account.dto'; /** * CRUD operations on Account data. * @class AccountController */ @ApiTags('account') @Controller('account') export class AccountController extends PostyBirbController<'AccountSchema'> { constructor(readonly service: AccountService) { super(service); } @Post() @ApiOkResponse({ description: 'Account created.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) create(@Body() createAccountDto: CreateAccountDto) { return this.service .create(createAccountDto) .then((account) => account.toDTO()); } @Post('/clear/:id') @ApiOkResponse({ description: 'Account data cleared.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) async clear(@Param('id') id: AccountId) { await this.service.clearAccountData(id); try { this.service.manuallyExecuteOnLogin(id); } catch { // For some reason throws error that crashes app when deleting account } } @Get('/refresh/:id') @ApiOkResponse({ description: 'Account login check queued.' }) async refresh(@Param('id') id: AccountId) { this.service.manuallyExecuteOnLogin(id); } @Patch(':id') @ApiOkResponse({ description: 'Account updated.', type: Boolean }) @ApiNotFoundResponse({ description: 'Account Id not found.' }) update( @Body() updateAccountDto: UpdateAccountDto, @Param('id') id: AccountId, ) { return this.service .update(id, updateAccountDto) .then((account) => account.toDTO()); } @Post('/account-data') @ApiOkResponse({ description: 'Account data set.' }) setWebsiteData(@Body() oauthRequestDto: SetWebsiteDataRequestDto) { return this.service.setAccountData(oauthRequestDto); } } ================================================ FILE: apps/client-server/src/app/account/account.events.ts ================================================ import { ACCOUNT_UPDATES } from '@postybirb/socket-events'; import { IAccountDto } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type AccountEventTypes = AccountUpdateEvent; class AccountUpdateEvent implements WebsocketEvent { event: string = ACCOUNT_UPDATES; data: IAccountDto[]; } ================================================ FILE: apps/client-server/src/app/account/account.module.ts ================================================ import { Module } from '@nestjs/common'; import { WebsitesModule } from '../websites/websites.module'; import { AccountController } from './account.controller'; import { AccountService } from './account.service'; @Module({ imports: [WebsitesModule], providers: [AccountService], controllers: [AccountController], exports: [AccountService], }) export class AccountModule {} ================================================ FILE: apps/client-server/src/app/account/account.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { NULL_ACCOUNT_ID } from '@postybirb/types'; import { Account } from '../drizzle/models'; import { waitUntil } from '../utils/wait.util'; import { WebsiteImplProvider } from '../websites/implementations/provider'; import { WebsiteRegistryService } from '../websites/website-registry.service'; import { AccountService } from './account.service'; import { CreateAccountDto } from './dtos/create-account.dto'; describe('AccountsService', () => { let service: AccountService; let registryService: WebsiteRegistryService; let module: TestingModule; // Mock objects for deleteUnregisteredAccounts tests let mockRepository: any; let mockWebsiteRegistry: any; let mockLogger: any; const mockRegisteredAccount = { id: 'account-1', name: 'Test Account 1', website: 'registered-website', withWebsiteInstance(websiteInstance) { return this; }, toDTO: () => {}, } as Account; const mockUnregisteredAccount = { id: 'account-2', name: 'Test Account 2', website: 'unregistered-website', withWebsiteInstance(websiteInstance) { return this; }, toDTO: () => {}, } as Account; const mockAnotherUnregisteredAccount = { id: 'account-3', name: 'Test Account 3', website: 'another-unregistered-website', withWebsiteInstance(websiteInstance) { return this; }, toDTO: () => {}, } as Account; beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ providers: [AccountService, WebsiteRegistryService, WebsiteImplProvider], }).compile(); service = module.get(AccountService); registryService = module.get( WebsiteRegistryService, ); await service.onModuleInit(); }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should set and clear account data', async () => { const dto = new CreateAccountDto(); dto.groups = ['test']; dto.name = 'test'; dto.website = 'test'; const record = await service.create(dto); const instance = registryService.findInstance(record); expect(instance).toBeDefined(); await instance.login(); const websiteData = instance.getWebsiteData(); expect(websiteData).toEqual({ test: 'test-mode', }); await service.setAccountData({ id: record.id, data: { test: 'test-mode-2' }, }); expect(instance.getWebsiteData()).toEqual({ test: 'test-mode-2', }); await service.clearAccountData(record.id); expect(instance.getWebsiteData()).toEqual({}); }, 10000); it('should create entities', async () => { const dto = new CreateAccountDto(); dto.groups = ['test']; dto.name = 'test'; dto.website = 'test'; const record = await service.create(dto); expect(registryService.findInstance(record)).toBeDefined(); const groups = await service.findAll(); await waitUntil(() => !record.websiteInstance?.getLoginState().pending, 50); expect(groups).toHaveLength(1); expect(groups[0].name).toEqual(dto.name); expect(groups[0].website).toEqual(dto.website); expect(groups[0].groups).toEqual(dto.groups); const recordDto = record.toDTO(); expect(recordDto).toEqual({ groups: dto.groups, name: dto.name, website: dto.website, id: record.id, createdAt: record.createdAt, updatedAt: record.updatedAt, state: { isLoggedIn: true, pending: false, username: 'TestUser', lastUpdated: expect.any(String), }, data: { test: 'test-mode', }, websiteInfo: { supports: ['MESSAGE', 'FILE'], websiteDisplayName: 'Test', }, }); }, 10000); it('should support crud operations', async () => { const createAccount: CreateAccountDto = new CreateAccountDto(); createAccount.name = 'test'; createAccount.website = 'test'; // Create const account = await service.create(createAccount); expect(account).toBeDefined(); expect(await service.findAll()).toHaveLength(1); expect(await service.findById(account.id)).toBeDefined(); // Update await service.update(account.id, { name: 'Updated', groups: [] }); expect(await (await service.findById(account.id)).name).toEqual('Updated'); // Remove await service.remove(account.id); expect(await service.findAll()).toHaveLength(0); }); describe('deleteUnregisteredAccounts', () => { beforeEach(() => { // Setup mock objects for testing private method mockRepository = { find: jest.fn(), deleteById: jest.fn(), schemaEntity: { id: 'id' }, }; mockWebsiteRegistry = { canCreate: jest.fn(), create: jest.fn(), findInstance: jest.fn(), getAvailableWebsites: () => [], markAsInitialized: jest.fn(), emit: jest.fn(), }; mockLogger = { withMetadata: jest.fn().mockReturnThis(), withError: jest.fn().mockReturnThis(), warn: jest.fn(), error: jest.fn(), }; // Replace service dependencies with mocks (service as any).repository = mockRepository; (service as any).websiteRegistry = mockWebsiteRegistry; (service as any).logger = mockLogger; // Setup default mock behavior mockRepository.find.mockResolvedValue([ mockRegisteredAccount, mockUnregisteredAccount, mockAnotherUnregisteredAccount, ]); mockWebsiteRegistry.canCreate.mockImplementation((website: string) => { return website === 'registered-website'; }); mockRepository.deleteById.mockResolvedValue(undefined); }); it('should delete accounts for unregistered websites', async () => { await (service as any).deleteUnregisteredAccounts(); // Verify that find was called to get all accounts except NULL_ACCOUNT_ID expect(mockRepository.find).toHaveBeenCalledWith({ where: expect.any(Object), // ne(schemaEntity.id, NULL_ACCOUNT_ID) }); // Verify canCreate was called for each account's website expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledWith( 'registered-website', ); expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledWith( 'unregistered-website', ); expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledWith( 'another-unregistered-website', ); expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledTimes(3); // Verify deleteById was called for unregistered accounts only expect(mockRepository.deleteById).toHaveBeenCalledWith(['account-2']); expect(mockRepository.deleteById).toHaveBeenCalledWith(['account-3']); expect(mockRepository.deleteById).toHaveBeenCalledTimes(2); // Verify logging expect(mockLogger.withMetadata).toHaveBeenCalledWith( mockUnregisteredAccount, ); expect(mockLogger.withMetadata).toHaveBeenCalledWith( mockAnotherUnregisteredAccount, ); expect(mockLogger.warn).toHaveBeenCalledWith( 'Deleting unregistered account: account-2 (Test Account 2)', ); expect(mockLogger.warn).toHaveBeenCalledWith( 'Deleting unregistered account: account-3 (Test Account 3)', ); }); it('should not delete accounts for registered websites', async () => { await (service as any).deleteUnregisteredAccounts(); // Verify the registered account was not deleted expect(mockRepository.deleteById).not.toHaveBeenCalledWith(['account-1']); }); it('should handle deletion errors gracefully', async () => { const deleteError = new Error('Database deletion failed'); mockRepository.deleteById .mockResolvedValueOnce(undefined) // First deletion succeeds .mockRejectedValueOnce(deleteError); // Second deletion fails await (service as any).deleteUnregisteredAccounts(); // Verify both deletions were attempted expect(mockRepository.deleteById).toHaveBeenCalledTimes(2); // Verify error was logged for the failed deletion expect(mockLogger.withError).toHaveBeenCalledWith(deleteError); expect(mockLogger.error).toHaveBeenCalledWith( 'Failed to delete unregistered account: account-3', ); }); it('should handle empty accounts list', async () => { mockRepository.find.mockResolvedValue([]); await (service as any).deleteUnregisteredAccounts(); expect(mockWebsiteRegistry.canCreate).not.toHaveBeenCalled(); expect(mockRepository.deleteById).not.toHaveBeenCalled(); expect(mockLogger.warn).not.toHaveBeenCalled(); }); it('should handle case where all accounts are registered', async () => { mockRepository.find.mockResolvedValue([mockRegisteredAccount]); await (service as any).deleteUnregisteredAccounts(); expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledWith( 'registered-website', ); expect(mockRepository.deleteById).not.toHaveBeenCalled(); expect(mockLogger.warn).not.toHaveBeenCalled(); }); it('should exclude NULL_ACCOUNT_ID from deletion consideration', async () => { const nullAccount = { id: NULL_ACCOUNT_ID, name: 'Null Account', website: 'null', } as Account; // Mock the repository.find to only return non-NULL accounts (simulating the database query) // The actual service uses ne(this.repository.schemaEntity.id, NULL_ACCOUNT_ID) to exclude it mockRepository.find.mockResolvedValue([ mockUnregisteredAccount, // Only return the unregistered account, not the null account ]); // Even if null website is not registered, it shouldn't be considered for deletion mockWebsiteRegistry.canCreate.mockImplementation((website: string) => { return website !== 'null' && website !== 'unregistered-website'; }); await (service as any).deleteUnregisteredAccounts(); // Verify the query excludes NULL_ACCOUNT_ID (this is tested by the repository mock) expect(mockRepository.find).toHaveBeenCalledWith({ where: expect.any(Object), }); // Only the unregistered account should be deleted, not the null account expect(mockRepository.deleteById).toHaveBeenCalledWith(['account-2']); expect(mockRepository.deleteById).toHaveBeenCalledTimes(1); }); }); }); ================================================ FILE: apps/client-server/src/app/account/account.service.ts ================================================ import { BadRequestException, Injectable, OnModuleInit, Optional, } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ACCOUNT_UPDATES } from '@postybirb/socket-events'; import { AccountId, IWebsiteMetadata, NULL_ACCOUNT_ID, NullAccount, } from '@postybirb/types'; import { IsTestEnvironment } from '@postybirb/utils/electron'; import { ne } from 'drizzle-orm'; import { Class } from 'type-fest'; import { PostyBirbService } from '../common/service/postybirb-service'; import { Account } from '../drizzle/models'; import { FindOptions } from '../drizzle/postybirb-database/find-options.type'; import { WSGateway } from '../web-socket/web-socket-gateway'; import { UnknownWebsite } from '../websites/website'; import { WebsiteRegistryService } from '../websites/website-registry.service'; import { CreateAccountDto } from './dtos/create-account.dto'; import { SetWebsiteDataRequestDto } from './dtos/set-website-data-request.dto'; import { UpdateAccountDto } from './dtos/update-account.dto'; import { LoginStatePoller } from './login-state-poller'; /** * Service responsible for returning Account data. * Also stores login refresh timers for initiating login checks. */ @Injectable() export class AccountService extends PostyBirbService<'AccountSchema'> implements OnModuleInit { private readonly loginRefreshTimers: Record< string, { timer: NodeJS.Timeout; websites: Class[]; } > = {}; private readonly loginStatePoller: LoginStatePoller; constructor( private readonly websiteRegistry: WebsiteRegistryService, @Optional() webSocket?: WSGateway, ) { super('AccountSchema', webSocket); this.repository.subscribe('AccountSchema', () => this.emit()); this.loginStatePoller = new LoginStatePoller( this.websiteRegistry, () => { this.emit(); this.websiteRegistry.emit(); }, ); } /** * Initializes all website login timers and creates instances for known accounts. * Heavy operations are deferred to avoid blocking application startup. */ async onModuleInit() { // Critical path: only populate null account to ensure database is ready await this.populateNullAccount(); // Defer heavy operations to avoid blocking NestJS initialization setImmediate(async () => { await this.deleteUnregisteredAccounts(); await this.initWebsiteRegistry(); this.websiteRegistry.markAsInitialized(); this.initWebsiteLoginRefreshTimers(); this.emit(); Object.keys(this.loginRefreshTimers).forEach((interval) => this.executeOnLoginForInterval(interval), ); }); } /** * CRON-driven poll for login state changes. * Compares cached login states against live values and emits to UI on change. */ @Cron(CronExpression.EVERY_SECOND) private pollLoginStates() { if (!IsTestEnvironment()) { this.loginStatePoller.checkForChanges(); } } private async deleteUnregisteredAccounts() { const accounts = await this.repository.find({ where: ne(this.repository.schemaEntity.id, NULL_ACCOUNT_ID), }); const unregisteredAccounts = accounts.filter( (account) => !this.websiteRegistry.canCreate(account.website), ); for (const account of unregisteredAccounts) { try { this.logger .withMetadata(account) .warn( `Deleting unregistered account: ${account.id} (${account.name})`, ); await this.repository.deleteById([account.id]); } catch (err) { this.logger .withError(err) .withMetadata(account) .error(`Failed to delete unregistered account: ${account.id}`); } } } /** * Create the Nullable typed account. */ private async populateNullAccount(): Promise { if (!(await this.repository.findById(NULL_ACCOUNT_ID))) { await this.repository.insert(new NullAccount()); } } /** * Loads accounts into website registry. */ private async initWebsiteRegistry(): Promise { const accounts = await this.repository.find({ where: ne(this.repository.schemaEntity.id, NULL_ACCOUNT_ID), }); await Promise.all( accounts.map((account) => this.websiteRegistry.create(account)), ).catch((err) => { this.logger.error(err, 'onModuleInit'); }); } /** * Creates website login check timers. */ private initWebsiteLoginRefreshTimers(): void { const availableWebsites = this.websiteRegistry.getAvailableWebsites(); availableWebsites.forEach((website) => { const interval: number = (website.prototype.decoratedProps.metadata as IWebsiteMetadata) .refreshInterval ?? 60_000 * 60; if (!this.loginRefreshTimers[interval]) { this.loginRefreshTimers[interval] = { websites: [], timer: setInterval(() => { this.executeOnLoginForInterval(interval); }, interval), }; } this.loginRefreshTimers[interval].websites.push(website); }); } public async emit() { const dtos = await this.findAll().then((accounts) => accounts.map((a) => this.injectWebsiteInstance(a)), ); super.emit({ event: ACCOUNT_UPDATES, data: dtos.map((dto) => dto.toDTO()), }); } /** * Runs login on all created website instances within a specific interval. * The mutex inside website.login() ensures only one login runs at a time * per instance; concurrent callers simply wait and get the fresh state. * * @param {string} interval */ private async executeOnLoginForInterval(interval: string | number) { const { websites } = this.loginRefreshTimers[interval]; websites.forEach((website) => { this.websiteRegistry.getInstancesOf(website).forEach((instance) => { // Fire-and-forget — the poller will detect state changes instance.login().catch((e) => { this.logger.withError(e).error(`Login failed for ${instance.id}`); }); }); }); } /** * Logic that needs to be run after an account is created. * * @param {Account} account * @param {UnknownWebsite} website */ private afterCreate(account: Account, website: UnknownWebsite) { // Fire-and-forget — poller picks up the state change website.login().catch((e) => { this.logger.withError(e).error(`Initial login failed for ${website.id}`); }); } /** * Executes a login refresh initiated from an external source. * Waits for the result so the caller knows when it's done. * * @param {AccountId} id */ async manuallyExecuteOnLogin(id: AccountId): Promise { const account = await this.findById(id); if (account) { const instance = this.websiteRegistry.findInstance(account); if (instance) { await instance.login(); // Force an immediate UI update for this instance this.loginStatePoller.checkInstance(instance); } } } /** * Creates an Account. * @param {CreateAccountDto} createDto * @return {*} {Promise} */ async create(createDto: CreateAccountDto): Promise { this.logger .withMetadata(createDto) .info(`Creating Account '${createDto.name}:${createDto.website}`); if (!this.websiteRegistry.canCreate(createDto.website)) { throw new BadRequestException( `Website ${createDto.website} is not supported.`, ); } const account = await this.repository.insert(new Account(createDto)); const instance = await this.websiteRegistry.create(account); this.afterCreate(account, instance); return account.withWebsiteInstance(instance); } public findById(id: AccountId, options?: FindOptions) { return this.repository .findById(id, options) .then((account) => this.injectWebsiteInstance(account)); } public async findAll() { return this.repository .find({ where: ne(this.repository.schemaEntity.id, NULL_ACCOUNT_ID), }) .then((accounts) => accounts.map((account) => this.injectWebsiteInstance(account)), ); } async update(id: AccountId, update: UpdateAccountDto) { this.logger.withMetadata(update).info(`Updating Account '${id}'`); return this.repository .update(id, update) .then((account) => this.injectWebsiteInstance(account)); } async remove(id: AccountId) { const account = await this.findById(id); if (account) { this.websiteRegistry.remove(account); } return super.remove(id); } /** * Clears the data and login state associated with an account. * * @param {string} id */ async clearAccountData(id: AccountId) { this.logger.info(`Clearing Account data for '${id}'`); const account = await this.findById(id); if (account) { const instance = this.websiteRegistry.findInstance(account); await instance.clearLoginStateAndData(); } } /** * Sets the data saved to an account's website. * * @param {SetWebsiteDataRequestDto} setWebsiteDataRequestDto */ async setAccountData(setWebsiteDataRequestDto: SetWebsiteDataRequestDto) { this.logger.info( `Setting Account data for '${setWebsiteDataRequestDto.id}'`, ); const account = await this.repository.findById( setWebsiteDataRequestDto.id, { failOnMissing: true }, ); const instance = this.websiteRegistry.findInstance(account); await instance.setWebsiteData(setWebsiteDataRequestDto.data); } private injectWebsiteInstance(account?: Account): Account | null { if (!account) { return null; } return account.withWebsiteInstance( this.websiteRegistry.findInstance(account), ); } } ================================================ FILE: apps/client-server/src/app/account/dtos/create-account.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ICreateAccountDto } from '@postybirb/types'; import { IsArray, IsString, Length } from 'class-validator'; /** * Account creation request object. */ export class CreateAccountDto implements ICreateAccountDto { @ApiProperty() @IsString() @Length(1, 64) name: string; @ApiProperty() @IsString() @Length(1) website: string; @ApiProperty() @IsArray() groups: string[]; } ================================================ FILE: apps/client-server/src/app/account/dtos/set-website-data-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { AccountId, DynamicObject, ISetWebsiteDataRequestDto, } from '@postybirb/types'; import { IsObject, IsString } from 'class-validator'; export class SetWebsiteDataRequestDto implements ISetWebsiteDataRequestDto { @ApiProperty() @IsString() id: AccountId; @ApiProperty({ type: Object, }) @IsObject() data: DynamicObject; } ================================================ FILE: apps/client-server/src/app/account/dtos/update-account.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IUpdateAccountDto } from '@postybirb/types'; import { IsArray, IsString } from 'class-validator'; /** * Account update request object. */ export class UpdateAccountDto implements IUpdateAccountDto { @ApiProperty() @IsString() name: string; @ApiProperty() @IsArray() groups: string[]; } ================================================ FILE: apps/client-server/src/app/account/login-state-poller.ts ================================================ import { Logger } from '@postybirb/logger'; import { AccountId, ILoginState } from '@postybirb/types'; import { UnknownWebsite } from '../websites/website'; import { WebsiteRegistryService } from '../websites/website-registry.service'; /** * Compares all website instances' login states against a cached snapshot * and triggers a callback when any state has changed. * * Designed to be driven externally (e.g. by a @Cron job) rather than * managing its own polling interval. */ export class LoginStatePoller { private readonly logger = Logger(LoginStatePoller.name); /** * Cached snapshot of the last-known login state per account. * Used for diffing to detect changes. */ private lastKnownStates: Record = {}; constructor( private readonly websiteRegistry: WebsiteRegistryService, private readonly onStateChange: () => void, ) {} /** * Compare current login states against the cached snapshot. * If any account's state has changed, update the cache and fire the callback. */ checkForChanges(): void { try { const instances = this.websiteRegistry.getAll(); let hasChanged = false; const currentStates: Record = {}; for (const instance of instances) { const { accountId } = instance; const state = instance.getLoginState(); currentStates[accountId] = state; const previous = this.lastKnownStates[accountId]; if (!previous || !this.statesEqual(previous, state)) { hasChanged = true; } } // Detect removed accounts for (const accountId of Object.keys(this.lastKnownStates)) { if (!(accountId in currentStates)) { hasChanged = true; } } if (hasChanged) { this.lastKnownStates = currentStates; this.onStateChange(); } } catch (e) { this.logger.withError(e).error('Error during login state poll'); } } /** * Check a single website instance for login state changes. * More efficient than checkForChanges() when you know exactly which instance changed. * * @param {UnknownWebsite} instance - The website instance to check */ checkInstance(instance: UnknownWebsite): void { try { const { accountId } = instance; const state = instance.getLoginState(); const previous = this.lastKnownStates[accountId]; if (!previous || !this.statesEqual(previous, state)) { this.lastKnownStates[accountId] = state; this.onStateChange(); } } catch (e) { this.logger.withError(e).error('Error during single instance login state check'); } } /** * Shallow equality check for two ILoginState objects. */ private statesEqual(a: ILoginState, b: ILoginState): boolean { return ( a.isLoggedIn === b.isLoggedIn && a.pending === b.pending && a.username === b.username && a.lastUpdated === b.lastUpdated ); } } ================================================ FILE: apps/client-server/src/app/app.controller.ts ================================================ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() getData() { return this.appService.getData(); } } ================================================ FILE: apps/client-server/src/app/app.module.ts ================================================ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; import { AccountModule } from './account/account.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { CustomShortcutsModule } from './custom-shortcuts/custom-shortcuts.module'; import { DirectoryWatchersModule } from './directory-watchers/directory-watchers.module'; import { FileConverterModule } from './file-converter/file-converter.module'; import { FileModule } from './file/file.module'; import { FormGeneratorModule } from './form-generator/form-generator.module'; import { ImageProcessingModule } from './image-processing/image-processing.module'; import { LegacyDatabaseImporterModule } from './legacy-database-importer/legacy-database-importer.module'; import { LogsModule } from './logs/logs.module'; import { NotificationsModule } from './notifications/notifications.module'; import { PostParsersModule } from './post-parsers/post-parsers.module'; import { PostModule } from './post/post.module'; import { RemotePasswordMiddleware } from './remote/remote.middleware'; import { RemoteModule } from './remote/remote.module'; import { SettingsModule } from './settings/settings.module'; import { SubmissionModule } from './submission/submission.module'; import { TagConvertersModule } from './tag-converters/tag-converters.module'; import { TagGroupsModule } from './tag-groups/tag-groups.module'; import { UpdateModule } from './update/update.module'; import { UserConvertersModule } from './user-converters/user-converters.module'; import { UserSpecifiedWebsiteOptionsModule } from './user-specified-website-options/user-specified-website-options.module'; import { ValidationModule } from './validation/validation.module'; import { WebSocketModule } from './web-socket/web-socket.module'; import { WebsiteOptionsModule } from './website-options/website-options.module'; import { WebsitesModule } from './websites/websites.module'; @Module({ imports: [ ScheduleModule.forRoot(), ImageProcessingModule, AccountModule, WebSocketModule, WebsitesModule, FileModule, SubmissionModule, SettingsModule, ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', 'postybirb-ui'), exclude: ['/api*'], }), FormGeneratorModule, WebsiteOptionsModule, TagGroupsModule, TagConvertersModule, UserConvertersModule, DirectoryWatchersModule, UserSpecifiedWebsiteOptionsModule, UpdateModule, PostModule, PostParsersModule, ValidationModule, FileConverterModule, NotificationsModule, RemoteModule, CustomShortcutsModule, LegacyDatabaseImporterModule, LogsModule, ], controllers: [AppController], providers: [AppService], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(RemotePasswordMiddleware).forRoutes('*'); } } ================================================ FILE: apps/client-server/src/app/app.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { app } from 'electron'; @Injectable() export class AppService { getData(): Record { return { message: 'pong', version: app.getVersion(), location: app.getPath('userData'), }; } } ================================================ FILE: apps/client-server/src/app/common/controller/postybirb-controller.ts ================================================ import { Delete, Get, Param, Query } from '@nestjs/common'; import { ApiOkResponse } from '@nestjs/swagger'; import { SchemaKey } from '@postybirb/database'; import { EntityId } from '@postybirb/types'; import { PostyBirbService } from '../service/postybirb-service'; /** * Base PostyBirb controller logic that should be good for most rest calls. * * @class PostyBirbController */ export abstract class PostyBirbController { constructor(protected readonly service: PostyBirbService) {} @Get(':id') @ApiOkResponse({ description: 'Record by Id.' }) findOne(@Param('id') id: EntityId) { return this.service .findById(id, { failOnMissing: true }) .then((record) => record.toDTO()); } @Get() @ApiOkResponse({ description: 'A list of all records.' }) findAll() { return this.service .findAll() .then((records) => records.map((record) => record.toDTO())); } @Delete() @ApiOkResponse({ description: 'Records removed.', }) async remove(@Query('ids') ids: EntityId | EntityId[]) { return Promise.all( (Array.isArray(ids) ? ids : [ids]).map((id) => this.service.remove(id)), ).then(() => ({ success: true, })); } } ================================================ FILE: apps/client-server/src/app/common/service/postybirb-service.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { SchemaKey } from '@postybirb/database'; import { Logger } from '@postybirb/logger'; import { EntityId } from '@postybirb/types'; import { SQL } from 'drizzle-orm'; import { FindOptions } from '../../drizzle/postybirb-database/find-options.type'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { WSGateway } from '../../web-socket/web-socket-gateway'; import { WebSocketEvents } from '../../web-socket/web-socket.events'; /** * Base class that implements simple CRUD logic * * @class PostyBirbService */ @Injectable() export abstract class PostyBirbService { protected readonly logger = Logger(this.constructor.name); protected readonly repository: PostyBirbDatabase; constructor( private readonly table: TSchemaKey | PostyBirbDatabase, private readonly webSocket?: WSGateway, ) { if (typeof table === 'string') { this.repository = new PostyBirbDatabase(table); } else { this.repository = table; } } /** * Emits events onto the websocket * * @protected * @param {WebSocketEvents} event */ protected async emit(event: WebSocketEvents) { try { if (this.webSocket) { this.webSocket.emit(event); } } catch (err) { this.logger.error(`Error emitting websocket event: ${event.event}`, err); } } protected get schema() { return this.repository.schemaEntity; } /** * Throws exception if a record matching the query already exists. * * @protected * @param {FilterQuery} where */ protected async throwIfExists(where: SQL) { const exists = await this.repository.select(where); if (exists.length) { this.logger .withMetadata(exists) .error(`A duplicate entity already exists`); throw new BadRequestException(`A duplicate entity already exists`); } } // Repository Wrappers public findById(id: EntityId, options?: FindOptions) { return this.repository.findById(id, options); } public findAll() { return this.repository.findAll(); } public remove(id: EntityId) { this.logger.withMetadata({ id }).info(`Removing entity '${id}'`); return this.repository.deleteById([id]); } // END Repository Wrappers } ================================================ FILE: apps/client-server/src/app/constants.ts ================================================ // Constant variables export const WEBSITE_IMPLEMENTATIONS = 'WEBSITE_IMPLEMENTATIONS'; ================================================ FILE: apps/client-server/src/app/custom-shortcuts/custom-shortcut.events.ts ================================================ import { CUSTOM_SHORTCUT_UPDATES } from '@postybirb/socket-events'; import { ICustomShortcut } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type CustomShortcutEventTypes = CustomShortcutEvent; class CustomShortcutEvent implements WebsocketEvent { event: string = CUSTOM_SHORTCUT_UPDATES; data: ICustomShortcut[]; } ================================================ FILE: apps/client-server/src/app/custom-shortcuts/custom-shortcuts.controller.ts ================================================ import { Body, Controller, Param, Patch, Post } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { CustomShortcutsService } from './custom-shortcuts.service'; import { CreateCustomShortcutDto } from './dtos/create-custom-shortcut.dto'; import { UpdateCustomShortcutDto } from './dtos/update-custom-shortcut.dto'; @ApiTags('custom-shortcut') @Controller('custom-shortcut') export class CustomShortcutsController extends PostyBirbController<'CustomShortcutSchema'> { constructor(readonly service: CustomShortcutsService) { super(service); } @Post() @ApiOkResponse({ description: 'Custom shortcut created' }) async create(@Body() createCustomShortcutDto: CreateCustomShortcutDto) { return this.service .create(createCustomShortcutDto) .then((shortcut) => shortcut.toDTO()); } @Patch(':id') @ApiOkResponse({ description: 'Custom shortcut updated' }) async update( @Body() updateCustomShortcutDto: UpdateCustomShortcutDto, @Param('id') id: string, ) { return this.service .update(id, updateCustomShortcutDto) .then((shortcut) => shortcut.toDTO()); } } ================================================ FILE: apps/client-server/src/app/custom-shortcuts/custom-shortcuts.module.ts ================================================ import { Module } from '@nestjs/common'; import { CustomShortcutsController } from './custom-shortcuts.controller'; import { CustomShortcutsService } from './custom-shortcuts.service'; @Module({ controllers: [CustomShortcutsController], providers: [CustomShortcutsService], exports: [CustomShortcutsService], }) export class CustomShortcutsModule {} ================================================ FILE: apps/client-server/src/app/custom-shortcuts/custom-shortcuts.service.ts ================================================ import { Injectable, Optional } from '@nestjs/common'; import { CUSTOM_SHORTCUT_UPDATES } from '@postybirb/socket-events'; import { EntityId } from '@postybirb/types'; import { eq } from 'drizzle-orm'; import { PostyBirbService } from '../common/service/postybirb-service'; import { CustomShortcut } from '../drizzle/models/custom-shortcut.entity'; import { WSGateway } from '../web-socket/web-socket-gateway'; import { CreateCustomShortcutDto } from './dtos/create-custom-shortcut.dto'; import { UpdateCustomShortcutDto } from './dtos/update-custom-shortcut.dto'; @Injectable() export class CustomShortcutsService extends PostyBirbService<'CustomShortcutSchema'> { constructor(@Optional() webSocket?: WSGateway) { super('CustomShortcutSchema', webSocket); this.repository.subscribe('CustomShortcutSchema', () => this.emit()); } public async emit() { const dtos = await this.findAll(); super.emit({ event: CUSTOM_SHORTCUT_UPDATES, data: dtos.map((dto) => dto.toDTO()), }); } public async create( createCustomShortcutDto: CreateCustomShortcutDto, ): Promise { this.logger .withMetadata(createCustomShortcutDto) .info('Creating custom shortcut'); await this.throwIfExists( eq(this.schema.name, createCustomShortcutDto.name), ); return this.repository.insert(createCustomShortcutDto); } public async update( id: string, updateCustomShortcutDto: UpdateCustomShortcutDto, ): Promise { this.logger .withMetadata(updateCustomShortcutDto) .info('Updating custom shortcut'); const existing = await this.repository.findById(id, { failOnMissing: true, }); return this.repository.update(id, updateCustomShortcutDto); } public async remove(id: EntityId) { const existing = await this.repository.findById(id, { failOnMissing: true, }); return super.remove(id); } } ================================================ FILE: apps/client-server/src/app/custom-shortcuts/dtos/create-custom-shortcut.dto.ts ================================================ import { ICreateCustomShortcutDto } from '@postybirb/types'; import { IsString } from 'class-validator'; export class CreateCustomShortcutDto implements ICreateCustomShortcutDto { @IsString() name: string; } ================================================ FILE: apps/client-server/src/app/custom-shortcuts/dtos/update-custom-shortcut.dto.ts ================================================ import { Description, IUpdateCustomShortcutDto } from '@postybirb/types'; import { IsObject, IsString } from 'class-validator'; export class UpdateCustomShortcutDto implements IUpdateCustomShortcutDto { @IsString() name: string; @IsObject() shortcut: Description; } ================================================ FILE: apps/client-server/src/app/directory-watchers/directory-watcher.events.ts ================================================ import { DIRECTORY_WATCHER_UPDATES } from '@postybirb/socket-events'; import { DirectoryWatcherDto } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type DirectoryWatcherEventTypes = DirectoryWatcherUpdateEvent; class DirectoryWatcherUpdateEvent implements WebsocketEvent { event: string = DIRECTORY_WATCHER_UPDATES; data: DirectoryWatcherDto[]; } ================================================ FILE: apps/client-server/src/app/directory-watchers/directory-watchers.controller.ts ================================================ import { Body, Controller, Param, Patch, Post } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { EntityId } from '@postybirb/types'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { DirectoryWatchersService } from './directory-watchers.service'; import { CheckPathDto } from './dtos/check-path.dto'; import { CreateDirectoryWatcherDto } from './dtos/create-directory-watcher.dto'; import { UpdateDirectoryWatcherDto } from './dtos/update-directory-watcher.dto'; /** * CRUD operations on DirectoryWatchers. * @class DirectoryWatchersController */ @ApiTags('directory-watchers') @Controller('directory-watchers') export class DirectoryWatchersController extends PostyBirbController<'DirectoryWatcherSchema'> { constructor(readonly service: DirectoryWatchersService) { super(service); } @Post() @ApiOkResponse({ description: 'Entity created.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) create(@Body() createDto: CreateDirectoryWatcherDto) { return this.service.create(createDto).then((entity) => entity.toDTO()); } @Patch(':id') @ApiOkResponse({ description: 'Entity updated.', type: Boolean }) @ApiNotFoundResponse({ description: 'Entity not found.' }) update( @Body() updateDto: UpdateDirectoryWatcherDto, @Param('id') id: EntityId, ) { return this.service.update(id, updateDto).then((entity) => entity.toDTO()); } @Post('check-path') @ApiOkResponse({ description: 'Path check result.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) checkPath(@Body() checkPathDto: CheckPathDto) { return this.service.checkPath(checkPathDto.path); } } ================================================ FILE: apps/client-server/src/app/directory-watchers/directory-watchers.module.ts ================================================ import { Module } from '@nestjs/common'; import { NotificationsModule } from '../notifications/notifications.module'; import { SubmissionModule } from '../submission/submission.module'; import { DirectoryWatchersController } from './directory-watchers.controller'; import { DirectoryWatchersService } from './directory-watchers.service'; @Module({ imports: [SubmissionModule, NotificationsModule], controllers: [DirectoryWatchersController], providers: [DirectoryWatchersService], }) export class DirectoryWatchersModule {} ================================================ FILE: apps/client-server/src/app/directory-watchers/directory-watchers.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { DirectoryWatcherImportAction, SubmissionType } from '@postybirb/types'; import { mkdir, readdir, rename, writeFile } from 'fs/promises'; import { join } from 'path'; import { AccountService } from '../account/account.service'; import { NotificationsModule } from '../notifications/notifications.module'; import { CreateSubmissionDto } from '../submission/dtos/create-submission.dto'; import { SubmissionService } from '../submission/services/submission.service'; import { SubmissionModule } from '../submission/submission.module'; import { DirectoryWatchersService } from './directory-watchers.service'; import { CreateDirectoryWatcherDto } from './dtos/create-directory-watcher.dto'; import { UpdateDirectoryWatcherDto } from './dtos/update-directory-watcher.dto'; // Mock fs/promises jest.mock('fs/promises', () => ({ readdir: jest.fn(), mkdir: jest.fn(), rename: jest.fn(), writeFile: jest.fn(), })); describe('DirectoryWatchersService', () => { let service: DirectoryWatchersService; let submissionService: SubmissionService; let accountService: AccountService; let module: TestingModule; beforeEach(async () => { clearDatabase(); // Setup mocks (readdir as jest.Mock).mockResolvedValue([]); (mkdir as jest.Mock).mockResolvedValue(undefined); (rename as jest.Mock).mockResolvedValue(undefined); (writeFile as jest.Mock).mockResolvedValue(undefined); module = await Test.createTestingModule({ imports: [SubmissionModule, NotificationsModule], providers: [DirectoryWatchersService], }).compile(); service = module.get(DirectoryWatchersService); submissionService = module.get(SubmissionService); accountService = module.get(AccountService); await accountService.onModuleInit(); }); async function createSubmission() { const dto = new CreateSubmissionDto(); dto.name = 'test'; dto.type = SubmissionType.MESSAGE; dto.isTemplate = true; const record = await submissionService.create(dto); return record; } afterEach(() => { jest.clearAllMocks(); }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); expect(module).toBeDefined(); }); it('should create entities and setup directory structure', async () => { const dto = new CreateDirectoryWatcherDto(); dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION; dto.path = 'path'; await service.create(dto); // Verify directory structure was created expect(mkdir).toHaveBeenCalledWith(join('path', 'processing'), { recursive: true, }); expect(mkdir).toHaveBeenCalledWith(join('path', 'completed'), { recursive: true, }); expect(mkdir).toHaveBeenCalledWith(join('path', 'failed'), { recursive: true, }); const entities = await service.findAll(); const record = entities[0]; expect(record.path).toBe(dto.path); expect(record.importAction).toBe(dto.importAction); }); it('should update entities', async () => { const dto = new CreateDirectoryWatcherDto(); dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION; dto.path = 'path'; const record = await service.create(dto); expect(record.path).toBe(dto.path); expect(record.importAction).toBe(dto.importAction); const updateDto = new UpdateDirectoryWatcherDto(); updateDto.path = 'updated-path'; const updatedRecord = await service.update(record.id, updateDto); expect(updatedRecord.path).toBe(updateDto.path); // Verify directory structure was created for new path expect(mkdir).toHaveBeenCalledWith(join('updated-path', 'processing'), { recursive: true, }); }); it('should support templates', async () => { const template = await createSubmission(); const dto = new CreateDirectoryWatcherDto(); dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION; dto.path = 'path'; const record = await service.create(dto); expect(record.path).toBe(dto.path); const updateDto = new UpdateDirectoryWatcherDto(); updateDto.templateId = template.id; const updatedRecord = await service.update(record.id, updateDto); expect(updatedRecord.templateId).toBe(template.id); expect(updatedRecord.toDTO()).toEqual({ createdAt: updatedRecord.createdAt, updatedAt: updatedRecord.updatedAt, id: updatedRecord.id, importAction: DirectoryWatcherImportAction.NEW_SUBMISSION, path: 'path', templateId: template.id, }); await submissionService.remove(template.id); const rec = await service.findById(updatedRecord.id); expect(rec.templateId).toBe(null); }); it('should throw error if path does not exist', async () => { (readdir as jest.Mock).mockRejectedValue(new Error('Path not found')); const dto = new CreateDirectoryWatcherDto(); dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION; dto.path = 'non-existent-path'; await expect(service.create(dto)).rejects.toThrow( "Path 'non-existent-path' does not exist or is not accessible", ); }); it('should create entity without path (UI flow)', async () => { const dto = new CreateDirectoryWatcherDto(); dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION; // No path provided const record = await service.create(dto); // Verify entity was created without path expect(record.importAction).toBe(dto.importAction); expect(record.path).toBeNull(); // Verify no directory structure was created expect(mkdir).not.toHaveBeenCalled(); }); it('should create directory structure when path is first set via update', async () => { // Create without path const dto = new CreateDirectoryWatcherDto(); dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION; const record = await service.create(dto); expect(record.path).toBeNull(); // Clear mocks from create jest.clearAllMocks(); // Update with path const updateDto = new UpdateDirectoryWatcherDto(); updateDto.path = 'new-path'; const updatedRecord = await service.update(record.id, updateDto); expect(updatedRecord.path).toBe('new-path'); // Verify directory structure was created expect(mkdir).toHaveBeenCalledWith(join('new-path', 'processing'), { recursive: true, }); expect(mkdir).toHaveBeenCalledWith(join('new-path', 'completed'), { recursive: true, }); expect(mkdir).toHaveBeenCalledWith(join('new-path', 'failed'), { recursive: true, }); }); }); ================================================ FILE: apps/client-server/src/app/directory-watchers/directory-watchers.service.ts ================================================ import { BadRequestException, Injectable, Optional } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { DIRECTORY_WATCHER_UPDATES } from '@postybirb/socket-events'; import { DirectoryWatcherImportAction, EntityId, SubmissionType, } from '@postybirb/types'; import { IsTestEnvironment } from '@postybirb/utils/electron'; import { mkdir, readdir, rename, writeFile } from 'fs/promises'; import { getType } from 'mime'; import { join } from 'path'; import { PostyBirbService } from '../common/service/postybirb-service'; import { DirectoryWatcher } from '../drizzle/models'; import { MulterFileInfo } from '../file/models/multer-file-info'; import { NotificationsService } from '../notifications/notifications.service'; import { SubmissionService } from '../submission/services/submission.service'; import { WSGateway } from '../web-socket/web-socket-gateway'; import { CreateDirectoryWatcherDto } from './dtos/create-directory-watcher.dto'; import { UpdateDirectoryWatcherDto } from './dtos/update-directory-watcher.dto'; /** * Directory structure for file processing: * * {watch-path}/ <- Users drop files here * ├── processing/ <- Files being actively processed * ├── completed/ <- Successfully processed files * └── failed/ <- Files that failed processing (with .error.txt) */ /** * A directory watcher service that reads created watchers and checks * for new files added to the folder. * * Files are moved through different folders based on processing status, * eliminating the need for metadata tracking. * * @class DirectoryWatchersService * @extends {PostyBirbService} */ /** * Threshold for warning users about folders with many files. * If a folder contains more than this number of files, a confirmation will be required. */ export const FILE_COUNT_WARNING_THRESHOLD = 10; /** * Result of checking a directory path for file watcher usage. */ export interface CheckPathResult { /** Whether the path is valid and accessible */ valid: boolean; /** Number of files in the directory (excluding subfolders) */ count: number; /** List of file names in the directory */ files: string[]; /** Error message if the path is invalid */ error?: string; } @Injectable() export class DirectoryWatchersService extends PostyBirbService<'DirectoryWatcherSchema'> { private runningWatchers = new Set(); private recoveredWatchers = new Set(); private readonly SUBFOLDER_PROCESSING = 'processing'; private readonly SUBFOLDER_COMPLETED = 'completed'; private readonly SUBFOLDER_FAILED = 'failed'; constructor( private readonly submissionService: SubmissionService, private readonly notificationService: NotificationsService, @Optional() webSocket?: WSGateway, ) { super('DirectoryWatcherSchema', webSocket); this.repository.subscribe('DirectoryWatcherSchema', () => this.emitUpdates(), ); } protected async emitUpdates() { super.emit({ event: DIRECTORY_WATCHER_UPDATES, data: (await this.repository.findAll()).map((entity) => entity.toDTO()), }); } /** * CRON run read of paths. */ @Cron(CronExpression.EVERY_30_SECONDS) private async run() { if (!IsTestEnvironment()) { const entities = await this.repository.findAll(); entities .filter((e) => !!e.path) .forEach((e) => { // Recover orphaned files on first run if (!this.recoveredWatchers.has(e.id)) { this.recoverOrphanedFiles(e); this.recoveredWatchers.add(e.id); } // Process new files if not already running if (!this.runningWatchers.has(e.id)) { this.runningWatchers.add(e.id); this.read(e).finally(() => this.runningWatchers.delete(e.id)); } }); } } /** * Ensures all required subdirectories exist. * Skips if the base path doesn't exist. * * @param {string} basePath */ private async ensureDirectoryStructure(basePath: string): Promise { // Check if base path exists, skip if it doesn't try { await readdir(basePath); } catch { return; } const subfolders = [ this.SUBFOLDER_PROCESSING, this.SUBFOLDER_COMPLETED, this.SUBFOLDER_FAILED, ]; for (const folder of subfolders) { await mkdir(join(basePath, folder), { recursive: true }); } } /** * Recovers files that were left in the processing folder due to app crash/restart. * Moves them back to the main watch folder for reprocessing. * * @param {DirectoryWatcher} watcher */ private async recoverOrphanedFiles(watcher: DirectoryWatcher): Promise { try { await this.ensureDirectoryStructure(watcher.path); const processingPath = join(watcher.path, this.SUBFOLDER_PROCESSING); const orphanedFiles = await readdir(processingPath); if (orphanedFiles.length > 0) { this.logger.info( `Recovering ${orphanedFiles.length} orphaned files in ${watcher.path}`, ); for (const file of orphanedFiles) { const sourcePath = join(processingPath, file); const targetPath = join(watcher.path, file); try { await rename(sourcePath, targetPath); this.logger.info(`Recovered orphaned file: ${file}`); } catch (err) { this.logger.error(err, `Failed to recover orphaned file: ${file}`); } } this.notificationService.create({ title: 'Directory Watcher Recovery', message: `Recovered ${orphanedFiles.length} orphaned files in '${watcher.path}'`, type: 'info', tags: ['directory-watcher', 'recovery'], data: { recoveredFiles: orphanedFiles, watcherId: watcher.id, }, }); } } catch (err) { this.logger.error( err, `Failed to recover orphaned files for watcher ${watcher.id}`, ); } } /** * Reads directory for processable files. * * @param {DirectoryWatcher} watcher */ private async read(watcher: DirectoryWatcher) { try { // Ensure directory structure exists await this.ensureDirectoryStructure(watcher.path); const allFiles = await readdir(watcher.path); const filesInDirectory = allFiles.filter( (file) => file !== this.SUBFOLDER_PROCESSING && file !== this.SUBFOLDER_COMPLETED && file !== this.SUBFOLDER_FAILED, ); // Only process and notify if there are files if (filesInDirectory.length === 0) { return; } const results = { success: [], failed: [] }; // Process files sequentially for (const file of filesInDirectory) { try { await this.processFileWithMove(watcher, file); results.success.push(file); } catch (err) { this.logger.error(err, `Failed to process file ${file}`); results.failed.push({ file, error: err.message }); } } // Create notification with success/failure breakdown this.notificationService.create({ title: 'Directory Watcher', message: `Processed ${results.success.length} of ${filesInDirectory.length} files in '${watcher.path}'`, type: results.failed.length > 0 ? 'warning' : 'info', tags: ['directory-watcher'], data: { successCount: results.success.length, failedCount: results.failed.length, successFiles: results.success, failedFiles: results.failed, watcherId: watcher.id, }, }); } catch (e) { this.logger.error(e, `Failed to read directory ${watcher.path}`); this.notificationService.create({ title: 'Directory Watcher Error', message: `Failed to read directory ${watcher.path}`, type: 'error', tags: ['directory-watcher'], data: { error: e.message, }, }); } } /** * Processes a file using the move/archive pattern. * Files are moved through: main folder -> processing -> completed/failed * * @param {DirectoryWatcher} watcher * @param {string} fileName */ private async processFileWithMove( watcher: DirectoryWatcher, fileName: string, ): Promise { const sourcePath = join(watcher.path, fileName); const processingPath = join( watcher.path, this.SUBFOLDER_PROCESSING, fileName, ); const completedPath = join( watcher.path, this.SUBFOLDER_COMPLETED, fileName, ); const failedPath = join(watcher.path, this.SUBFOLDER_FAILED, fileName); let currentLocation = sourcePath; let submissionId: EntityId | null = null; try { // Step 1: Move to processing folder (atomic operation) await rename(sourcePath, processingPath); currentLocation = processingPath; this.logger.info(`Processing file ${fileName}`); // Step 2: Process the file const multerInfo: MulterFileInfo = { fieldname: '', origin: 'directory-watcher', originalname: fileName, encoding: '', mimetype: getType(fileName), size: 0, destination: '', filename: fileName, path: processingPath, // Use processing path }; switch (watcher.importAction) { case DirectoryWatcherImportAction.NEW_SUBMISSION: { const submission = await this.submissionService.create( { name: fileName, type: SubmissionType.FILE, }, multerInfo, ); submissionId = submission.id; if (watcher.template) { await this.submissionService.applyOverridingTemplate( submission.id, watcher.template?.id, ); } break; } default: break; } // Step 3: Move to completed folder await rename(processingPath, completedPath); this.logger.info( `Successfully processed file ${fileName} (submission: ${submissionId})`, ); } catch (err) { this.logger.error(err, `Failed to process file ${fileName}`); // Cleanup submission if it was created if (submissionId) { await this.submissionService .remove(submissionId) .catch((cleanupErr) => { this.logger.error( cleanupErr, `Failed to cleanup submission ${submissionId}`, ); }); } // Move to failed folder and create error file try { await rename(currentLocation, failedPath); // Create error details file const errorFilePath = join( watcher.path, this.SUBFOLDER_FAILED, `${fileName}.error.txt`, ); const errorDetails = [ `File: ${fileName}`, `Failed at: ${new Date().toISOString()}`, `Error: ${err.message}`, `Stack: ${err.stack || 'N/A'}`, ].join('\n'); await writeFile(errorFilePath, errorDetails); } catch (moveErr) { this.logger.error( moveErr, `Failed to move file to failed folder: ${fileName}`, ); } throw err; } } async create( createDto: CreateDirectoryWatcherDto, ): Promise { // Validate path exists and is accessible (only if path is provided) if (createDto.path) { try { await readdir(createDto.path); } catch (err) { throw new BadRequestException( `Path '${createDto.path}' does not exist or is not accessible`, ); } // Create directory structure await this.ensureDirectoryStructure(createDto.path); } return this.repository.insert(createDto); } async update(id: EntityId, update: UpdateDirectoryWatcherDto) { this.logger.withMetadata(update).info(`Updating DirectoryWatcher '${id}'`); const entity = await this.repository.findById(id, { failOnMissing: true }); // Validate path if being updated if (update.path && update.path !== entity.path) { try { await readdir(update.path); } catch (err) { throw new BadRequestException( `Path '${update.path}' does not exist or is not accessible`, ); } // Create directory structure for new path await this.ensureDirectoryStructure(update.path); } const template = update.templateId ? await this.submissionService.findById(update.templateId, { failOnMissing: true, }) : null; if (template && !template.isTemplate) { throw new BadRequestException('Template Id provided is not a template.'); } const updatedEntity = await this.repository.update(id, { importAction: update.importAction ?? entity.importAction, path: update.path ?? entity.path, templateId: update.templateId ?? entity.templateId, }); // If this is the first time setting a path (from null/empty to valid path), ensure directory structure if (!entity.path && updatedEntity.path) { await this.ensureDirectoryStructure(updatedEntity.path); } return updatedEntity; } /** * Checks a directory path for validity and returns file count information. * Used to warn users before selecting folders with many files. * * @param {string} path - The directory path to check * @returns {Promise} Information about the directory */ async checkPath(path: string): Promise { try { const allFiles = await readdir(path); const files = allFiles.filter( (file) => file !== this.SUBFOLDER_PROCESSING && file !== this.SUBFOLDER_COMPLETED && file !== this.SUBFOLDER_FAILED, ); return { valid: true, count: files.length, files, }; } catch (err) { return { valid: false, count: 0, files: [], error: `Path '${path}' does not exist or is not accessible`, }; } } } ================================================ FILE: apps/client-server/src/app/directory-watchers/dtos/check-path.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class CheckPathDto { @ApiProperty({ description: 'The directory path to check' }) @IsNotEmpty() @IsString() path: string; } ================================================ FILE: apps/client-server/src/app/directory-watchers/dtos/create-directory-watcher.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { DirectoryWatcherImportAction, ICreateDirectoryWatcherDto, } from '@postybirb/types'; import { IsEnum, IsOptional, IsString } from 'class-validator'; export class CreateDirectoryWatcherDto implements ICreateDirectoryWatcherDto { @ApiProperty() @IsOptional() @IsString() path: string; @ApiProperty({ enum: DirectoryWatcherImportAction, }) @IsEnum(DirectoryWatcherImportAction) importAction: DirectoryWatcherImportAction; } ================================================ FILE: apps/client-server/src/app/directory-watchers/dtos/update-directory-watcher.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { DirectoryWatcherImportAction, IUpdateDirectoryWatcherDto, SubmissionId, } from '@postybirb/types'; import { IsEnum, IsOptional, IsString } from 'class-validator'; export class UpdateDirectoryWatcherDto implements IUpdateDirectoryWatcherDto { @ApiProperty({ enum: DirectoryWatcherImportAction, required: false, }) @IsOptional() @IsEnum(DirectoryWatcherImportAction) importAction?: DirectoryWatcherImportAction; @ApiProperty({ required: false }) @IsOptional() @IsString() path?: string; @ApiProperty({ required: false }) @IsOptional() @IsString() templateId?: SubmissionId; } ================================================ FILE: apps/client-server/src/app/drizzle/models/account.entity.ts ================================================ import { IAccount, IAccountDto } from '@postybirb/types'; import { Exclude, instanceToPlain, Type } from 'class-transformer'; import { UnknownWebsite } from '../../websites/website'; import { DatabaseEntity } from './database-entity'; import { WebsiteData } from './website-data.entity'; export class Account extends DatabaseEntity implements IAccount { name: string; website: string; groups: string[] = []; /** * we don't want to pass this down to users unless filtered * by the website instance. */ @Exclude() @Type(() => WebsiteData) websiteData: WebsiteData; @Exclude() websiteInstance?: UnknownWebsite; // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor(entity: Partial) { super(entity); Object.assign(this, entity); } toObject(): IAccount { return instanceToPlain(this, { enableCircularCheck: true, }) as IAccount; } toDTO(): IAccountDto { const dto: IAccountDto = { ...(this.toObject() as unknown as IAccountDto), data: this.websiteInstance?.getWebsiteData() ?? {}, state: this.websiteInstance?.getLoginState() ?? { isLoggedIn: false, username: '', pending: false, lastUpdated: null, }, websiteInfo: { websiteDisplayName: this.websiteInstance?.decoratedProps.metadata.displayName ?? '', supports: this.websiteInstance?.getSupportedTypes() ?? [], }, }; return dto; } withWebsiteInstance(websiteInstance: UnknownWebsite): this { this.websiteInstance = websiteInstance; return this; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/custom-shortcut.entity.ts ================================================ import { DefaultDescription, Description, ICustomShortcut, ICustomShortcutDto, } from '@postybirb/types'; import { instanceToPlain } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; export class CustomShortcut extends DatabaseEntity implements ICustomShortcut { name: string; shortcut: Description = DefaultDescription(); constructor(entity: Partial) { super(entity); Object.assign(this, entity); } public toObject(): ICustomShortcut { return instanceToPlain(this, { enableCircularCheck: true, }) as ICustomShortcut; } public toDTO(): ICustomShortcutDto { return instanceToPlain(this, { enableCircularCheck: true, }) as ICustomShortcutDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/database-entity.spec.ts ================================================ import { IEntity, IEntityDto } from '@postybirb/types'; import { instanceToPlain } from 'class-transformer'; import 'reflect-metadata'; import { DatabaseEntity, fromDatabaseRecord } from './database-entity'; class TestEntity extends DatabaseEntity { public testField: string; constructor(entity: Partial) { super(entity); Object.assign(this, entity); } toObject(): IEntity { return instanceToPlain(this, { enableCircularCheck: true, }) as unknown as IEntity; } toDTO(): IEntityDto { return this.toObject() as unknown as IEntityDto; } } describe('DatabaseEntity', () => { let entity: TestEntity; beforeEach(() => { entity = new TestEntity({ id: 'id', testField: 'test', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); }); it('should create an instance', () => { expect(entity).toBeTruthy(); }); it('should convert class to object', () => { const obj = entity.toObject(); expect(obj).toBeTruthy(); }); it('should succeed fromDatabaseObject', () => { const dbObj = { id: 'id', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), testField: 'test', }; const obj = fromDatabaseRecord(TestEntity, dbObj); expect(obj).toBeTruthy(); expect(obj.id).toBe(dbObj.id); expect(obj.createdAt).toEqual(dbObj.createdAt); expect(obj.updatedAt).toEqual(dbObj.updatedAt); expect(obj.testField).toBe(dbObj.testField); }); it('should convert toObject', () => { const obj = entity.toObject(); expect(obj).toBeTruthy(); expect(obj.id).toBe(entity.id); expect(obj.createdAt).toBe(entity.createdAt); expect(obj.updatedAt).toBe(entity.updatedAt); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((obj as any).testField).toBe(entity.testField); }); }); ================================================ FILE: apps/client-server/src/app/drizzle/models/database-entity.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { EntityId, IEntity, IEntityDto } from '@postybirb/types'; import { ClassConstructor, plainToClass, plainToInstance, } from 'class-transformer'; import { v4 } from 'uuid'; export function fromDatabaseRecord( entity: ClassConstructor, record: any[], ): TEntity[]; export function fromDatabaseRecord( entity: ClassConstructor, record: any, ): TEntity; export function fromDatabaseRecord( entity: ClassConstructor, record: any | any[], ): TEntity | TEntity[] { if (Array.isArray(record)) { return record.map((r) => plainToInstance(entity, r, { enableCircularCheck: true }), ) as TEntity[]; } return plainToClass(entity, record, { enableCircularCheck: true, }) as TEntity; } export abstract class DatabaseEntity implements IEntity { public readonly id: EntityId; public createdAt: string; public updatedAt: string; constructor(entity: Partial) { Object.assign(this, entity); if (!this.id) { this.id = v4(); } } public abstract toObject(): IEntity; public abstract toDTO(): IEntityDto; public toJSON(): string { return JSON.stringify(this.toDTO()); } } ================================================ FILE: apps/client-server/src/app/drizzle/models/directory-watcher.entity.ts ================================================ import { DirectoryWatcherDto, DirectoryWatcherImportAction, IDirectoryWatcher, SubmissionId, } from '@postybirb/types'; import { instanceToPlain, Type } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; import { Submission } from './submission.entity'; export class DirectoryWatcher extends DatabaseEntity implements IDirectoryWatcher { path?: string; templateId: SubmissionId; importAction: DirectoryWatcherImportAction; @Type(() => Submission) template: Submission; toObject(): IDirectoryWatcher { return instanceToPlain(this, { enableCircularCheck: true, }) as IDirectoryWatcher; } toDTO(): DirectoryWatcherDto { return this.toObject() as unknown as DirectoryWatcherDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/file-buffer.entity.ts ================================================ import { EntityId, FileBufferDto, IFileBuffer } from '@postybirb/types'; import { Exclude, instanceToPlain, Type } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; export class FileBuffer extends DatabaseEntity implements IFileBuffer { submissionFileId: EntityId; @Type(() => Buffer) @Exclude({ toPlainOnly: true }) buffer: Buffer; fileName: string; mimeType: string; size: number; width: number; height: number; constructor(entity: Partial) { super(entity); Object.assign(this, entity); } toObject(): IFileBuffer { return instanceToPlain(this, { enableCircularCheck: true, }) as IFileBuffer; } toDTO(): FileBufferDto { return this.toObject() as unknown as FileBufferDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/index.ts ================================================ export * from './account.entity'; export * from './database-entity'; export * from './directory-watcher.entity'; export * from './file-buffer.entity'; export * from './post-event.entity'; export * from './post-queue-record.entity'; export * from './post-record.entity'; export * from './settings.entity'; export * from './submission-file.entity'; export * from './submission.entity'; export * from './tag-converter.entity'; export * from './tag-group.entity'; export * from './user-converter.entity'; export * from './user-specified-website-options.entity'; export * from './website-data.entity'; export * from './website-options.entity'; ================================================ FILE: apps/client-server/src/app/drizzle/models/notification.entity.ts ================================================ import { INotification } from '@postybirb/types'; import { instanceToPlain } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; export class Notification extends DatabaseEntity implements INotification { title: string; message: string; tags: string[]; data: Record; isRead: boolean; hasEmitted: boolean; type: 'warning' | 'error' | 'info' | 'success'; toObject(): INotification { return instanceToPlain(this, {}) as INotification; } toDTO(): INotification { return this.toObject() as unknown as INotification; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/post-event.entity.ts ================================================ import { AccountId, EntityId, IPostEvent, IPostEventError, IPostEventMetadata, PostEventDto, PostEventType, } from '@postybirb/types'; import { instanceToPlain, Type } from 'class-transformer'; import { Account } from './account.entity'; import { DatabaseEntity } from './database-entity'; import { PostRecord } from './post-record.entity'; export class PostEvent extends DatabaseEntity implements IPostEvent { postRecordId: EntityId; accountId?: AccountId; eventType: PostEventType; fileId?: EntityId; sourceUrl?: string; error?: IPostEventError; metadata?: IPostEventMetadata; @Type(() => PostRecord) postRecord: PostRecord; @Type(() => Account) account?: Account; constructor(entity: Partial) { super(entity); Object.assign(this, entity); } toObject(): IPostEvent { return instanceToPlain(this, { enableCircularCheck: true, }) as IPostEvent; } toDTO(): PostEventDto { const dto: PostEventDto = { ...this.toObject(), account: this.account?.toDTO(), }; return dto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/post-queue-record.entity.ts ================================================ import { EntityId, IPostQueueRecord, PostQueueRecordDto, SubmissionId } from '@postybirb/types'; import { instanceToPlain, Type } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; import { PostRecord } from './post-record.entity'; import { Submission } from './submission.entity'; export class PostQueueRecord extends DatabaseEntity implements IPostQueueRecord { postRecordId: EntityId; submissionId: SubmissionId; @Type(() => PostRecord) postRecord: PostRecord; @Type(() => Submission) submission: Submission; toObject(): IPostQueueRecord { return instanceToPlain(this, { enableCircularCheck: true, }) as IPostQueueRecord; } toDTO(): PostQueueRecordDto { return this.toObject() as unknown as PostQueueRecordDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/post-record.entity.ts ================================================ import { EntityId, IPostRecord, PostRecordDto, PostRecordResumeMode, PostRecordState, SubmissionId, } from '@postybirb/types'; import { instanceToPlain, Type } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; import { PostEvent } from './post-event.entity'; import { PostQueueRecord } from './post-queue-record.entity'; import { Submission } from './submission.entity'; export class PostRecord extends DatabaseEntity implements IPostRecord { postQueueRecordId: EntityId; submissionId: SubmissionId; @Type(() => Submission) submission: Submission; /** * Reference to the originating NEW PostRecord for this chain. * null for NEW records (they ARE the origin). */ originPostRecordId?: EntityId; /** * The originating NEW PostRecord (resolved relation). */ @Type(() => PostRecord) origin?: PostRecord; /** * All CONTINUE/RETRY PostRecords that chain to this origin. */ @Type(() => PostRecord) chainedRecords?: PostRecord[]; completedAt?: string; state: PostRecordState; resumeMode: PostRecordResumeMode; // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor(entity: Partial) { super(entity); } @Type(() => PostEvent) events: PostEvent[]; @Type(() => PostQueueRecord) postQueueRecord: PostQueueRecord; toObject(): IPostRecord { return instanceToPlain(this, { enableCircularCheck: true, }) as IPostRecord; } toDTO(): PostRecordDto { const dto: PostRecordDto = { ...this.toObject(), events: this.events?.map((event) => event.toDTO()), postQueueRecord: this.postQueueRecord?.toDTO(), }; return dto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/settings.entity.ts ================================================ import { ISettings, ISettingsOptions, SettingsConstants, SettingsDto, } from '@postybirb/types'; import { instanceToPlain } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; export class Settings extends DatabaseEntity implements ISettings { profile: string; settings: ISettingsOptions = { ...SettingsConstants.DEFAULT_SETTINGS }; toObject(): ISettings { return instanceToPlain(this, { enableCircularCheck: true, }) as ISettings; } toDTO(): SettingsDto { return this.toObject() as unknown as SettingsDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/submission-file.entity.ts ================================================ import { EntityId, FileSubmissionMetadata, ISubmissionFile, ISubmissionFileDto, SubmissionFileMetadata, } from '@postybirb/types'; import { instanceToPlain, Type } from 'class-transformer'; import { PostyBirbDatabase } from '../postybirb-database/postybirb-database'; import { DatabaseEntity } from './database-entity'; import { FileBuffer } from './file-buffer.entity'; import { Submission } from './submission.entity'; export class SubmissionFile extends DatabaseEntity implements ISubmissionFile { submissionId: EntityId; primaryFileId: EntityId; altFileId: EntityId; thumbnailId: EntityId; @Type(() => Submission) submission: Submission; fileName: string; hash: string; mimeType: string; @Type(() => FileBuffer) file: FileBuffer; @Type(() => FileBuffer) thumbnail?: FileBuffer; @Type(() => FileBuffer) altFile?: FileBuffer; hasThumbnail: boolean; hasAltFile: boolean; hasCustomThumbnail: boolean; size: number; width: number; height: number; metadata: SubmissionFileMetadata; order: number; constructor(entity: Partial) { super(entity); Object.assign(this, entity); } toObject(): ISubmissionFile { return instanceToPlain(this, { enableCircularCheck: true, }) as ISubmissionFile; } toDTO(): ISubmissionFileDto { return this.toObject() as unknown as ISubmissionFileDto; } /** * Load the submission file from the database * More of a workaround for the lack of proper ORM support with * blob relations loading from nested with queries. */ public async load(fileTarget?: 'file' | 'thumbnail' | 'alt') { const db = new PostyBirbDatabase('FileBufferSchema'); if (fileTarget) { switch (fileTarget) { case 'file': this.file = await db.findById(this.primaryFileId); break; case 'thumbnail': this.thumbnail = await db.findById(this.thumbnailId); break; case 'alt': this.altFile = await db.findById(this.altFileId); break; default: throw new Error('Invalid file target'); } return; } this.file = await db.findById(this.primaryFileId); if (this.thumbnailId) { this.thumbnail = await db.findById(this.thumbnailId); } if (this.altFileId) { this.altFile = await db.findById(this.altFileId); } } } ================================================ FILE: apps/client-server/src/app/drizzle/models/submission.entity.ts ================================================ import { ISubmission, ISubmissionDto, ISubmissionMetadata, ISubmissionScheduleInfo, ScheduleType, SubmissionType, } from '@postybirb/types'; import { instanceToPlain, Type } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; import { PostQueueRecord } from './post-queue-record.entity'; import { PostRecord } from './post-record.entity'; import { SubmissionFile } from './submission-file.entity'; import { WebsiteOptions } from './website-options.entity'; export class Submission extends DatabaseEntity implements ISubmission { type: SubmissionType; @Type(() => WebsiteOptions) options: WebsiteOptions[]; @Type(() => PostQueueRecord) postQueueRecord?: PostQueueRecord; isScheduled = false; isTemplate = false; isMultiSubmission = false; isArchived = false; isInitialized = false; schedule: ISubmissionScheduleInfo = { scheduleType: ScheduleType.NONE, }; @Type(() => SubmissionFile) files: SubmissionFile[]; metadata: T; @Type(() => PostRecord) posts: PostRecord[]; order: number; constructor(entity: Partial>) { super(entity); Object.assign(this, entity); } toObject(): ISubmission { return instanceToPlain(this, { enableCircularCheck: true, }) as ISubmission; } toDTO(): ISubmissionDto { const dto: ISubmissionDto = { ...this.toObject(), files: this.files?.map((file) => file.toDTO()), options: this.options?.map((option) => option.toDTO()), posts: this.posts?.map((post) => post.toDTO()), postQueueRecord: this.postQueueRecord?.toDTO(), validations: [], }; return dto; } getSubmissionName(): string { if (this.options?.length) { return this.options.find((o) => o.isDefault)?.data.title; } return 'Unknown'; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/tag-converter.entity.ts ================================================ import { ITagConverter, TagConverterDto } from '@postybirb/types'; import { instanceToPlain } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; export class TagConverter extends DatabaseEntity implements ITagConverter { public readonly tag: string; public convertTo: Record = {}; constructor(entity: ITagConverter) { super(entity); Object.assign(this, entity); } toObject(): ITagConverter { return instanceToPlain(this, { enableCircularCheck: true, }) as ITagConverter; } toDTO(): TagConverterDto { return this.toObject() as unknown as TagConverterDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/tag-group.entity.ts ================================================ import { ITagGroup, TagGroupDto } from '@postybirb/types'; import { instanceToPlain } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; export class TagGroup extends DatabaseEntity implements ITagGroup { name: string; tags: string[] = []; toObject(): ITagGroup { return instanceToPlain(this, { enableCircularCheck: true, }) as ITagGroup; } toDTO(): TagGroupDto { return this.toObject() as unknown as TagGroupDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/user-converter.entity.ts ================================================ import { IUserConverter, UserConverterDto } from '@postybirb/types'; import { instanceToPlain } from 'class-transformer'; import { DatabaseEntity } from './database-entity'; export class UserConverter extends DatabaseEntity implements IUserConverter { public readonly username: string; public convertTo: Record = {}; constructor(entity: IUserConverter) { super(entity); Object.assign(this, entity); } toObject(): IUserConverter { return instanceToPlain(this, { enableCircularCheck: true, }) as IUserConverter; } toDTO(): UserConverterDto { return this.toObject() as unknown as UserConverterDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/user-specified-website-options.entity.ts ================================================ import { AccountId, DynamicObject, IUserSpecifiedWebsiteOptions, SubmissionType, UserSpecifiedWebsiteOptionsDto } from '@postybirb/types'; import { instanceToPlain, Type } from 'class-transformer'; import { Account } from './account.entity'; import { DatabaseEntity } from './database-entity'; export class UserSpecifiedWebsiteOptions extends DatabaseEntity implements IUserSpecifiedWebsiteOptions { accountId: AccountId; @Type(() => Account) account: Account; type: SubmissionType; options: DynamicObject; toObject(): IUserSpecifiedWebsiteOptions { return instanceToPlain(this, { enableCircularCheck: true, }) as IUserSpecifiedWebsiteOptions; } toDTO(): UserSpecifiedWebsiteOptionsDto { return this.toObject() as unknown as UserSpecifiedWebsiteOptionsDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/website-data.entity.ts ================================================ import { DynamicObject, IWebsiteData, IWebsiteDataDto } from '@postybirb/types'; import { instanceToPlain, Type } from 'class-transformer'; import { Account } from './account.entity'; import { DatabaseEntity } from './database-entity'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export class WebsiteData extends DatabaseEntity implements IWebsiteData { data: T = {} as T; @Type(() => Account) account: Account; toObject(): IWebsiteData { return instanceToPlain(this) as IWebsiteData; } toDTO(): IWebsiteDataDto { return this.toObject() as unknown as IWebsiteDataDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/models/website-options.entity.ts ================================================ import { AccountId, IWebsiteFormFields, IWebsiteOptions, SubmissionId, WebsiteOptionsDto, } from '@postybirb/types'; import { instanceToPlain, Type } from 'class-transformer'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { Account } from './account.entity'; import { DatabaseEntity } from './database-entity'; import { Submission } from './submission.entity'; export class WebsiteOptions extends DatabaseEntity implements IWebsiteOptions { accountId: AccountId; @Type(() => Account) account: Account; submissionId: SubmissionId; @Type(() => Submission) submission: Submission; data: IWebsiteFormFields = new BaseWebsiteOptions(); isDefault: boolean; constructor(entity: Partial) { super(entity); Object.assign(this, entity); } toObject(): IWebsiteOptions { return instanceToPlain(this, { enableCircularCheck: true, }) as IWebsiteOptions; } toDTO(): WebsiteOptionsDto { return { ...this.toObject(), submission: this.submission?.toDTO(), } as unknown as WebsiteOptionsDto; } } ================================================ FILE: apps/client-server/src/app/drizzle/postybirb-database/find-options.type.ts ================================================ export type FindOptions = { failOnMissing?: boolean; }; ================================================ FILE: apps/client-server/src/app/drizzle/postybirb-database/postybirb-database.spec.ts ================================================ import { clearDatabase, Schemas } from '@postybirb/database'; import { eq as equals } from 'drizzle-orm'; import 'reflect-metadata'; import { PostyBirbDatabase } from './postybirb-database'; describe('PostyBirbDatabase', () => { let service: PostyBirbDatabase<'AccountSchema'>; beforeEach(() => { clearDatabase(); service = new PostyBirbDatabase('AccountSchema'); }); it('should be created', () => { expect(service).toBeTruthy(); }); it('should insert and delete', async () => { const account = await service.insert({ name: 'test', website: 'test', groups: [], }); const accounts = await service.findAll(); expect(accounts).toHaveLength(1); expect(accounts[0].id).toBe(account.id); expect(accounts[0].name).toBe('test'); expect(accounts[0].website).toBe('test'); await service.deleteById([account.id]); const accountsAfterDelete = await service.findAll(); expect(accountsAfterDelete).toHaveLength(0); }); it('should find by id', async () => { const account = await service.insert({ name: 'test', website: 'test', groups: [], }); const foundAccount = await service.findById(account.id, { failOnMissing: true, }); expect(foundAccount).toBeTruthy(); expect(foundAccount.id).toBe(account.id); expect(foundAccount.name).toBe('test'); expect(foundAccount.website).toBe('test'); const notFoundAccount = await service.findById('not-found', { failOnMissing: false, }); expect(notFoundAccount).toBeNull(); }); it('should throw on find by id not found', async () => { await expect( service.findById('not-found', { failOnMissing: true }), ).rejects.toThrow('Record with id not-found not found'); }); it('should update', async () => { const account = await service.insert({ name: 'test', website: 'test', groups: [], }); await service.update(account.id, { name: 'test2', }); const updatedAccount = await service.findById(account.id, { failOnMissing: true, }); expect(updatedAccount).toBeTruthy(); expect(updatedAccount.id).toBe(account.id); expect(updatedAccount.name).toBe('test2'); }); it('should find one', async () => { await service.insert({ name: 'test', website: 'test', groups: [], }); const foundAccount = await service.findOne({ where: (account, { eq }) => eq(account.name, 'test'), }); expect(foundAccount).toBeTruthy(); expect(foundAccount?.name).toBe('test'); }); it('should find many', async () => { await service.insert({ name: 'test', website: 'test', groups: [], }); await service.insert({ name: 'test2', website: 'test', groups: [], }); const foundAccounts = await service.find({ where: (account, { eq }) => eq(account.website, 'test'), }); expect(foundAccounts).toHaveLength(2); }); it('should return empty array on find many not found', async () => { const foundAccounts = await service.find({ where: (account, { eq }) => eq(account.website, 'test'), }); expect(foundAccounts).toHaveLength(0); }); it('should notify subscribers on create', async () => { const subscriber = jest.fn(); service.subscribe('AccountSchema', subscriber); const entity = await service.insert({ name: 'test', website: 'test', groups: [], }); expect(subscriber).toHaveBeenCalledWith([entity.id], 'insert'); }); it('should select', async () => { await service.insert({ name: 'test', website: 'test', groups: [], }); const foundAccounts = await service.select( equals(Schemas.AccountSchema.name, 'test'), ); expect(foundAccounts).toHaveLength(1); }); }); ================================================ FILE: apps/client-server/src/app/drizzle/postybirb-database/postybirb-database.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { NotFoundException } from '@nestjs/common'; import { getDatabase, Insert, PostyBirbDatabaseType, SchemaKey, Schemas, Select, } from '@postybirb/database'; import { EntityId, NULL_ACCOUNT_ID } from '@postybirb/types'; import { eq, inArray, KnownKeysOnly, SQL } from 'drizzle-orm'; import { DBQueryConfig, ExtractTablesWithRelations, } from 'drizzle-orm/relations'; import { fromDatabaseRecord } from '../models'; import { FindOptions } from './find-options.type'; import { DatabaseSchemaEntityMap, DatabaseSchemaEntityMapConst, } from './schema-entity-map'; type ExtractedRelations = ExtractTablesWithRelations; type Relation = PostyBirbDatabaseType['_']['schema'][TSchemaKey]; export type Action = 'delete' | 'insert' | 'update'; type SubscribeCallback = (ids: EntityId[], action: Action) => void; export class PostyBirbDatabase< TSchemaKey extends SchemaKey, TEntityClass = DatabaseSchemaEntityMap[TSchemaKey], > { public readonly db: PostyBirbDatabaseType; private static readonly subscribers: Record< SchemaKey, Array > = { AccountSchema: [], DirectoryWatcherSchema: [], FileBufferSchema: [], PostEventSchema: [], PostQueueRecordSchema: [], PostRecordSchema: [], SettingsSchema: [], SubmissionFileSchema: [], SubmissionSchema: [], TagConverterSchema: [], TagGroupSchema: [], UserConverterSchema: [], UserSpecifiedWebsiteOptionsSchema: [], WebsiteDataSchema: [], WebsiteOptionsSchema: [], NotificationSchema: [], CustomShortcutSchema: [], }; constructor( private readonly schemaKey: TSchemaKey, private readonly load?: DBQueryConfig< 'many', true, ExtractedRelations, Relation >['with'], ) { this.db = getDatabase(); } public subscribe(key: SchemaKey[], callback: SubscribeCallback): this; public subscribe(key: SchemaKey, callback: SubscribeCallback): this; public subscribe( key: SchemaKey | SchemaKey[], callback: SubscribeCallback, ): this { if (Array.isArray(key)) { key.forEach((k) => this.subscribe(k, callback)); return this; } if (!PostyBirbDatabase.subscribers[key]) { PostyBirbDatabase.subscribers[key] = []; } PostyBirbDatabase.subscribers[key].push(callback); return this; } private notify(ids: EntityId[], action: Action) { PostyBirbDatabase.subscribers[this.schemaKey].forEach((callback) => callback(ids, action), ); } /** * Forcefully calls notify. * To be used where database updates occur outside of normal db calls * such as during transaction and direct db mutations. * * @param {EntityId[]} ids * @param {Action} action */ public forceNotify(ids: EntityId[], action: Action) { this.notify(ids, action); } /** * Static method to notify subscribers for a given schema. * Used by TransactionContext to trigger notifications after commit. * * @param {SchemaKey} schemaKey - The schema key to notify subscribers for * @param {EntityId[]} ids - The entity IDs that were affected * @param {Action} action - The action that occurred (insert, update, delete) */ public static notifySubscribers( schemaKey: SchemaKey, ids: EntityId[], action: Action, ) { PostyBirbDatabase.subscribers[schemaKey]?.forEach((callback) => callback(ids, action), ); } public get EntityClass() { return DatabaseSchemaEntityMapConst[this.schemaKey]; } public get schemaEntity() { return Schemas[this.schemaKey]; } private classConverter(value: any[]): TEntityClass[]; private classConverter(value: any): TEntityClass; private classConverter(value: any | any[]): TEntityClass | TEntityClass[] { if (Array.isArray(value)) { return fromDatabaseRecord(this.EntityClass, value); } return fromDatabaseRecord(this.EntityClass, value); } public async insert(value: Insert): Promise; public async insert(value: Insert[]): Promise; public async insert( value: Insert | Insert[], ): Promise { const insertQuery = this.db .insert(this.schemaEntity) .values(value) .returning(); // After calling .returning(), the result is always an array of the selected fields const inserts = (await insertQuery) as Array>; this.notify( inserts.map((insert) => insert.id), 'insert', ); const result = await Promise.all( inserts.map((insert) => this.findById(insert.id)), ); return Array.isArray(value) ? result : result[0]; } public async deleteById(ids: EntityId[]) { if (ids.find((id) => id === NULL_ACCOUNT_ID)) { throw new Error('Cannot delete the null account'); } const result = await this.db .delete(this.schemaEntity) .where(inArray(this.schemaEntity.id, ids)); this.notify(ids, 'delete'); return result; } public async findById( id: EntityId, options?: FindOptions, load?: DBQueryConfig< 'many', true, ExtractedRelations, Relation >['with'], ): Promise { const record = await this.db.query[this.schemaKey].findFirst({ where: eq(this.schemaEntity.id, id), with: { ...(load ?? this.load ?? {}), }, }); if (!record && options?.failOnMissing) { throw new NotFoundException(`Record with id ${id} not found`); } return record ? (this.classConverter(record) as TEntityClass) : null; } public async select(query: SQL): Promise { const records = await this.db.select().from(this.schemaEntity).where(query); return this.classConverter(records); } public async find< TConfig extends DBQueryConfig< 'many', true, ExtractedRelations, Relation >, >( query: KnownKeysOnly< TConfig, DBQueryConfig<'many', true, ExtractedRelations, Relation> >, ): Promise { const record: any[] = (await this.db.query[ this.schemaKey as keyof PostyBirbDatabaseType ].findMany({ ...query, with: query.with ? query.with : { ...(this.load ?? {}), }, })) ?? []; return this.classConverter(record); } public async findOne< TSelection extends Omit< DBQueryConfig<'many', true, ExtractedRelations, Relation>, 'limit' >, >( query: KnownKeysOnly< TSelection, Omit< DBQueryConfig<'many', true, ExtractedRelations, Relation>, 'limit' > >, ): Promise { const record = await this.db.query[ this.schemaKey as keyof PostyBirbDatabaseType ].findFirst({ ...query, with: query.with ? query.with : { ...(this.load ?? {}), }, }); return record ? this.classConverter(record) : null; } public async findAll(): Promise { const records: object[] = await this.db.query[this.schemaKey].findMany({ with: { ...(this.load ?? {}), }, }); return this.classConverter(records); } public async update( id: EntityId, set: Partial>, ): Promise { await this.findById(id, { failOnMissing: true }); await this.db // eslint-disable-next-line testing-library/await-async-query .update(this.schemaEntity) .set(set) .where(eq(this.schemaEntity.id, id)); this.notify([id], 'update'); return this.findById(id); } public count(filter?: SQL): Promise { return this.db.$count(this.schemaEntity, filter); } } ================================================ FILE: apps/client-server/src/app/drizzle/postybirb-database/postybirb-database.util.ts ================================================ /* eslint-disable no-param-reassign */ import { SchemaKey, Schemas } from '@postybirb/database'; import { DatabaseEntity } from '../models'; import { PostyBirbDatabase } from './postybirb-database'; export class PostyBirbDatabaseUtil { static async saveFromEntity(entity: T) { const obj = entity.toObject(); let entitySchemaKey: SchemaKey | undefined; for (const schemaKey of Object.keys(Schemas)) { if (schemaKey === `${entity.constructor.name}Schema`) { entitySchemaKey = schemaKey as SchemaKey; break; } } if (!entitySchemaKey) { throw new Error(`Could not find schema for ${entity.constructor.name}`); } const db = new PostyBirbDatabase(entitySchemaKey); const exists = await db.findById(entity.id); if (exists) { if (exists.updatedAt !== entity.updatedAt) { throw new Error('Entity has been updated since last fetch'); } const update = await db.update(entity.id, obj); entity.updatedAt = update.updatedAt; } else { const insert = await db.insert(obj); entity.createdAt = insert.createdAt; entity.updatedAt = insert.updatedAt; } return entity; } } ================================================ FILE: apps/client-server/src/app/drizzle/postybirb-database/schema-entity-map.ts ================================================ import { SchemaKey } from '@postybirb/database'; import { Account, DirectoryWatcher, FileBuffer, PostEvent, PostQueueRecord, PostRecord, Settings, Submission, SubmissionFile, TagConverter, TagGroup, UserConverter, UserSpecifiedWebsiteOptions, WebsiteData, WebsiteOptions, } from '../models'; import { CustomShortcut } from '../models/custom-shortcut.entity'; import { Notification } from '../models/notification.entity'; export type DatabaseSchemaEntityMap = { AccountSchema: InstanceType; DirectoryWatcherSchema: InstanceType; FileBufferSchema: InstanceType; PostEventSchema: InstanceType; PostQueueRecordSchema: InstanceType; PostRecordSchema: InstanceType; SettingsSchema: InstanceType; SubmissionFileSchema: InstanceType; SubmissionSchema: InstanceType; TagConverterSchema: InstanceType; TagGroupSchema: InstanceType; UserConverterSchema: InstanceType; UserSpecifiedWebsiteOptionsSchema: InstanceType< typeof UserSpecifiedWebsiteOptions >; WebsiteDataSchema: InstanceType; WebsiteOptionsSchema: InstanceType; NotificationSchema: InstanceType; CustomShortcutSchema: InstanceType; }; export const DatabaseSchemaEntityMapConst: Record< SchemaKey, // eslint-disable-next-line @typescript-eslint/no-explicit-any InstanceType > = { AccountSchema: Account, DirectoryWatcherSchema: DirectoryWatcher, FileBufferSchema: FileBuffer, PostEventSchema: PostEvent, PostQueueRecordSchema: PostQueueRecord, PostRecordSchema: PostRecord, SettingsSchema: Settings, SubmissionFileSchema: SubmissionFile, SubmissionSchema: Submission, TagConverterSchema: TagConverter, TagGroupSchema: TagGroup, UserConverterSchema: UserConverter, UserSpecifiedWebsiteOptionsSchema: UserSpecifiedWebsiteOptions, WebsiteDataSchema: WebsiteData, WebsiteOptionsSchema: WebsiteOptions, NotificationSchema: Notification, CustomShortcutSchema: CustomShortcut, }; ================================================ FILE: apps/client-server/src/app/drizzle/transaction-context.ts ================================================ import type { PostyBirbDatabaseType, SchemaKey } from '@postybirb/database'; import { Schemas } from '@postybirb/database'; import { Logger } from '@postybirb/logger'; import { EntityId } from '@postybirb/types'; import { eq } from 'drizzle-orm'; import { PostyBirbDatabase } from './postybirb-database/postybirb-database'; interface TrackedEntity { schemaKey: SchemaKey; id: EntityId; } /** * A transaction-like wrapper that tracks created entities and provides * automatic cleanup on failure. This works around drizzle-orm's synchronous * transaction requirement with better-sqlite3. */ export class TransactionContext { private readonly logger = Logger(); private readonly createdEntities: TrackedEntity[] = []; private readonly db: PostyBirbDatabaseType; constructor(db: PostyBirbDatabaseType) { this.db = db; } /** * Track an entity that was created during this operation. * If the operation fails, this entity will be deleted. */ track(schemaKey: SchemaKey, id: EntityId): void { this.createdEntities.push({ schemaKey, id }); } /** * Track multiple entities that were created during this operation. */ trackMany(schemaKey: SchemaKey, ids: EntityId[]): void { ids.forEach((id) => this.track(schemaKey, id)); } /** * Get the database instance for performing operations. */ getDb(): PostyBirbDatabaseType { return this.db; } /** * Cleanup all tracked entities in reverse order (LIFO). * This is called automatically on failure. */ async cleanup(): Promise { this.logger.warn( `Rolling back transaction: cleaning up ${this.createdEntities.length} entities`, ); // Delete in reverse order (most recently created first) const entities = [...this.createdEntities].reverse(); for (const { schemaKey, id } of entities) { try { const schema = Schemas[schemaKey]; await this.db.delete(schema).where(eq(schema.id, id)); this.logger.debug(`Cleaned up ${String(schemaKey)} entity: ${id}`); } catch (err) { this.logger.error( `Failed to cleanup ${String(schemaKey)} entity ${id}: ${err.message}`, err.stack, ); } } } /** * Clear tracked entities and notify subscribers (called on successful completion). * * NOTE: Currently only tracks inserts. If update/delete tracking is needed in the future, * add trackUpdate() and trackDelete() methods that store the action type alongside the entity. */ commit(): void { // Group tracked entities by schemaKey const bySchema = new Map(); for (const { schemaKey, id } of this.createdEntities) { const existing = bySchema.get(schemaKey); if (existing) { existing.push(id); } else { bySchema.set(schemaKey, [id]); } } // Notify subscribers for each schema for (const [schemaKey, ids] of bySchema) { PostyBirbDatabase.notifySubscribers(schemaKey, ids, 'insert'); } this.createdEntities.length = 0; } } /** * Execute an operation with automatic cleanup on failure. * * @example * ```typescript * const result = await withTransactionContext( * this.fileRepository.db, * async (ctx) => { * const entity = await createEntity(...); * ctx.track('SubmissionFileSchema', entity.id); * * const buffer = await createBuffer(...); * ctx.track('FileBufferSchema', buffer.id); * * return entity; * } * ); * ``` */ export async function withTransactionContext( db: PostyBirbDatabaseType, operation: (ctx: TransactionContext) => Promise, ): Promise { const ctx = new TransactionContext(db); try { const result = await operation(ctx); ctx.commit(); return result; } catch (err) { await ctx.cleanup(); throw err; } } ================================================ FILE: apps/client-server/src/app/file/file.controller.ts ================================================ import { BadRequestException, Controller, Get, Param, Res, } from '@nestjs/common'; import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { EntityId, IFileBuffer } from '@postybirb/types'; import { SubmissionFile } from '../drizzle/models'; import { FileService } from './file.service'; @ApiTags('file') @Controller('file') export class FileController { constructor(private readonly service: FileService) {} @Get(':fileTarget/:id') @ApiOkResponse() @ApiNotFoundResponse() async getThumbnail( @Param('fileTarget') fileTarget: 'file' | 'thumbnail' | 'alt', @Param('id') id: EntityId, @Res() response, ) { const submissionFile = await this.service.findFile(id); await submissionFile.load(fileTarget); const imageProvidingEntity = this.getFileBufferForTarget( fileTarget, submissionFile, ); if (!imageProvidingEntity) { throw new BadRequestException(`No ${fileTarget} found for file ${id}`); } response.contentType(imageProvidingEntity.mimeType); response.send(imageProvidingEntity.buffer); } private getFileBufferForTarget( fileTarget: 'file' | 'thumbnail' | 'alt', submissionFile: SubmissionFile, ): IFileBuffer | undefined { switch (fileTarget) { case 'file': return submissionFile.file; case 'thumbnail': return submissionFile.thumbnail; case 'alt': return submissionFile.altFile; default: throw new BadRequestException('Invalid file target'); } } } ================================================ FILE: apps/client-server/src/app/file/file.module.ts ================================================ import { Module } from '@nestjs/common'; import { ImageProcessingModule } from '../image-processing/image-processing.module'; import { FileController } from './file.controller'; import { FileService } from './file.service'; import { CreateFileService } from './services/create-file.service'; import { UpdateFileService } from './services/update-file.service'; @Module({ imports: [ImageProcessingModule], controllers: [FileController], providers: [FileService, CreateFileService, UpdateFileService], exports: [FileService], }) export class FileModule {} ================================================ FILE: apps/client-server/src/app/file/file.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { PostyBirbDirectories, writeSync } from '@postybirb/fs'; import { FileSubmission, SubmissionType } from '@postybirb/types'; import { readFileSync } from 'fs'; import { join } from 'path'; import { AccountService } from '../account/account.service'; import { CustomShortcutsService } from '../custom-shortcuts/custom-shortcuts.service'; import { SubmissionFile } from '../drizzle/models'; import { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database'; import { FileConverterService } from '../file-converter/file-converter.service'; import { FormGeneratorService } from '../form-generator/form-generator.service'; import { DescriptionParserService } from '../post-parsers/parsers/description-parser.service'; import { TagParserService } from '../post-parsers/parsers/tag-parser.service'; import { TitleParser } from '../post-parsers/parsers/title-parser'; import { PostParsersService } from '../post-parsers/post-parsers.service'; import { SettingsService } from '../settings/settings.service'; import { CreateSubmissionDto } from '../submission/dtos/create-submission.dto'; import { FileSubmissionService } from '../submission/services/file-submission.service'; import { MessageSubmissionService } from '../submission/services/message-submission.service'; import { SubmissionService } from '../submission/services/submission.service'; import { TagConvertersService } from '../tag-converters/tag-converters.service'; import { UserConvertersService } from '../user-converters/user-converters.service'; import { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service'; import { ValidationService } from '../validation/validation.service'; import { WebsiteOptionsService } from '../website-options/website-options.service'; import { WebsiteImplProvider } from '../websites/implementations/provider'; import { WebsiteRegistryService } from '../websites/website-registry.service'; import { FileService } from './file.service'; import { MulterFileInfo } from './models/multer-file-info'; import { CreateFileService } from './services/create-file.service'; import { UpdateFileService } from './services/update-file.service'; import { SharpInstanceManager } from '../image-processing/sharp-instance-manager'; describe('FileService', () => { let testFile: Buffer | null = null; let testFile2: Buffer | null = null; let service: FileService; let submissionService: SubmissionService; let module: TestingModule; let fileBufferRepository: PostyBirbDatabase<'FileBufferSchema'>; async function createSubmission() { const dto = new CreateSubmissionDto(); dto.name = 'test'; dto.type = SubmissionType.MESSAGE; // Use message submission just for the sake of insertion const record = await submissionService.create(dto); return record; } function createMulterData(path: string): MulterFileInfo { return { fieldname: 'file', originalname: 'small_image.jpg', encoding: '', mimetype: 'image/jpeg', size: testFile.length, destination: '', filename: 'small_image.jpg', path, origin: undefined, }; } function createMulterData2(path: string): MulterFileInfo { return { fieldname: 'file', originalname: 'png_with_alpha.png', encoding: '', mimetype: 'image/png', size: testFile2.length, destination: '', filename: 'png_with_alpha.jpg', path, origin: undefined, }; } function setup(): string[] { const path = `${PostyBirbDirectories.DATA_DIRECTORY}/${Date.now()}.jpg`; const path2 = `${PostyBirbDirectories.DATA_DIRECTORY}/${Date.now()}.png`; writeSync(path, testFile); writeSync(path2, testFile2); return [path, path2]; } beforeAll(() => { testFile = readFileSync( join(__dirname, '../../test-files/small_image.jpg'), ); testFile2 = readFileSync( join(__dirname, '../../test-files/png_with_alpha.png'), ); }); beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ providers: [ UserSpecifiedWebsiteOptionsService, SubmissionService, CreateFileService, UpdateFileService, SharpInstanceManager, FileService, ValidationService, SubmissionService, FileSubmissionService, MessageSubmissionService, AccountService, WebsiteRegistryService, WebsiteOptionsService, WebsiteImplProvider, PostParsersService, TagParserService, DescriptionParserService, TitleParser, TagConvertersService, SettingsService, FormGeneratorService, FileConverterService, CustomShortcutsService, UserConvertersService, ], }).compile(); fileBufferRepository = new PostyBirbDatabase('FileBufferSchema'); service = module.get(FileService); submissionService = module.get(SubmissionService); const accountService = module.get(AccountService); await accountService.onModuleInit(); }); async function loadBuffers(rec: SubmissionFile) { // !bug - https://github.com/drizzle-team/drizzle-orm/issues/3497 // eslint-disable-next-line no-param-reassign rec.file = await fileBufferRepository.findById(rec.primaryFileId); // eslint-disable-next-line no-param-reassign rec.thumbnail = rec.thumbnailId ? await fileBufferRepository.findById(rec.thumbnailId) : undefined; // eslint-disable-next-line no-param-reassign rec.altFile = rec.altFileId ? await fileBufferRepository.findById(rec.altFileId) : undefined; } it('should be defined', () => { expect(service).toBeDefined(); }); it('should create submission file', async () => { const path = setup(); const submission = await createSubmission(); const fileInfo = createMulterData(path[0]); const file = await service.create( fileInfo, submission as unknown as FileSubmission, ); await loadBuffers(file); expect(file.file).toBeDefined(); expect(file.thumbnail).toBeDefined(); expect(file.thumbnail.fileName.startsWith('thumbnail_')).toBe(true); expect(file.fileName).toBe(fileInfo.originalname); expect(file.size).toBe(fileInfo.size); expect(file.hasThumbnail).toBe(true); expect(file.hasCustomThumbnail).toBe(false); expect(file.height).toBe(202); expect(file.width).toBe(138); expect(file.file.size).toBe(fileInfo.size); expect(file.file.height).toBe(202); expect(file.file.width).toBe(138); expect(file.file.submissionFileId).toEqual(file.id); expect(file.file.mimeType).toEqual(fileInfo.mimetype); expect(file.file.buffer).toEqual(testFile); }); it('should not update submission file when hash is same', async () => { const path = setup(); const submission = await createSubmission(); const fileInfo = createMulterData(path[0]); const file = await service.create( fileInfo, submission as unknown as FileSubmission, ); await loadBuffers(file); expect(file.file).toBeDefined(); const path2 = setup(); const updateFileInfo: MulterFileInfo = { fieldname: 'file', originalname: 'small_image.jpg', encoding: '', mimetype: 'image/png', size: testFile.length, destination: '', filename: 'small_image.jpg', path: path2[0], origin: undefined, }; const updatedFile = await service.update(updateFileInfo, file.id, false); await loadBuffers(updatedFile); expect(updatedFile.file).toBeDefined(); expect(updatedFile.thumbnail).toBeDefined(); expect(updatedFile.fileName).toBe(updateFileInfo.originalname); expect(updatedFile.size).toBe(updateFileInfo.size); expect(updatedFile.hasThumbnail).toBe(true); expect(updatedFile.hasCustomThumbnail).toBe(false); expect(updatedFile.height).toBe(202); expect(updatedFile.width).toBe(138); expect(updatedFile.file.size).toBe(updateFileInfo.size); expect(updatedFile.file.height).toBe(202); expect(updatedFile.file.width).toBe(138); expect(updatedFile.file.submissionFileId).toEqual(file.id); expect(updatedFile.file.mimeType).not.toEqual(updateFileInfo.mimetype); expect(updatedFile.file.buffer).toEqual(testFile); }); it('should update submission primary file', async () => { const path = setup(); const submission = await createSubmission(); const fileInfo = createMulterData(path[0]); const file = await service.create( fileInfo, submission as unknown as FileSubmission, ); await loadBuffers(file); expect(file.file).toBeDefined(); const path2 = setup(); const updateFileInfo = createMulterData2(path2[1]); const updatedFile = await service.update(updateFileInfo, file.id, false); await loadBuffers(updatedFile); expect(updatedFile.file).toBeDefined(); expect(updatedFile.thumbnail).toBeDefined(); expect(updatedFile.fileName).toBe(updateFileInfo.filename); expect(updatedFile.size).toBe(updateFileInfo.size); expect(updatedFile.hasThumbnail).toBe(true); expect(updatedFile.hasCustomThumbnail).toBe(false); expect(updatedFile.height).toBe(600); expect(updatedFile.width).toBe(600); expect(updatedFile.file.size).toBe(updateFileInfo.size); expect(updatedFile.file.height).toBe(600); expect(updatedFile.file.width).toBe(600); expect(updatedFile.file.submissionFileId).toEqual(file.id); expect(updatedFile.file.mimeType).toEqual(updateFileInfo.mimetype); expect(updatedFile.file.buffer).toEqual(testFile2); }); it('should cleanup entities on transaction failure', async () => { const path = setup(); const submission = await createSubmission(); const fileInfo = createMulterData(path[0]); // Get initial count of entities const initialFiles = await new PostyBirbDatabase( 'SubmissionFileSchema', ).findAll(); const initialBuffers = await fileBufferRepository.findAll(); const initialFileCount = initialFiles.length; const initialBufferCount = initialBuffers.length; // Mock a method to throw an error partway through creation const createFileService = module.get(CreateFileService); const originalMethod = createFileService.createFileBufferEntity; let callCount = 0; jest .spyOn(createFileService, 'createFileBufferEntity') .mockImplementation(async (...args) => { callCount++; // Fail on the second buffer creation (thumbnail) if (callCount === 2) { throw new Error('Simulated error during buffer creation'); } return originalMethod.apply(createFileService, args); }); // Attempt to create a file, which should fail and trigger cleanup await expect( service.create(fileInfo, submission as unknown as FileSubmission), ).rejects.toThrow('Simulated error during buffer creation'); // Verify that entities were cleaned up const finalFiles = await new PostyBirbDatabase( 'SubmissionFileSchema', ).findAll(); const finalBuffers = await fileBufferRepository.findAll(); expect(finalFiles.length).toBe(initialFileCount); expect(finalBuffers.length).toBe(initialBufferCount); // Restore the original method jest.restoreAllMocks(); }); }); ================================================ FILE: apps/client-server/src/app/file/file.service.ts ================================================ /* eslint-disable no-param-reassign */ import { BadRequestException, Injectable } from '@nestjs/common'; import { read } from '@postybirb/fs'; import { Logger } from '@postybirb/logger'; import { EntityId, FileSubmission, SubmissionFileMetadata, } from '@postybirb/types'; import type { queueAsPromised } from 'fastq'; import fastq from 'fastq'; import { readFile } from 'fs/promises'; import { cpus } from 'os'; import { SubmissionFile } from '../drizzle/models'; import { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database'; import { ReorderSubmissionFilesDto } from '../submission/dtos/reorder-submission-files.dto'; import { UpdateAltFileDto } from '../submission/dtos/update-alt-file.dto'; import { MulterFileInfo, TaskOrigin } from './models/multer-file-info'; import { CreateTask, Task, UpdateTask } from './models/task'; import { TaskType } from './models/task-type.enum'; import { CreateFileService } from './services/create-file.service'; import { UpdateFileService } from './services/update-file.service'; /** * Service that handles storing file data into database. * @todo text encoding parsing and file name conversion (no periods) */ @Injectable() export class FileService { private readonly logger = Logger(); private readonly queue: queueAsPromised = fastq.promise< this, Task >(this, this.doTask, Math.min(cpus().length, 5)); private readonly fileBufferRepository = new PostyBirbDatabase( 'FileBufferSchema', ); private readonly fileRepository = new PostyBirbDatabase( 'SubmissionFileSchema', ); constructor( private readonly createFileService: CreateFileService, private readonly updateFileService: UpdateFileService, ) {} /** * Deletes a file. * * @param {EntityId} id * @return {*} */ public async remove(id: EntityId) { this.logger.info(id, `Removing entity '${id}'`); return this.fileRepository.deleteById([id]); } /** * Queues a file to create a database record. * * @param {MulterFileInfo} file * @param {FileSubmission} submission * @return {*} {Promise} */ public async create( file: MulterFileInfo, submission: FileSubmission, ): Promise { return this.queue.push({ type: TaskType.CREATE, file, submission }); } /** * Queues a file to update. * * @param {MulterFileInfo} file * @param {EntityId} submissionFileId * @param {boolean} forThumbnail * @return {*} {Promise} */ public async update( file: MulterFileInfo, submissionFileId: EntityId, forThumbnail: boolean, ): Promise { return this.queue.push({ type: TaskType.UPDATE, file, submissionFileId, target: forThumbnail ? 'thumbnail' : undefined, }); } private async doTask(task: Task): Promise { task.file.originalname = this.sanitizeFilename(task.file.originalname); const buf: Buffer = await this.getFile(task.file.path, task.file.origin); this.logger.withMetadata(task).info('Reading File'); switch (task.type) { case TaskType.CREATE: // eslint-disable-next-line no-case-declarations const ct = task as CreateTask; return this.createFileService.create(ct.file, ct.submission, buf); case TaskType.UPDATE: // eslint-disable-next-line no-case-declarations const ut = task as UpdateTask; return this.updateFileService.update( ut.file, ut.submissionFileId, buf, ut.target, ); default: throw new BadRequestException(`Unknown TaskType '${task.type}'`); } } /** * Removes periods from the filename. * There is some website that doesn't like them. * * @param {string} filename * @return {*} {string} */ private sanitizeFilename(filename: string): string { const nameParts = filename.split('.'); const ext = nameParts.pop(); return `${nameParts.join('_')}.${ext}`; } private async getFile(path: string, taskOrigin: TaskOrigin): Promise { switch (taskOrigin) { case 'directory-watcher': return readFile(path); default: // Approved location return read(path); } } /** * Returns file by Id. * * @param {EntityId} id */ public async findFile(id: EntityId): Promise { return this.fileRepository.findById(id, { failOnMissing: true }); } /** * Gets the size of an alt text file without loading the buffer. * @param {EntityId} id */ async getAltFileSize(id: EntityId): Promise { const altFile = await this.fileBufferRepository.findById(id, { failOnMissing: false, }); return altFile?.size ?? 0; } /** * Gets the raw text of an alt text file. * @param {EntityId} id */ async getAltText(id: EntityId): Promise { const altFile = await this.fileBufferRepository.findById(id, { failOnMissing: true, }); if (altFile.size) { return altFile.buffer.toString(); } return ''; } /** * Updates the raw text of an alt text file. * @param {EntityId} id * @param {UpdateAltFileDto} update */ async updateAltText(id: EntityId, update: UpdateAltFileDto) { const buffer = Buffer.from(update.text ?? ''); return this.fileBufferRepository.update(id, { buffer, mimeType: 'text/plain', size: buffer.length, }); } async updateMetadata(id: string, update: SubmissionFileMetadata) { const file = await this.findFile(id); const merged = { ...file.metadata, ...update }; await this.fileRepository.update(id, { metadata: merged }); } async reorderFiles(update: ReorderSubmissionFilesDto) { await Promise.all( Object.entries(update.order).map(([id, order]) => this.fileRepository.update(id, { order }), ), ); } } ================================================ FILE: apps/client-server/src/app/file/models/multer-file-info.ts ================================================ /** * Matches a multer file info object. * TypeScript is not importing multer types correctly. * * @interface MulterFileInfo */ export interface MulterFileInfo { fieldname: string; originalname: string; encoding: string; mimetype: string; size: number; destination: string; filename: string; path: string; buffer?: Buffer; /** * Internal origin, empty when external. * @type {TaskOrigin} */ origin?: TaskOrigin; } export type TaskOrigin = 'directory-watcher'; ================================================ FILE: apps/client-server/src/app/file/models/task-type.enum.ts ================================================ /** * Defines the requested TaskType */ export enum TaskType { CREATE = 'CREATE', // Creating a new file entity UPDATE = 'UPDATE', // Updating a file entity } ================================================ FILE: apps/client-server/src/app/file/models/task.ts ================================================ import { EntityId, FileSubmission } from '@postybirb/types'; import { MulterFileInfo } from './multer-file-info'; import { TaskType } from './task-type.enum'; // Task Type export type Task = CreateTask | UpdateTask; // Defines CreateTask Params export type CreateTask = { type: TaskType; /** * File for use. * @type {MulterFileInfo} */ file: MulterFileInfo; /** * Submission the entities will be attached to. * @type {FileSubmission} */ submission: FileSubmission; }; // Defines UpdateTask Params export type UpdateTask = { type: TaskType; /** * Updating file. * * @type {MulterFileInfo} */ file: MulterFileInfo; /** * SubmissionFile being updated. * @type {EntityId} */ submissionFileId: EntityId; /** * The target type being updates (primary on empty). * @type {string} */ target?: 'thumbnail'; }; ================================================ FILE: apps/client-server/src/app/file/services/create-file.service.ts ================================================ import * as rtf from '@iarna/rtf-to-html'; import { Injectable } from '@nestjs/common'; import { Insert, Select } from '@postybirb/database'; import { removeFile } from '@postybirb/fs'; import { Logger } from '@postybirb/logger'; import { DefaultSubmissionFileMetadata, FileSubmission, FileType, IFileBuffer, } from '@postybirb/types'; import { getFileType } from '@postybirb/utils/file-type'; import { eq } from 'drizzle-orm'; import { async as hash } from 'hasha'; import { htmlToText } from 'html-to-text'; import * as mammoth from 'mammoth'; import { parse } from 'path'; import { promisify } from 'util'; import { v4 as uuid } from 'uuid'; import { FileBuffer, fromDatabaseRecord, SubmissionFile, } from '../../drizzle/models'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { TransactionContext, withTransactionContext, } from '../../drizzle/transaction-context'; import { SharpInstanceManager } from '../../image-processing/sharp-instance-manager'; import { MulterFileInfo } from '../models/multer-file-info'; import { ImageUtil } from '../utils/image.util'; /** * A Service that defines operations for creating a SubmissionFile. * @class CreateFileService */ @Injectable() export class CreateFileService { private readonly logger = Logger(); private readonly fileBufferRepository = new PostyBirbDatabase( 'FileBufferSchema', ); private readonly fileRepository = new PostyBirbDatabase( 'SubmissionFileSchema', ); constructor( private readonly sharpInstanceManager: SharpInstanceManager, ) {} /** * Creates file entity and stores it. * @todo extra data (image resize per website) * @todo figure out what to do about non-image * * @param {MulterFileInfo} file * @param {MulterFileInfo} submission * @param {Buffer} buf * @return {*} {Promise} */ public async create( file: MulterFileInfo, submission: FileSubmission, buf: Buffer, ): Promise { try { this.logger.withMetadata(file).info(`Creating SubmissionFile entity`); const newSubmission = await withTransactionContext( this.fileRepository.db, async (ctx) => { let entity = await this.createSubmissionFile( ctx, file, submission, buf, ); if (ImageUtil.isImage(file.mimetype, true)) { this.logger.info('[Mutation] Populating as Image'); entity = await this.populateAsImageFile(ctx, entity, file, buf); } if (getFileType(file.originalname) === FileType.TEXT) { await this.createSubmissionTextAltFile(ctx, entity, file, buf); } const primaryFile = await this.createFileBufferEntity( ctx, entity, buf, ); await ctx .getDb() .update(this.fileRepository.schemaEntity) .set({ primaryFileId: primaryFile.id }) .where(eq(this.fileRepository.schemaEntity.id, entity.id)); this.logger .withMetadata({ id: entity.id }) .info('SubmissionFile Created'); return entity; }, ); return await this.fileRepository.findById(newSubmission.id); } catch (err) { this.logger.error(err.message, err.stack); throw err; } finally { if (!file.origin) { removeFile(file.path); } } } /** * Populates an alt file containing text data extracted from a file. * Currently supports docx, rtf, and plaintext. * * @param {SubmissionFile} entity * @param {MulterFileInfo} file * @param {Buffer} buf */ async createSubmissionTextAltFile( ctx: TransactionContext, entity: SubmissionFile, file: MulterFileInfo, buf: Buffer, ) { // Default to empty string - all TEXT files get an alt file let altText = ''; if ( file.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || file.mimetype === 'application/msword' || file.originalname.endsWith('.docx') || file.originalname.endsWith('.doc') ) { this.logger.info('[Mutation] Creating Alt File for Text Document: DOCX'); altText = (await mammoth.extractRawText({ buffer: buf })).value; } else if ( file.mimetype === 'application/rtf' || file.originalname.endsWith('.rtf') ) { this.logger.info('[Mutation] Creating Alt File for Text Document: RTF'); const promisifiedRtf = promisify(rtf.fromString); const rtfHtml = await promisifiedRtf(buf.toString(), { template(_, __, content: string) { return content; }, }); altText = htmlToText(rtfHtml, { wordwrap: false }); } else if ( file.mimetype === 'text/plain' || file.originalname.endsWith('.txt') ) { this.logger.info('[Mutation] Creating Alt File for Text Document: TXT'); altText = buf.toString(); } else { this.logger.info( `[Mutation] Creating empty Alt File for unsupported text format: ${file.mimetype}`, ); } const prettifiedBuf = Buffer.from(altText ?? ''); const altFile = await this.createFileBufferEntity( ctx, entity, prettifiedBuf, { mimeType: 'text/plain', fileName: `${entity.fileName}.txt`, }, ); await ctx .getDb() .update(this.fileRepository.schemaEntity) .set({ altFileId: altFile.id, hasAltFile: true, }) .where(eq(this.fileRepository.schemaEntity.id, entity.id)); this.logger.withMetadata({ id: altFile.id }).info('Alt File Created'); } /** * Creates a SubmissionFile with pre-populated fields. * * @param {MulterFileInfo} file * @param {FileSubmission} submission * @param {Buffer} buf * @return {*} {Promise} */ private async createSubmissionFile( ctx: TransactionContext, file: MulterFileInfo, submission: FileSubmission, buf: Buffer, ): Promise { const { mimetype: mimeType, originalname, size } = file; const submissionFile: Insert<'SubmissionFileSchema'> = { submissionId: submission.id, mimeType, fileName: originalname, size, hash: await hash(buf, { algorithm: 'sha256' }), width: 0, height: 0, hasThumbnail: false, metadata: DefaultSubmissionFileMetadata(), order: Date.now(), }; const sf = fromDatabaseRecord( SubmissionFile, await ctx .getDb() .insert(this.fileRepository.schemaEntity) .values(submissionFile) .returning(), ); const entity = sf[0]; ctx.track('SubmissionFileSchema', entity.id); return entity; } /** * Populates SubmissionFile with Image specific fields. * Width, Height, Thumbnail. * * @param {SubmissionFile} entity * @param {MulterFileInfo} file * @param {Buffer} buf * @return {*} {Promise} */ private async populateAsImageFile( ctx: TransactionContext, entity: SubmissionFile, file: MulterFileInfo, buf: Buffer, ): Promise { const meta = await this.sharpInstanceManager.getMetadata(buf); const thumbnail = await this.createFileThumbnail( ctx, entity, file, buf, ); const update: Select = { width: meta.width ?? 0, height: meta.height ?? 0, hasThumbnail: true, thumbnailId: thumbnail.id, metadata: { ...entity.metadata, dimensions: { default: { width: meta.width ?? 0, height: meta.height ?? 0, }, }, }, }; return fromDatabaseRecord( SubmissionFile, await ctx .getDb() .update(this.fileRepository.schemaEntity) .set(update) .where(eq(this.fileRepository.schemaEntity.id, entity.id)) .returning(), )[0]; } /** * Returns a thumbnail entity for a file. * * @param {SubmissionFile} fileEntity * @param {MulterFileInfo} file * @param {Buffer} imageBuffer - The source image buffer * @return {*} {Promise} */ public async createFileThumbnail( ctx: TransactionContext, fileEntity: SubmissionFile, file: MulterFileInfo, imageBuffer: Buffer, ): Promise { const { buffer: thumbnailBuf, height, width, mimeType: thumbnailMimeType, } = await this.generateThumbnail( imageBuffer, file.mimetype, ); // Remove existing extension and add the appropriate thumbnail extension const fileNameWithoutExt = parse(fileEntity.fileName).name; const thumbnailExt = thumbnailMimeType === 'image/jpeg' ? 'jpg' : 'png'; return this.createFileBufferEntity(ctx, fileEntity, thumbnailBuf, { height, width, mimeType: thumbnailMimeType, fileName: `thumbnail_${fileNameWithoutExt}.${thumbnailExt}`, }); } /** * Generates a thumbnail for display at specific dimension requirements. * Delegates to the sharp worker pool for crash isolation. * * @param {Buffer} imageBuffer - The source image buffer * @param {string} sourceMimeType - The mimetype of the source image * @param {number} [preferredDimension=400] - The preferred thumbnail dimension * @return {*} {Promise<{ width: number; height: number; buffer: Buffer; mimeType: string }>} */ public async generateThumbnail( imageBuffer: Buffer, sourceMimeType: string, preferredDimension = 400, ): Promise<{ width: number; height: number; buffer: Buffer; mimeType: string; }> { const result = await this.sharpInstanceManager.generateThumbnail( imageBuffer, sourceMimeType, 'thumbnail', preferredDimension, ); return { buffer: result.buffer, height: result.height, width: result.width, mimeType: result.mimeType, }; } /** * Creates a file buffer entity for storing blob data of a file. * * @param {File} fileEntity * @param {Buffer} buf * @param {string} type - thumbnail/alt/primary * @return {*} {IFileBuffer} */ public async createFileBufferEntity( ctx: TransactionContext, fileEntity: SubmissionFile, buf: Buffer, opts: Select<'FileBufferSchema'> = {} as Select<'FileBufferSchema'>, ): Promise { const { mimeType, height, width, fileName } = fileEntity; const data: Insert<'FileBufferSchema'> = { id: uuid(), buffer: buf, submissionFileId: fileEntity.id, height, width, fileName, mimeType, size: buf.length, ...opts, }; const result = fromDatabaseRecord( FileBuffer, await ctx .getDb() .insert(this.fileBufferRepository.schemaEntity) .values(data) .returning(), )[0]; ctx.track('FileBufferSchema', result.id); return result; } } ================================================ FILE: apps/client-server/src/app/file/services/update-file.service.ts ================================================ /* eslint-disable no-param-reassign */ import * as rtf from '@iarna/rtf-to-html'; import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { EntityId, FileType } from '@postybirb/types'; import { getFileType } from '@postybirb/utils/file-type'; import { eq } from 'drizzle-orm'; import { async as hash } from 'hasha'; import { htmlToText } from 'html-to-text'; import * as mammoth from 'mammoth'; import { parse } from 'path'; import { promisify } from 'util'; import { SubmissionFile } from '../../drizzle/models'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { TransactionContext, withTransactionContext, } from '../../drizzle/transaction-context'; import { SharpInstanceManager } from '../../image-processing/sharp-instance-manager'; import { MulterFileInfo } from '../models/multer-file-info'; import { ImageUtil } from '../utils/image.util'; import { CreateFileService } from './create-file.service'; /** * A Service for updating existing SubmissionFile entities. */ @Injectable() export class UpdateFileService { private readonly logger = Logger(); private readonly fileRepository = new PostyBirbDatabase( 'SubmissionFileSchema', ); private readonly fileBufferRepository = new PostyBirbDatabase( 'FileBufferSchema', ); constructor( private readonly createFileService: CreateFileService, private readonly sharpInstanceManager: SharpInstanceManager, ) {} /** * Creates file entity and stores it. * * @param {MulterFileInfo} file * @param {MulterFileInfo} submission * @param {Buffer} buf * @param {string} target * @return {*} {Promise} */ public async update( file: MulterFileInfo, submissionFileId: EntityId, buf: Buffer, target?: 'thumbnail', ): Promise { const submissionFile = await this.findFile(submissionFileId); await withTransactionContext(this.fileRepository.db, async (ctx) => { if (target === 'thumbnail') { await this.replaceFileThumbnail(ctx, submissionFile, file, buf); } else { await this.replacePrimaryFile(ctx, submissionFile, file, buf); } }); // Notify subscribers so SubmissionService emits a websocket update this.fileRepository.forceNotify([submissionFileId], 'update'); this.fileBufferRepository.forceNotify([submissionFileId], 'update'); // return the latest return this.findFile(submissionFileId); } private async replaceFileThumbnail( ctx: TransactionContext, submissionFile: SubmissionFile, file: MulterFileInfo, buf: Buffer, ) { const thumbnailDetails = await this.getImageDetails(file, buf); let { thumbnailId } = submissionFile; if (!thumbnailId) { // Create a new thumbnail buffer entity const thumbnail = await this.createFileService.createFileBufferEntity( ctx, submissionFile, thumbnailDetails.buffer, { width: thumbnailDetails.width, height: thumbnailDetails.height, mimeType: file.mimetype, }, ); thumbnailId = thumbnail.id; } else { // Update existing thumbnail buffer await ctx .getDb() .update(this.fileBufferRepository.schemaEntity) .set({ buffer: thumbnailDetails.buffer, size: thumbnailDetails.buffer.length, mimeType: file.mimetype, width: thumbnailDetails.width, height: thumbnailDetails.height, }) .where(eq(this.fileBufferRepository.schemaEntity.id, thumbnailId)); } // Recompute hash from thumbnail buffer so the frontend cache-buster updates const thumbnailHash = await hash(thumbnailDetails.buffer, { algorithm: 'sha256', }); await ctx .getDb() .update(this.fileRepository.schemaEntity) .set({ thumbnailId, hasCustomThumbnail: true, hasThumbnail: true, hash: thumbnailHash, }) .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id)); } async replacePrimaryFile( ctx: TransactionContext, submissionFile: SubmissionFile, file: MulterFileInfo, buf: Buffer, ) { return this.updateFileEntity(ctx, submissionFile, file, buf); } private async updateFileEntity( ctx: TransactionContext, submissionFile: SubmissionFile, file: MulterFileInfo, buf: Buffer, ) { const fileHash = await hash(buf, { algorithm: 'sha256' }); // Only need to replace when unique file is given if (submissionFile.hash !== fileHash) { const fileType = getFileType(file.filename); if (fileType === FileType.IMAGE) { await this.updateImageFileProps(ctx, submissionFile, file, buf); } // Update submission file entity await ctx .getDb() .update(this.fileRepository.schemaEntity) .set({ hash: fileHash, size: buf.length, fileName: file.filename, mimeType: file.mimetype, }) .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id)); // Just to get the latest data // Duplicate props to primary file await ctx .getDb() .update(this.fileBufferRepository.schemaEntity) .set({ buffer: buf, size: buf.length, fileName: file.filename, mimeType: file.mimetype, }) .where( eq( this.fileBufferRepository.schemaEntity.id, submissionFile.primaryFileId, ), ); if ( getFileType(file.originalname) === FileType.TEXT && submissionFile.hasAltFile ) { const altFileText = (await this.repopulateTextFile(file, buf)) || submissionFile?.altFile?.buffer; if (altFileText) { await ctx .getDb() .update(this.fileBufferRepository.schemaEntity) .set({ buffer: altFileText, size: altFileText.length, }) .where( eq( this.fileBufferRepository.schemaEntity.id, submissionFile.altFile.id, ), ); await ctx .getDb() .update(this.fileRepository.schemaEntity) .set({ hasAltFile: true, }) .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id)); } } } } async repopulateTextFile( file: MulterFileInfo, buf: Buffer, ): Promise { let altText: string; if ( file.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || file.originalname.endsWith('.docx') ) { this.logger.info('[Mutation] Updating Alt File for Text Document: DOCX'); altText = (await mammoth.extractRawText({ buffer: buf })).value; } if ( file.mimetype === 'application/rtf' || file.originalname.endsWith('.rtf') ) { this.logger.info('[Mutation] Updating Alt File for Text Document: RTF'); const promisifiedRtf = promisify(rtf.fromString); const rtfHtml = await promisifiedRtf(buf.toString(), { template(_, __, content: string) { return content; }, }); altText = htmlToText(rtfHtml, { wordwrap: false }); } if (file.mimetype === 'text/plain' || file.originalname.endsWith('.txt')) { this.logger.info('[Mutation] Updating Alt File for Text Document: TXT'); altText = buf.toString(); } return altText ? Buffer.from(altText) : null; } private async updateImageFileProps( ctx: TransactionContext, submissionFile: SubmissionFile, file: MulterFileInfo, buf: Buffer, ) { const { width, height } = await this.getImageDetails( file, buf, ); await ctx .getDb() .update(this.fileRepository.schemaEntity) .set({ width, height, }) .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id)); await ctx .getDb() .update(this.fileBufferRepository.schemaEntity) .set({ width, height, }) .where( eq( this.fileBufferRepository.schemaEntity.id, submissionFile.primaryFileId, ), ); // Reset metadata dimensions so they don't reference the old file size const updatedMetadata = { ...submissionFile.metadata }; if (updatedMetadata.dimensions) { updatedMetadata.dimensions = { ...updatedMetadata.dimensions, default: { width, height }, }; } await ctx .getDb() .update(this.fileRepository.schemaEntity) .set({ metadata: updatedMetadata }) .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id)); if (submissionFile.hasThumbnail && !submissionFile.hasCustomThumbnail) { // Regenerate auto-thumbnail; const { buffer: thumbnailBuf, width: thumbnailWidth, height: thumbnailHeight, mimeType: thumbnailMimeType, } = await this.createFileService.generateThumbnail( buf, file.mimetype, ); const fileNameWithoutExt = parse(file.filename).name; const thumbnailExt = thumbnailMimeType === 'image/jpeg' ? 'jpg' : 'png'; await ctx .getDb() .update(this.fileBufferRepository.schemaEntity) .set({ buffer: thumbnailBuf, width: thumbnailWidth, height: thumbnailHeight, size: thumbnailBuf.length, mimeType: thumbnailMimeType, fileName: `thumbnail_${fileNameWithoutExt}.${thumbnailExt}`, }) .where( eq( this.fileBufferRepository.schemaEntity.id, submissionFile.thumbnailId, ), ); } } /** * Details of a multer file. * * @param {MulterFileInfo} file */ private async getImageDetails(file: MulterFileInfo, buf: Buffer) { if (ImageUtil.isImage(file.mimetype, false)) { const { height, width } = await this.sharpInstanceManager.getMetadata(buf); return { buffer: buf, width, height }; } throw new BadRequestException('File is not an image'); } /** * Returns file by Id. * * @param {EntityId} id */ private async findFile(id: EntityId): Promise { try { const entity = await this.fileRepository.findOne({ where: (f, { eq: equals }) => equals(f.id, id), // !bug - https://github.com/drizzle-team/drizzle-orm/issues/3497 // with: { // thumbnail: true, // primaryFile: true, // altFile: true, // }, }); return entity; } catch (e) { this.logger.error(e.message, e.stack); throw new NotFoundException(id); } } } ================================================ FILE: apps/client-server/src/app/file/utils/image.util.ts ================================================ /** * Utility class for image-related checks. * * NOTE: Sharp image processing has been moved to SharpInstanceManager * which runs sharp in isolated worker threads for crash protection. * The load() and getMetadata() methods have been removed. * Use SharpInstanceManager.getMetadata() or SharpInstanceManager.resizeForPost() instead. */ export class ImageUtil { static isImage(mimetype: string, includeGIF = false): boolean { if (includeGIF && mimetype === 'image/gif') { return true; } return mimetype.startsWith('image/') && mimetype !== 'image/gif'; } } ================================================ FILE: apps/client-server/src/app/file-converter/converters/file-converter.ts ================================================ import { IFileBuffer } from '@postybirb/types'; export interface IFileConverter { /** * Determines if the file can be converted to any of the allowable output mime types. * * @param {IFileBuffer} file * @param {string[]} allowableOutputMimeTypes * @return {*} {boolean} */ canConvert(file: IFileBuffer, allowableOutputMimeTypes: string[]): boolean; /** * Converts the file to one of the allowable output mime types. * * @param {IFileBuffer} file * @param {string[]} allowableOutputMimeTypes * @return {*} {Promise} */ convert( file: IFileBuffer, allowableOutputMimeTypes: string[], ): Promise; } ================================================ FILE: apps/client-server/src/app/file-converter/converters/text-file-converter.ts ================================================ import { IFileBuffer } from '@postybirb/types'; import { htmlToText } from 'html-to-text'; import { TurndownService } from 'turndown'; import { IFileConverter } from './file-converter'; const supportedInputMimeTypes = ['text/html', 'text/plain'] as const; const supportedOutputMimeTypes = [ 'text/plain', 'text/html', 'text/markdown', ] as const; type SupportedInputMimeTypes = (typeof supportedInputMimeTypes)[number]; type SupportedOutputMimeTypes = (typeof supportedOutputMimeTypes)[number]; type ConversionMap = { [inputMimeType in SupportedInputMimeTypes]: { [outputMimeType in SupportedOutputMimeTypes]: ( file: IFileBuffer, ) => Promise; }; }; type ConversionWeights = { [outputMimeType in SupportedOutputMimeTypes]: number; }; /** * A class that converts text files to other text formats. * Largely for use when converting AltFiles (text/plain or text/html) to other desirable formats. * @class TextFileConverter * @implements {IFileConverter} */ export class TextFileConverter implements IFileConverter { private passThrough = async (file: IFileBuffer): Promise => ({ ...file, }); private convertHtmlToPlaintext = async ( file: IFileBuffer, ): Promise => { const text = htmlToText(file.buffer.toString(), { wordwrap: 120, }); return this.toMergedBuffer(file, text, 'text/plain'); }; private convertHtmlToMarkdown = async ( file: IFileBuffer, ): Promise => { const turndownService = new TurndownService(); const markdown = turndownService.turndown(file.buffer.toString()); return this.toMergedBuffer(file, markdown, 'text/markdown'); }; /** * Converts plain text to HTML by wrapping lines in

tags. */ private convertPlaintextToHtml = async ( file: IFileBuffer, ): Promise => { const lines = file.buffer.toString().split(/\n/); const html = lines .map((line) => `

${line || '
'}

`) .join('\n'); return this.toMergedBuffer(file, html, 'text/html'); }; /** * Plain text is valid markdown, so this is a passthrough with mime type change. */ private convertPlaintextToMarkdown = async ( file: IFileBuffer, ): Promise => this.toMergedBuffer(file, file.buffer.toString(), 'text/markdown'); private readonly supportConversionMappers: ConversionMap = { 'text/html': { 'text/html': this.passThrough, 'text/plain': this.convertHtmlToPlaintext, 'text/markdown': this.convertHtmlToMarkdown, }, 'text/plain': { 'text/plain': this.passThrough, 'text/html': this.convertPlaintextToHtml, 'text/markdown': this.convertPlaintextToMarkdown, }, }; /** * Defines the preference of conversion, trying to convert to the most preferred format first. */ private readonly conversionWeights: ConversionWeights = { 'text/plain': Number.MAX_SAFE_INTEGER, 'text/html': 1, 'text/markdown': 2, }; canConvert(file: IFileBuffer, allowableOutputMimeTypes: string[]): boolean { return ( supportedInputMimeTypes.includes( file.mimeType as SupportedInputMimeTypes, ) && supportedOutputMimeTypes.some((m) => allowableOutputMimeTypes.includes(m)) ); } async convert( file: IFileBuffer, allowableOutputMimeTypes: string[], ): Promise { const conversionMap = this.supportConversionMappers[file.mimeType as SupportedInputMimeTypes]; const sortedOutputMimeTypes = allowableOutputMimeTypes .filter((mimeType) => mimeType in conversionMap) .sort( (a, b) => this.conversionWeights[a as SupportedOutputMimeTypes] - this.conversionWeights[b as SupportedOutputMimeTypes], ); for (const outputMimeType of sortedOutputMimeTypes) { const conversionFunction = conversionMap[outputMimeType as SupportedOutputMimeTypes]; if (conversionFunction) { return conversionFunction(file); } } throw new Error( `Cannot convert file ${file.fileName} with mime type: ${file.mimeType}`, ); } private toMergedBuffer( fb: IFileBuffer, str: string, mimeType: string, ): IFileBuffer { return { ...fb, buffer: Buffer.from(str), mimeType, }; } } ================================================ FILE: apps/client-server/src/app/file-converter/file-converter.module.ts ================================================ import { Module } from '@nestjs/common'; import { FileConverterService } from './file-converter.service'; @Module({ providers: [FileConverterService], exports: [FileConverterService], }) export class FileConverterModule {} ================================================ FILE: apps/client-server/src/app/file-converter/file-converter.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { FileConverterService } from './file-converter.service'; describe('FileConverterService', () => { let service: FileConverterService; beforeEach(async () => { clearDatabase(); const module: TestingModule = await Test.createTestingModule({ providers: [FileConverterService], }).compile(); service = module.get(FileConverterService); }); it('should be defined', () => { expect(service).toBeDefined(); }); }); ================================================ FILE: apps/client-server/src/app/file-converter/file-converter.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { IFileBuffer } from '@postybirb/types'; import { IFileConverter } from './converters/file-converter'; import { TextFileConverter } from './converters/text-file-converter'; @Injectable() export class FileConverterService { private readonly converters: IFileConverter[] = [new TextFileConverter()]; public async convert( file: T, allowableOutputMimeTypes: string[], ): Promise { const converter = this.converters.find((c) => c.canConvert(file, allowableOutputMimeTypes), ); if (!converter) { throw new Error('No converter found for file'); } return converter.convert(file, allowableOutputMimeTypes); } public async canConvert( mimeType: string, allowableOutputMimeTypes: string[], ): Promise { return this.converters.some((c) => c.canConvert({ mimeType } as IFileBuffer, allowableOutputMimeTypes), ); } } ================================================ FILE: apps/client-server/src/app/form-generator/dtos/form-generation-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { AccountId, IFormGenerationRequestDto, SubmissionType, } from '@postybirb/types'; import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; export class FormGenerationRequestDto implements IFormGenerationRequestDto { @ApiProperty() @IsString() accountId: AccountId; @ApiProperty({ enum: SubmissionType }) @IsEnum(SubmissionType) type: SubmissionType; @ApiProperty() @IsOptional() @IsBoolean() isMultiSubmission?: boolean; } ================================================ FILE: apps/client-server/src/app/form-generator/form-generator.controller.ts ================================================ import { Body, Controller, Post } from '@nestjs/common'; import { ApiResponse, ApiTags } from '@nestjs/swagger'; import { NULL_ACCOUNT_ID } from '@postybirb/types'; import { FormGenerationRequestDto } from './dtos/form-generation-request.dto'; import { FormGeneratorService } from './form-generator.service'; @ApiTags('form-generator') @Controller('form-generator') export class FormGeneratorController { constructor(private readonly service: FormGeneratorService) {} @Post() @ApiResponse({ status: 200, description: 'Returns the generated form with default', }) @ApiResponse({ status: 404, description: 'Website instance not found.' }) @ApiResponse({ status: 500, description: 'An error occurred while performing operation.', }) getFormForWebsite(@Body() request: FormGenerationRequestDto) { return request.accountId === NULL_ACCOUNT_ID ? this.service.getDefaultForm(request.type, request.isMultiSubmission) : this.service.generateForm(request); } } ================================================ FILE: apps/client-server/src/app/form-generator/form-generator.module.ts ================================================ import { Module } from '@nestjs/common'; import { AccountModule } from '../account/account.module'; import { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module'; import { WebsitesModule } from '../websites/websites.module'; import { FormGeneratorController } from './form-generator.controller'; import { FormGeneratorService } from './form-generator.service'; @Module({ imports: [WebsitesModule, UserSpecifiedWebsiteOptionsModule, AccountModule], providers: [FormGeneratorService], controllers: [FormGeneratorController], exports: [FormGeneratorService], }) export class FormGeneratorModule {} ================================================ FILE: apps/client-server/src/app/form-generator/form-generator.service.spec.ts ================================================ import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { NullAccount, SubmissionRating, SubmissionType, } from '@postybirb/types'; import { AccountModule } from '../account/account.module'; import { AccountService } from '../account/account.service'; import { CreateUserSpecifiedWebsiteOptionsDto } from '../user-specified-website-options/dtos/create-user-specified-website-options.dto'; import { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module'; import { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service'; import { WebsitesModule } from '../websites/websites.module'; import { FormGeneratorService } from './form-generator.service'; describe('FormGeneratorService', () => { let service: FormGeneratorService; let userSpecifiedService: UserSpecifiedWebsiteOptionsService; let accountService: AccountService; let module: TestingModule; beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ imports: [ AccountModule, WebsitesModule, UserSpecifiedWebsiteOptionsModule, ], providers: [FormGeneratorService], }).compile(); service = module.get(FormGeneratorService); accountService = module.get(AccountService); userSpecifiedService = module.get( UserSpecifiedWebsiteOptionsService ); await accountService.onModuleInit(); }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should fail on missing account', async () => { await expect( service.generateForm({ accountId: 'fake', type: SubmissionType.MESSAGE }) ).rejects.toThrow(NotFoundException); }); it('should return user specific defaults', async () => { const userSpecifiedDto = new CreateUserSpecifiedWebsiteOptionsDto(); userSpecifiedDto.accountId = new NullAccount().id; userSpecifiedDto.type = SubmissionType.MESSAGE; userSpecifiedDto.options = { rating: SubmissionRating.ADULT }; await userSpecifiedService.create(userSpecifiedDto); const messageForm = await service.getDefaultForm(SubmissionType.MESSAGE); expect(messageForm).toMatchInlineSnapshot(` { "contentWarning": { "defaultValue": "", "formField": "input", "hidden": false, "label": "contentWarning", "order": 5, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "text", }, "description": { "defaultValue": { "description": { "content": [], "type": "doc", }, "overrideDefault": false, }, "descriptionType": "html", "formField": "description", "label": "description", "order": 4, "required": true, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "description", }, "rating": { "defaultValue": "ADULT", "formField": "rating", "label": "rating", "layout": "horizontal", "options": [ { "label": "General", "value": "GENERAL", }, { "label": "Mature", "value": "MATURE", }, { "label": "Adult", "value": "ADULT", }, { "label": "Extreme", "value": "EXTREME", }, ], "order": 1, "required": true, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "rating", }, "tags": { "defaultValue": { "overrideDefault": false, "tags": [], }, "formField": "tag", "label": "tags", "minTagLength": 1, "order": 3, "responsive": { "xs": 12, }, "section": "common", "spaceReplacer": "_", "span": 12, "type": "tag", }, "title": { "defaultValue": "", "expectedInDescription": false, "formField": "input", "label": "title", "order": 2, "required": true, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "title", }, } `); }); it('should return standard form', async () => { const messageForm = await service.getDefaultForm(SubmissionType.MESSAGE); expect(messageForm).toMatchInlineSnapshot(` { "contentWarning": { "defaultValue": "", "formField": "input", "hidden": false, "label": "contentWarning", "order": 5, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "text", }, "description": { "defaultValue": { "description": { "content": [], "type": "doc", }, "overrideDefault": false, }, "descriptionType": "html", "formField": "description", "label": "description", "order": 4, "required": true, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "description", }, "rating": { "defaultValue": "GENERAL", "formField": "rating", "label": "rating", "layout": "horizontal", "options": [ { "label": "General", "value": "GENERAL", }, { "label": "Mature", "value": "MATURE", }, { "label": "Adult", "value": "ADULT", }, { "label": "Extreme", "value": "EXTREME", }, ], "order": 1, "required": true, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "rating", }, "tags": { "defaultValue": { "overrideDefault": false, "tags": [], }, "formField": "tag", "label": "tags", "minTagLength": 1, "order": 3, "responsive": { "xs": 12, }, "section": "common", "spaceReplacer": "_", "span": 12, "type": "tag", }, "title": { "defaultValue": "", "expectedInDescription": false, "formField": "input", "label": "title", "order": 2, "required": true, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "title", }, } `); const fileForm = await service.getDefaultForm(SubmissionType.FILE); expect(fileForm).toMatchInlineSnapshot(` { "contentWarning": { "defaultValue": "", "formField": "input", "hidden": false, "label": "contentWarning", "order": 5, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "text", }, "description": { "defaultValue": { "description": { "content": [], "type": "doc", }, "overrideDefault": false, }, "descriptionType": "html", "formField": "description", "label": "description", "order": 4, "required": true, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "description", }, "rating": { "defaultValue": "GENERAL", "formField": "rating", "label": "rating", "layout": "horizontal", "options": [ { "label": "General", "value": "GENERAL", }, { "label": "Mature", "value": "MATURE", }, { "label": "Adult", "value": "ADULT", }, { "label": "Extreme", "value": "EXTREME", }, ], "order": 1, "required": true, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "rating", }, "tags": { "defaultValue": { "overrideDefault": false, "tags": [], }, "formField": "tag", "label": "tags", "minTagLength": 1, "order": 3, "responsive": { "xs": 12, }, "section": "common", "spaceReplacer": "_", "span": 12, "type": "tag", }, "title": { "defaultValue": "", "expectedInDescription": false, "formField": "input", "label": "title", "order": 2, "required": true, "responsive": { "xs": 12, }, "section": "common", "span": 12, "type": "title", }, } `); }); }); ================================================ FILE: apps/client-server/src/app/form-generator/form-generator.service.ts ================================================ import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { FormBuilderMetadata, formBuilder } from '@postybirb/form-builder'; import { AccountId, IWebsiteFormFields, NullAccount, SubmissionType, } from '@postybirb/types'; import { AccountService } from '../account/account.service'; import { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service'; import { DefaultWebsiteOptions } from '../websites/models/default-website-options'; import { isFileWebsite } from '../websites/models/website-modifiers/file-website'; import { isMessageWebsite } from '../websites/models/website-modifiers/message-website'; import { WebsiteRegistryService } from '../websites/website-registry.service'; import { FormGenerationRequestDto } from './dtos/form-generation-request.dto'; @Injectable() export class FormGeneratorService { constructor( private readonly websiteRegistryService: WebsiteRegistryService, private readonly userSpecifiedWebsiteOptionsService: UserSpecifiedWebsiteOptionsService, private readonly accountService: AccountService, ) {} /** * Generates the form properties for a submission option. * Form properties are used for form generation in UI. * * @param {FormGenerationRequestDto} request */ async generateForm( request: FormGenerationRequestDto, ): Promise { const account = await this.accountService.findById(request.accountId, { failOnMissing: true, }); // Get instance for creation const instance = await this.websiteRegistryService.findInstance(account); if (!instance) { throw new NotFoundException('Unable to find website instance'); } // Get data for inserting into form const data = instance.getFormProperties(); // Get form model let formModel: IWebsiteFormFields = null; if (request.type === SubmissionType.MESSAGE && isMessageWebsite(instance)) { formModel = instance.createMessageModel(); } if (request.type === SubmissionType.FILE && isFileWebsite(instance)) { formModel = instance.createFileModel(); } if (!formModel) { throw new BadRequestException( `Website instance does not support ${request.type}`, ); } const form = formBuilder(formModel, data); const formWithPopulatedDefaults = await this.populateUserDefaults( form, request.accountId, request.type, ); if (request.isMultiSubmission) { delete formWithPopulatedDefaults.title; // Having title here just causes confusion for multi this flow } return formWithPopulatedDefaults; } /** * Returns the default fields form. * @param {SubmissionType} type */ async getDefaultForm(type: SubmissionType, isMultiSubmission = false) { const form = await this.populateUserDefaults( formBuilder(new DefaultWebsiteOptions(), {}), new NullAccount().id, type, ); if (isMultiSubmission) { delete form.title; // Having title here just causes confusion for multi this flow } return form; } private async populateUserDefaults( form: FormBuilderMetadata, accountId: AccountId, type: SubmissionType, ): Promise { const userSpecifiedDefaults = await this.userSpecifiedWebsiteOptionsService.findByAccountAndSubmissionType( accountId, type, ); if (userSpecifiedDefaults) { Object.entries(userSpecifiedDefaults.options).forEach(([key, value]) => { const field = form[key]; if (field) { field.defaultValue = value ?? field.defaultValue; } }); } return form; } } ================================================ FILE: apps/client-server/src/app/image-processing/image-processing.module.ts ================================================ import { Global, Module } from '@nestjs/common'; import { SharpInstanceManager } from './sharp-instance-manager'; /** * Global module providing the SharpInstanceManager to all modules. * Sharp image processing is isolated in worker threads to protect * the main process from native libvips crashes. */ @Global() @Module({ providers: [SharpInstanceManager], exports: [SharpInstanceManager], }) export class ImageProcessingModule {} ================================================ FILE: apps/client-server/src/app/image-processing/index.ts ================================================ export { ImageProcessingModule } from './image-processing.module'; export { SharpInstanceManager } from './sharp-instance-manager'; export type { SharpWorkerInput, SharpWorkerResult } from './sharp-instance-manager'; ================================================ FILE: apps/client-server/src/app/image-processing/sharp-instance-manager.ts ================================================ import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { ImageResizeProps } from '@postybirb/types'; import { IsTestEnvironment } from '@postybirb/utils/electron'; import { existsSync } from 'fs'; import { cpus } from 'os'; import { join, resolve } from 'path'; import Piscina from 'piscina'; /** * Input sent to the sharp worker thread. */ export interface SharpWorkerInput { operation: 'resize' | 'metadata' | 'thumbnail' | 'healthcheck'; buffer: Buffer; resize?: ImageResizeProps; mimeType: string; fileName?: string; fileId?: string; fileWidth?: number; fileHeight?: number; thumbnailBuffer?: Buffer; thumbnailMimeType?: string; thumbnailPreferredDimension?: number; generateThumbnail?: boolean; } /** * Result returned from the sharp worker thread. */ export interface SharpWorkerResult { buffer?: Buffer; mimeType?: string; width?: number; height?: number; format?: string; fileName?: string; modified: boolean; thumbnailBuffer?: Buffer; thumbnailMimeType?: string; thumbnailWidth?: number; thumbnailHeight?: number; thumbnailFileName?: string; } /** * Manages a pool of worker threads that run sharp image processing. * * This isolates sharp/libvips native code from the main process so that * if libvips segfaults (e.g. after long idle periods), only the worker * dies — the main process survives and piscina spawns a replacement. * * @class SharpInstanceManager */ @Injectable() export class SharpInstanceManager implements OnModuleDestroy { private readonly logger = Logger(); private pool: Piscina | null = null; /** * In test mode, we call the worker function directly in-process to avoid * thread contention issues with the electron test runner. The sharp logic * is still fully exercised — only the threading is bypassed. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private workerFn: ((input: any) => Promise) | null = null; constructor() { const workerPath = this.resolveWorkerPath(); if (IsTestEnvironment()) { // In tests: call the worker function directly (no threads) // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-dynamic-require, global-require this.workerFn = require(workerPath); } else { // Production: use piscina for crash isolation const maxThreads = Math.min(cpus().length, 4); this.logger .withMetadata({ workerPath, maxThreads }) .info('Initializing sharp worker pool'); this.pool = new Piscina({ filename: workerPath, maxThreads, minThreads: 0, // Allow ALL workers to be reaped after idle idleTimeout: 60_000, // Kill idle workers after 60s }); } // Run health check asynchronously — don't block construction, // but log warnings if sharp is broken on this system. // The .catch() is a safety net to prevent unhandled rejection // if something unexpected escapes the try/catch inside runHealthCheck. this.runHealthCheck().catch((err) => { this.logger .withError(err) .error('Unexpected error during sharp health check'); }); } /** * Probe the worker with a trivial sharp operation to detect * missing native bindings, glibc issues, or sandbox restrictions * at startup rather than failing silently during posting. */ private async runHealthCheck(): Promise { try { const result = await this.processImage({ operation: 'healthcheck', buffer: Buffer.alloc(0), mimeType: '', }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const { diagnostics } = result as any; if (diagnostics) { this.logger .withMetadata(diagnostics) .info('Sharp health check passed'); } } catch (error) { this.logger .withError(error) .error( 'Sharp health check FAILED — image processing will not work. ' + 'This usually means native sharp bindings failed to load. ' + 'On Linux, ensure glibc >= 2.17 is installed. ' + 'On Snap/Flatpak, the sandbox may prevent loading native modules.', ); } } async onModuleDestroy() { if (this.pool) { this.logger.info('Destroying sharp worker pool'); try { await this.pool.destroy(); } catch { // pool.destroy() throws for in-flight tasks — safe to ignore } } } /** * Cached resolved path — avoids repeated filesystem probing. */ private static resolvedWorkerPath: string | null = null; /** * Resolve the path to the sharp-worker.js file. * Works in: * - Production build (webpack output): __dirname/assets/sharp-worker.js * - Development (nx serve): relative to source tree * - Test mode: also relative to source tree (for require()) */ private resolveWorkerPath(): string { if (SharpInstanceManager.resolvedWorkerPath) { return SharpInstanceManager.resolvedWorkerPath; } const candidates = [ // Production build: assets sit next to the bundled main.js join(__dirname, 'assets', 'sharp-worker.js'), // Source tree: __dirname = apps/client-server/src/app/image-processing resolve(__dirname, '..', '..', '..', 'assets', 'sharp-worker.js'), // cwd-based (nx serve, standalone) join( process.cwd(), 'apps', 'client-server', 'src', 'assets', 'sharp-worker.js', ), // cwd-based (jest may cd into apps/client-server) join(process.cwd(), 'src', 'assets', 'sharp-worker.js'), ]; for (const candidate of candidates) { if (existsSync(candidate)) { SharpInstanceManager.resolvedWorkerPath = candidate; return candidate; } } this.logger.warn( `Sharp worker not found in any candidate path. Checked: ${candidates.join(', ')}`, ); return candidates[0]; } /** * Process an image using the worker pool. * * Buffers are copied to the worker via structured clone — the caller's * original buffers are NOT detached and remain usable after the call. * This means peak memory for a single operation is roughly * 2× the input size (original + worker copy). */ async processImage(input: SharpWorkerInput): Promise { try { // In test mode, call the worker function directly (no threads) if (this.workerFn) { return (await this.workerFn(input)) as SharpWorkerResult; } const result = await this.pool.run(input); // Structured clone across worker threads converts Node.js Buffers // into plain Uint8Arrays. Re-wrap them so downstream consumers // (e.g. form-data) that rely on Buffer methods/stream semantics work. if (result.buffer && !Buffer.isBuffer(result.buffer)) { result.buffer = Buffer.from(result.buffer); } if (result.thumbnailBuffer && !Buffer.isBuffer(result.thumbnailBuffer)) { result.thumbnailBuffer = Buffer.from(result.thumbnailBuffer); } return result as SharpWorkerResult; } catch (error) { this.logger .withError(error) .error('Sharp worker error — worker may have crashed'); throw error; } } /** * Get metadata for an image buffer. * @param buffer - The image buffer * @returns width, height, format, mimeType */ async getMetadata(buffer: Buffer): Promise<{ width: number; height: number; format: string; mimeType: string; }> { const result = await this.processImage({ operation: 'metadata', buffer, mimeType: '', }); return { width: result.width ?? 0, height: result.height ?? 0, format: result.format ?? 'unknown', mimeType: result.mimeType ?? 'image/unknown', }; } /** * Generate a thumbnail from an image buffer. * Used by CreateFileService and UpdateFileService during file upload. */ async generateThumbnail( buffer: Buffer, mimeType: string, fileName: string, preferredDimension = 400, ): Promise<{ buffer: Buffer; width: number; height: number; mimeType: string; fileName: string; }> { const result = await this.processImage({ operation: 'thumbnail', buffer, mimeType, fileName, thumbnailPreferredDimension: preferredDimension, }); return { buffer: result.buffer ?? buffer, width: result.width ?? 0, height: result.height ?? 0, mimeType: result.mimeType ?? mimeType, fileName: result.fileName ?? fileName, }; } /** * Resize an image for posting. Handles format conversion, dimensional * resize, maxBytes scaling, and optional thumbnail generation. */ async resizeForPost(input: { buffer: Buffer; resize?: ImageResizeProps; mimeType: string; fileName: string; fileId: string; fileWidth: number; fileHeight: number; thumbnailBuffer?: Buffer; thumbnailMimeType?: string; generateThumbnail: boolean; thumbnailPreferredDimension?: number; }): Promise { return this.processImage({ operation: 'resize', buffer: input.buffer, resize: input.resize, mimeType: input.mimeType, fileName: input.fileName, fileId: input.fileId, fileWidth: input.fileWidth, fileHeight: input.fileHeight, thumbnailBuffer: input.thumbnailBuffer, thumbnailMimeType: input.thumbnailMimeType, generateThumbnail: input.generateThumbnail, thumbnailPreferredDimension: input.thumbnailPreferredDimension ?? 500, }); } /** * Get pool statistics for monitoring. */ getStats() { if (!this.pool) return null; return { completed: this.pool.completed, duration: this.pool.duration, utilization: this.pool.utilization, queueSize: this.pool.queueSize, }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-converter.ts ================================================ /* eslint-disable no-underscore-dangle */ import { SchemaKey } from '@postybirb/database'; import { Logger } from '@postybirb/logger'; import { join } from 'path'; import { Class } from 'type-fest'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { LegacyConverterEntity } from '../legacy-entities/legacy-converter-entity'; import { NdjsonParser } from '../utils/ndjson-parser'; export abstract class LegacyConverter { abstract readonly modernSchemaKey: SchemaKey; // eslint-disable-next-line @typescript-eslint/no-explicit-any abstract readonly LegacyEntityConstructor: Class>; abstract readonly legacyFileName: string; constructor(protected readonly databasePath: string) {} private getEntityFilePath(): string { return join(this.databasePath, 'data', `${this.legacyFileName}.db`); } private getModernDatabase() { return new PostyBirbDatabase(this.modernSchemaKey); } public async import(): Promise { const logger = Logger(`LegacyConverter:${this.legacyFileName}`); logger.info(`Starting import for ${this.legacyFileName}...`); const filePath = this.getEntityFilePath(); logger.info(`Reading legacy data from ${filePath}...`); const parser = new NdjsonParser(); const result = await parser.parseFile( filePath, this.LegacyEntityConstructor, ); logger.info( `Parsed ${filePath}: ${result.records.length} records, ${result.errors.length} errors`, ); if (result.errors.length > 0) { throw new Error( `Errors occurred while parsing ${this.LegacyEntityConstructor.name} data: ${result.errors .map((err) => `Line ${err.line}: ${err.error}`) .join('; ')}`, ); } const modernDb = this.getModernDatabase(); let skippedCount = 0; for (const legacyEntity of result.records) { const exists = await modernDb.findById(legacyEntity._id); if (exists) { logger.warn( `Entity with ID ${legacyEntity._id} already exists in modern database. Skipping.`, ); continue; } const modernEntity = await legacyEntity.convert(); // Skip null conversions (e.g., deprecated websites) if (modernEntity === null) { skippedCount++; continue; } await modernDb.insert(modernEntity); } if (skippedCount > 0) { logger.info(`Skipped ${skippedCount} records during conversion`); } logger.info(`Import for ${this.legacyFileName} completed successfully.`); } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-custom-shortcut.converter.spec.ts ================================================ import { clearDatabase } from '@postybirb/database'; import { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs'; import { readFileSync } from 'fs'; import { join } from 'path'; import { v4 } from 'uuid'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { LegacyCustomShortcut } from '../legacy-entities/legacy-custom-shortcut'; import { LegacyCustomShortcutConverter } from './legacy-custom-shortcut.converter'; describe('LegacyCustomShortcutConverter', () => { let converter: LegacyCustomShortcutConverter; let testDataPath: string; let repository: PostyBirbDatabase<'CustomShortcutSchema'>; const ts = Date.now(); beforeEach(async () => { clearDatabase(); // Setup test data directory testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/CustomShortcut-${v4()}`; // Copy test data to temp directory const testDataDir = join(testDataPath, 'data'); ensureDirSync(testDataDir); const sourceFile = join(__dirname, '../test-files/data/custom-shortcut.db'); const testFile = readFileSync(sourceFile); const destFile = join(testDataDir, 'custom-shortcut.db'); writeSync(destFile, testFile); converter = new LegacyCustomShortcutConverter(testDataPath); repository = new PostyBirbDatabase('CustomShortcutSchema'); }); it('should be defined', () => { expect(LegacyCustomShortcutConverter).toBeDefined(); }); describe('System Shortcuts Conversion', () => { it('should convert {title} to titleShortcut', async () => { const legacyShortcut = new LegacyCustomShortcut({ _id: 'test-title-shortcut', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'titletest', content: '

Artwork: {title}

', isDynamic: false, }); const result = await legacyShortcut.convert(); expect(result.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Artwork: ' }, { type: 'titleShortcut', attrs: {} }, ], }, ], }); }); it('should convert {tags} to tagsShortcut', async () => { const legacyShortcut = new LegacyCustomShortcut({ _id: 'test-tags-shortcut', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'tagstest', content: '

Tags: {tags}

', isDynamic: false, }); const result = await legacyShortcut.convert(); expect(result.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Tags: ' }, { type: 'tagsShortcut', attrs: {} }, ], }, ], }); }); it('should convert {cw} to contentWarningShortcut', async () => { const legacyShortcut = new LegacyCustomShortcut({ _id: 'test-cw-shortcut', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'cwtest', content: '

Content Warning: {cw}

', isDynamic: false, }); const result = await legacyShortcut.convert(); expect(result.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Content Warning: ' }, { type: 'contentWarningShortcut', attrs: {} }, ], }, ], }); }); it('should convert multiple system shortcuts in a single block', async () => { const legacyShortcut = new LegacyCustomShortcut({ _id: 'test-multi-system', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'multisystem', content: '

{title} ({cw})

{tags}

', isDynamic: false, }); const result = await legacyShortcut.convert(); expect(result.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'titleShortcut', attrs: {} }, { type: 'text', text: ' (' }, { type: 'contentWarningShortcut', attrs: {} }, { type: 'text', text: ')' }, ], }, { type: 'paragraph', content: [{ type: 'tagsShortcut', attrs: {} }], }, ], }); }); it('should convert system shortcuts alongside username shortcuts', async () => { const legacyShortcut = new LegacyCustomShortcut({ _id: 'test-mixed-shortcuts', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'mixedtest', content: '

{title} by {fa:myusername}

{cw}

{tags}

', isDynamic: false, }); const result = await legacyShortcut.convert(); expect(result.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'titleShortcut', attrs: {} }, { type: 'text', text: ' by ' }, { type: 'username', attrs: expect.objectContaining({ shortcut: 'furaffinity', only: '', username: 'myusername', }), }, ], }, { type: 'paragraph', content: [{ type: 'contentWarningShortcut', attrs: {} }], }, { type: 'paragraph', content: [{ type: 'tagsShortcut', attrs: {} }], }, ], }); }); it('should handle case-insensitive system shortcuts', async () => { const legacyShortcut = new LegacyCustomShortcut({ _id: 'test-case-insensitive', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'casetest', content: '

{TITLE} {CW} {TAGS}

', isDynamic: false, }); const result = await legacyShortcut.convert(); expect(result.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'titleShortcut', attrs: {} }, { type: 'text', text: ' ' }, { type: 'contentWarningShortcut', attrs: {} }, { type: 'text', text: ' ' }, { type: 'tagsShortcut', attrs: {} }, ], }, ], }); }); }); it('should import and convert legacy custom shortcut data', async () => { await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(2); // Verify first shortcut const shortcut1 = records.find( (r) => r.id === 'cs123456-1234-1234-1234-123456789abc', ); expect(shortcut1).toBeDefined(); expect(shortcut1!.name).toBe('myshortcut'); // Verify TipTap format expect(shortcut1!.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'This is my custom shortcut content', }, ], }, ], }); // Verify second shortcut (dynamic with HTML) const shortcut2 = records.find( (r) => r.id === 'cs234567-2345-2345-2345-234567890bcd', ); expect(shortcut2).toBeDefined(); expect(shortcut2!.name).toBe('dynamicshortcut'); // Verify HTML is converted to TipTap format with bold formatting const textContent2 = JSON.stringify(shortcut2!.shortcut); expect(textContent2).toContain('Dynamic content'); expect(textContent2).toContain('bold'); }); it('should handle custom shortcut with empty content', async () => { // Create test data with empty content const emptyContentData = { _id: 'test-empty-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'empty', content: '', isDynamic: false, }; const testDataDir = join(testDataPath, 'data'); const emptyFile = join(testDataDir, 'custom-shortcut.db'); writeSync(emptyFile, Buffer.from(JSON.stringify(emptyContentData) + '\n')); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; expect(record.name).toBe('empty'); // Empty content should create a doc with a single empty paragraph expect(record.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', }, ], }); }); it('should preserve shortcut name as the modern name field', async () => { await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(2); // All records should have name field matching legacy shortcut const names = records.map((r) => r.name); expect(names).toContain('myshortcut'); expect(names).toContain('dynamicshortcut'); }); it('should convert legacy shortcuts in content to TipTap format', async () => { // Create test data with legacy shortcut syntax const shortcutData = { _id: 'test-shortcuts-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'testshortcut', content: '

Hello {default} and {fa:myusername} with {customshortcut} text

', isDynamic: false, }; const testDataDir = join(testDataPath, 'data'); const shortcutFile = join(testDataDir, 'custom-shortcut.db'); writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\n')); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; expect(record.name).toBe('testshortcut'); const blocks = (record.shortcut as any).content; // Should have 2 blocks: defaultShortcut block + paragraph with content expect(blocks).toHaveLength(2); // First block should be the defaultShortcut block expect(blocks[0]).toMatchObject({ type: 'defaultShortcut', attrs: {}, }); // Second block should be paragraph with username shortcut and customShortcut expect(blocks[1]).toMatchObject({ type: 'paragraph', content: [ { type: 'text', text: 'Hello ' }, { type: 'text', text: ' and ' }, { type: 'username', attrs: expect.objectContaining({ shortcut: 'furaffinity', only: '', username: 'myusername', }), }, { type: 'text', text: ' with ' }, { type: 'customShortcut', attrs: { id: 'customshortcut' }, content: [{ type: 'text', text: '' }], }, { type: 'text', text: ' text' }, ], }); }); it('should convert multiple username shortcuts to modern format', async () => { // Create test data with multiple username shortcuts const shortcutData = { _id: 'test-multi-username-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'multiusername', content: '

Follow me on {fa:furuser}, {tw:twitterhandle}, and {da:deviantartist}

', isDynamic: false, }; const testDataDir = join(testDataPath, 'data'); const shortcutFile = join(testDataDir, 'custom-shortcut.db'); writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\n')); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; // Verify the complete structure with all username shortcuts expect(record.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Follow me on ' }, { type: 'username', attrs: expect.objectContaining({ shortcut: 'furaffinity', only: '', username: 'furuser', }), }, { type: 'text', text: ', ' }, { type: 'username', attrs: expect.objectContaining({ shortcut: 'twitter', only: '', username: 'twitterhandle', }), }, { type: 'text', text: ', and ' }, { type: 'username', attrs: expect.objectContaining({ shortcut: 'deviantart', only: '', username: 'deviantartist', }), }, ], }, ], }); }); it('should convert {default} to block-level when alone in paragraph', async () => { const shortcutData = { _id: 'test-default-alone', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'defaultalone', content: '

{default}

', isDynamic: false, }; const testDataDir = join(testDataPath, 'data'); const shortcutFile = join(testDataDir, 'custom-shortcut.db'); writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\n')); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; // Should have a single defaultShortcut block expect(record.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'defaultShortcut', attrs: {}, }, ], }); }); it('should insert defaultShortcut block before paragraph when {default} is with other content', async () => { const shortcutData = { _id: 'test-default-with-content', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'defaultwithcontent', content: '

Hello {default} World

', isDynamic: false, }; const testDataDir = join(testDataPath, 'data'); const shortcutFile = join(testDataDir, 'custom-shortcut.db'); writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\n')); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; // Should have 2 blocks: defaultShortcut block + paragraph with remaining text expect(record.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'defaultShortcut', attrs: {}, }, { type: 'paragraph', content: [ { type: 'text', text: 'Hello ' }, { type: 'text', text: ' World' }, ], }, ], }); }); it('should handle multiple {default} tags correctly', async () => { const shortcutData = { _id: 'test-multiple-defaults', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'multipledefaults', content: '

{default}

Some text {default} here

', isDynamic: false, }; const testDataDir = join(testDataPath, 'data'); const shortcutFile = join(testDataDir, 'custom-shortcut.db'); writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\n')); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; // Should have 3 blocks: defaultShortcut (from first para), defaultShortcut (inserted), paragraph (remaining content) expect(record.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'defaultShortcut', attrs: {}, }, { type: 'defaultShortcut', attrs: {}, }, { type: 'paragraph', content: [ { type: 'text', text: 'Some text ' }, { type: 'text', text: ' here' }, ], }, ], }); }); it('should handle and strip modifier blocks from shortcuts', async () => { const shortcutData = { _id: 'test-modifiers', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', shortcut: 'modifiertest', content: '

Test {fa[only=furaffinity]:testuser} and {customshortcut[modifier]} text

', isDynamic: false, }; const testDataDir = join(testDataPath, 'data'); const shortcutFile = join(testDataDir, 'custom-shortcut.db'); writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\n')); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; // Verify the structure - modifiers should be stripped expect(record.shortcut).toMatchObject({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Test ' }, { type: 'username', attrs: expect.objectContaining({ shortcut: 'furaffinity', only: '', username: 'testuser', }), }, { type: 'text', text: ' and ' }, { type: 'customShortcut', attrs: { id: 'customshortcut' }, content: [{ type: 'text', text: '' }], }, { type: 'text', text: ' text' }, ], }, ], }); }); }); ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-custom-shortcut.converter.ts ================================================ import { SchemaKey } from '@postybirb/database'; import { LegacyCustomShortcut } from '../legacy-entities/legacy-custom-shortcut'; import { LegacyConverter } from './legacy-converter'; export class LegacyCustomShortcutConverter extends LegacyConverter { modernSchemaKey: SchemaKey = 'CustomShortcutSchema'; LegacyEntityConstructor = LegacyCustomShortcut; legacyFileName = 'custom-shortcut'; } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-tag-converter.converter.spec.ts ================================================ import { clearDatabase } from '@postybirb/database'; import { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs'; import { readFileSync } from 'fs'; import { join } from 'path'; import { v4 } from 'uuid'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { LegacyTagConverterConverter } from './legacy-tag-converter.converter'; describe('LegacyTagConverterConverter', () => { let converter: LegacyTagConverterConverter; let testDataPath: string; let repository: PostyBirbDatabase<'TagConverterSchema'>; const ts = Date.now(); beforeEach(async () => { clearDatabase(); // Setup test data directory testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/TagConverter-${v4()}`; // Copy test data to temp directory const testDataDir = join(testDataPath, 'data'); ensureDirSync(testDataDir); const sourceFile = join(__dirname, '../test-files/data/tag-converter.db'); const testFile = readFileSync(sourceFile); const destFile = join(testDataDir, 'tag-converter.db'); writeSync(destFile, testFile); converter = new LegacyTagConverterConverter(testDataPath); repository = new PostyBirbDatabase('TagConverterSchema'); }); it('should import and convert legacy tag converter data', async () => { await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; // Verify ID is preserved expect(record.id).toBe('f499b833-b465-462a-bb3c-0983b35b3475'); // Verify tag is preserved expect(record.tag).toBe('converter'); // Verify legacy website IDs are mapped to modern ones (FurAffinity -> fur-affinity) expect(record.convertTo).toHaveProperty('fur-affinity'); expect(record.convertTo['fur-affinity']).toBe('converted'); }); it('should handle empty conversions object', async () => { // Create test data with empty conversions const emptyConversionData = { _id: 'test-empty-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', tag: 'empty-tag', conversions: {}, }; const testDataDir = join(testDataPath, 'data'); const emptyTestFile = join(testDataDir, 'tag-converter.db'); writeSync( emptyTestFile, Buffer.from(JSON.stringify(emptyConversionData) + '\n'), ); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; expect(record.tag).toBe('empty-tag'); expect(record.convertTo).toEqual({}); }); it('should map legacy website names to modern IDs', async () => { // Create test data with multiple legacy website names const multiWebsiteData = { _id: 'test-multi-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', tag: 'multi-tag', conversions: { FurAffinity: 'fa-tag', DeviantArt: 'da-tag', }, }; const testDataDir = join(testDataPath, 'data'); const multiTestFile = join(testDataDir, 'tag-converter.db'); writeSync( multiTestFile, Buffer.from(JSON.stringify(multiWebsiteData) + '\n'), ); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; expect(record.convertTo['fur-affinity']).toBe('fa-tag'); expect(record.convertTo['deviant-art']).toBe('da-tag'); }); }); ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-tag-converter.converter.ts ================================================ import { SchemaKey } from '@postybirb/database'; import { LegacyTagConverter } from '../legacy-entities/legacy-tag-converter'; import { LegacyConverter } from './legacy-converter'; export class LegacyTagConverterConverter extends LegacyConverter { modernSchemaKey: SchemaKey = 'TagConverterSchema'; LegacyEntityConstructor = LegacyTagConverter; legacyFileName = 'tag-converter'; } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-tag-group.converter.spec.ts ================================================ import { clearDatabase } from '@postybirb/database'; import { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs'; import { readFileSync } from 'fs'; import { join } from 'path'; import { v4 } from 'uuid'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { LegacyTagGroupConverter } from './legacy-tag-group.converter'; describe('LegacyTagGroupConverter', () => { let converter: LegacyTagGroupConverter; let testDataPath: string; let repository: PostyBirbDatabase<'TagGroupSchema'>; const ts = Date.now(); beforeEach(async () => { clearDatabase(); // Setup test data directory testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/TagGroup-${v4()}`; // Copy test data to temp directory const testDataDir = join(testDataPath, 'data'); ensureDirSync(testDataDir); const sourceFile = join(__dirname, '../test-files/data/tag-group.db'); const testFile = readFileSync(sourceFile); const destFile = join(testDataDir, 'tag-group.db'); writeSync(destFile, testFile); converter = new LegacyTagGroupConverter(testDataPath); repository = new PostyBirbDatabase('TagGroupSchema'); }); it('should import and convert legacy tag group data', async () => { await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; // Verify ID is preserved expect(record.id).toBe('f499b833-b465-462a-bb3c-0983b35b3475'); // Verify alias is converted to name expect(record.name).toBe('converter'); // Verify tags array is preserved expect(record.tags).toEqual(['tag1', 'tag2']); }); it('should handle tag group with empty tags array', async () => { // Create test data with empty tags const emptyTagsData = { _id: 'test-empty-tags-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', alias: 'empty-group', tags: [], }; const testDataDir = join(testDataPath, 'data'); const emptyTagsFile = join(testDataDir, 'tag-group.db'); writeSync(emptyTagsFile, Buffer.from(JSON.stringify(emptyTagsData) + '\n')); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; expect(record.name).toBe('empty-group'); expect(record.tags).toEqual([]); }); it('should handle tag group with single tag', async () => { // Create test data with single tag const singleTagData = { _id: 'test-single-tag-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', alias: 'single-tag-group', tags: ['lonely-tag'], }; const testDataDir = join(testDataPath, 'data'); const singleTagFile = join(testDataDir, 'tag-group.db'); writeSync(singleTagFile, Buffer.from(JSON.stringify(singleTagData) + '\n')); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; expect(record.name).toBe('single-tag-group'); expect(record.tags).toHaveLength(1); expect(record.tags[0]).toBe('lonely-tag'); }); it('should handle tag group with special characters in name', async () => { // Create test data with special characters const specialCharsData = { _id: 'test-special-chars-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', alias: 'group-with-special!@#$%', tags: ['tag1', 'tag2'], }; const testDataDir = join(testDataPath, 'data'); const specialCharsFile = join(testDataDir, 'tag-group.db'); writeSync( specialCharsFile, Buffer.from(JSON.stringify(specialCharsData) + '\n'), ); await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(1); const record = records[0]; expect(record.name).toBe('group-with-special!@#$%'); }); }); ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-tag-group.converter.ts ================================================ import { SchemaKey } from '@postybirb/database'; import { LegacyTagGroup } from '../legacy-entities/legacy-tag-group'; import { LegacyConverter } from './legacy-converter'; export class LegacyTagGroupConverter extends LegacyConverter { modernSchemaKey: SchemaKey = 'TagGroupSchema'; LegacyEntityConstructor = LegacyTagGroup; legacyFileName = 'tag-group'; } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-user-account.converter.spec.ts ================================================ import { clearDatabase } from '@postybirb/database'; import { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs'; import { readFileSync } from 'fs'; import { join } from 'path'; import { v4 } from 'uuid'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { LegacyUserAccountConverter } from './legacy-user-account.converter'; describe('LegacyUserAccountConverter', () => { let converter: LegacyUserAccountConverter; let testDataPath: string; let repository: PostyBirbDatabase<'AccountSchema'>; const ts = Date.now(); beforeEach(async () => { clearDatabase(); // Setup test data directory testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/Account-${v4()}`; // Copy test data to temp directory const testDataDir = join(testDataPath, 'data'); ensureDirSync(testDataDir); const sourceFile = join(__dirname, '../test-files/data/accounts.db'); const testFile = readFileSync(sourceFile); const destFile = join(testDataDir, 'accounts.db'); writeSync(destFile, testFile); converter = new LegacyUserAccountConverter(testDataPath); repository = new PostyBirbDatabase('AccountSchema'); }); it('should import and convert legacy user account data', async () => { await converter.import(); const records = await repository.findAll(); expect(records).toHaveLength(2); // Verify FurAffinity account const faAccount = records.find( (r) => r.id === 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', ); expect(faAccount).toBeDefined(); expect(faAccount!.name).toBe('FurAffinity Main'); expect(faAccount!.website).toBe('fur-affinity'); // Verify DeviantArt account const daAccount = records.find( (r) => r.id === 'b2c3d4e5-f6a7-8901-bcde-f12345678901', ); expect(daAccount).toBeDefined(); expect(daAccount!.name).toBe('DeviantArt Account'); expect(daAccount!.website).toBe('deviant-art'); }); it('should skip accounts for deprecated websites', async () => { // Create test data with deprecated website const deprecatedData = { _id: 'deprecated-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', alias: 'FurryNetwork Account', website: 'FurryNetwork', data: { username: 'test' }, }; const testDataDir = join(testDataPath, 'data'); const deprecatedFile = join(testDataDir, 'accounts.db'); writeSync( deprecatedFile, Buffer.from(JSON.stringify(deprecatedData) + '\n'), ); await converter.import(); const records = await repository.findAll(); // Should be empty because FurryNetwork is deprecated expect(records).toHaveLength(0); }); }); ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-user-account.converter.ts ================================================ import { SchemaKey } from '@postybirb/database'; import { LegacyUserAccount } from '../legacy-entities/legacy-user-account'; import { LegacyConverter } from './legacy-converter'; export class LegacyUserAccountConverter extends LegacyConverter { modernSchemaKey: SchemaKey = 'AccountSchema'; LegacyEntityConstructor = LegacyUserAccount; legacyFileName = 'accounts'; } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-website-data.converter.spec.ts ================================================ import { clearDatabase } from '@postybirb/database'; import { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs'; import { readFileSync } from 'fs'; import { join } from 'path'; import { v4 } from 'uuid'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { LegacyUserAccountConverter } from './legacy-user-account.converter'; import { LegacyWebsiteDataConverter } from './legacy-website-data.converter'; describe('LegacyWebsiteDataConverter', () => { let accountConverter: LegacyUserAccountConverter; let websiteDataConverter: LegacyWebsiteDataConverter; let testDataPath: string; let accountRepository: PostyBirbDatabase<'AccountSchema'>; let websiteDataRepository: PostyBirbDatabase<'WebsiteDataSchema'>; const ts = Date.now(); beforeEach(async () => { clearDatabase(); // Setup test data directory testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/WebsiteData-${v4()}`; // Copy test data to temp directory const testDataDir = join(testDataPath, 'data'); ensureDirSync(testDataDir); // Use the accounts-with-websitedata.db test file const sourceFile = join( __dirname, '../test-files/data/accounts-with-websitedata.db', ); const testFile = readFileSync(sourceFile); const destFile = join(testDataDir, 'accounts.db'); writeSync(destFile, testFile); accountConverter = new LegacyUserAccountConverter(testDataPath); websiteDataConverter = new LegacyWebsiteDataConverter(testDataPath); accountRepository = new PostyBirbDatabase('AccountSchema'); websiteDataRepository = new PostyBirbDatabase('WebsiteDataSchema'); }); /** * Helper to run both converters in the correct order. * Account converter must run first due to foreign key dependency. */ async function runConverters() { // Accounts must be created first (WebsiteData has FK reference) await accountConverter.import(); await websiteDataConverter.import(); } it('should import Twitter WebsiteData with transformed credentials', async () => { await runConverters(); const websiteData = await websiteDataRepository.findById( 'twitter-test-id-001', ); expect(websiteData).toBeDefined(); expect(websiteData!.data).toEqual({ apiKey: 'consumer_key_123', apiSecret: 'consumer_secret_456', accessToken: 'access_token_789', accessTokenSecret: 'access_secret_012', screenName: 'test_user', userId: '123456789', }); }); it('should import Discord WebsiteData with webhook config', async () => { await runConverters(); const websiteData = await websiteDataRepository.findById( 'discord-test-id-002', ); expect(websiteData).toBeDefined(); expect(websiteData!.data).toEqual({ webhook: 'https://discord.com/api/webhooks/123456789/abcdefghijklmnop', serverLevel: 2, isForum: true, }); }); it('should import Telegram WebsiteData with app credentials', async () => { await runConverters(); const websiteData = await websiteDataRepository.findById( 'telegram-test-id-003', ); expect(websiteData).toBeDefined(); expect(websiteData!.data).toEqual({ appId: 12345678, // Converted from string to number appHash: 'abcdef0123456789abcdef0123456789', phoneNumber: '+1234567890', session: undefined, channels: [], }); }); it('should import Mastodon WebsiteData with normalized instance URL', async () => { await runConverters(); const websiteData = await websiteDataRepository.findById( 'mastodon-test-id-004', ); expect(websiteData).toBeDefined(); expect(websiteData!.data).toMatchObject({ accessToken: 'mastodon_access_token_xyz', instanceUrl: 'mastodon.social', // Normalized (protocol stripped) username: 'mastodon_user', }); }); it('should import Bluesky WebsiteData with username and password', async () => { await runConverters(); const websiteData = await websiteDataRepository.findById( 'bluesky-test-id-005', ); expect(websiteData).toBeDefined(); expect(websiteData!.data).toEqual({ username: 'bluesky.user.bsky.social', password: 'app_password_123', }); }); it('should import e621 WebsiteData with API key', async () => { await runConverters(); const websiteData = await websiteDataRepository.findById('e621-test-id-007'); expect(websiteData).toBeDefined(); expect(websiteData!.data).toEqual({ username: 'e621_user', key: 'api_key_xyz789', }); }); it('should import Custom webhook with fixed typo', async () => { await runConverters(); const websiteData = await websiteDataRepository.findById( 'custom-test-id-009', ); expect(websiteData).toBeDefined(); // Verify typo fix: thumbnaiField -> thumbnailField expect(websiteData!.data).toMatchObject({ fileUrl: 'https://example.com/upload', descriptionField: 'description', headers: [{ name: 'Authorization', value: 'Bearer token123' }], thumbnailField: 'thumbnail', // Fixed from thumbnaiField }); }); it('should import Pleroma using MegalodonDataTransformer', async () => { await runConverters(); const websiteData = await websiteDataRepository.findById( 'pleroma-test-id-010', ); expect(websiteData).toBeDefined(); expect(websiteData!.data).toMatchObject({ accessToken: 'pleroma_token_abc', instanceUrl: 'pleroma.example.com', username: 'pleroma_user', }); }); it('should import Pixelfed and normalize instance URL', async () => { await runConverters(); const websiteData = await websiteDataRepository.findById( 'pixelfed-test-id-011', ); expect(websiteData).toBeDefined(); expect(websiteData!.data).toMatchObject({ accessToken: 'pixelfed_token_def', instanceUrl: 'pixelfed.social', // Protocol and trailing slash stripped username: 'pixelfed_user', }); }); it('should NOT create WebsiteData for browser-cookie websites (FurAffinity)', async () => { await runConverters(); // FurAffinity uses browser cookies, not WebsiteData - no transformer exists const websiteData = await websiteDataRepository.findById( 'furaffinity-test-id-012', ); expect(websiteData).toBeNull(); // But the account should exist const account = await accountRepository.findById('furaffinity-test-id-012'); expect(account).toBeDefined(); expect(account!.website).toBe('fur-affinity'); }); it('should skip deprecated websites', async () => { // Create test data with deprecated website const testDataDir = join(testDataPath, 'data'); const deprecatedData = { _id: 'deprecated-id', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', alias: 'FurryNetwork Account', website: 'FurryNetwork', data: { username: 'test' }, }; const deprecatedFile = join(testDataDir, 'accounts.db'); writeSync( deprecatedFile, Buffer.from(JSON.stringify(deprecatedData) + '\n'), ); // Run just the website data converter (account doesn't exist) await websiteDataConverter.import(); // Should be empty because FurryNetwork is deprecated const records = await websiteDataRepository.findAll(); expect(records).toHaveLength(0); }); it('should handle accounts with transformer but missing data', async () => { // Create test data with Twitter account but no data field const testDataDir = join(testDataPath, 'data'); const noDataAccount = { _id: 'twitter-no-data', created: '2023-10-01T12:00:00Z', lastUpdated: '2023-10-01T12:00:00Z', alias: 'Twitter No Data', website: 'Twitter', // data field is missing }; const testFile = join(testDataDir, 'accounts.db'); writeSync(testFile, Buffer.from(JSON.stringify(noDataAccount) + '\n')); // Create the account first await accountConverter.import(); await websiteDataConverter.import(); // WebsiteData should not be created const websiteData = await websiteDataRepository.findById('twitter-no-data'); expect(websiteData).toBeNull(); }); }); ================================================ FILE: apps/client-server/src/app/legacy-database-importer/converters/legacy-website-data.converter.ts ================================================ import { SchemaKey } from '@postybirb/database'; import { LegacyWebsiteData } from '../legacy-entities/legacy-website-data'; import { LegacyConverter } from './legacy-converter'; /** * Converter for importing website-specific data (OAuth tokens, API keys, credentials) * from legacy PostyBirb Plus accounts. * * IMPORTANT: This converter must run AFTER LegacyUserAccountConverter because * WebsiteData records have a foreign key reference to Account records. * The Account must exist before its associated WebsiteData can be created. * * Only websites with registered transformers in WebsiteDataTransformerRegistry * will produce records. Websites using browser cookies for authentication * (e.g., FurAffinity, DeviantArt) will be skipped. */ export class LegacyWebsiteDataConverter extends LegacyConverter { modernSchemaKey: SchemaKey = 'WebsiteDataSchema'; LegacyEntityConstructor = LegacyWebsiteData; /** * Reads from the same 'accounts' file as LegacyUserAccountConverter, * but only extracts and transforms the website-specific data. */ legacyFileName = 'accounts'; } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/dtos/legacy-import.dto.ts ================================================ import { IsBoolean, IsOptional, IsString } from 'class-validator'; export class LegacyImportDto { @IsBoolean() customShortcuts: boolean; @IsBoolean() tagGroups: boolean; @IsBoolean() accounts: boolean; @IsBoolean() tagConverters: boolean; @IsOptional() @IsString() customPath?: string; } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/legacy-database-importer.controller.ts ================================================ import { Body, Controller, Post } from '@nestjs/common'; import { LegacyImportDto } from './dtos/legacy-import.dto'; import { LegacyDatabaseImporterService } from './legacy-database-importer.service'; @Controller('legacy-database-importer') export class LegacyDatabaseImporterController { constructor( private readonly legacyDatabaseImporterService: LegacyDatabaseImporterService, ) {} @Post('import') async import(@Body() importRequest: LegacyImportDto) { return this.legacyDatabaseImporterService.import(importRequest); } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/legacy-database-importer.module.ts ================================================ import { Module } from '@nestjs/common'; import { AccountModule } from '../account/account.module'; import { LegacyDatabaseImporterController } from './legacy-database-importer.controller'; import { LegacyDatabaseImporterService } from './legacy-database-importer.service'; @Module({ imports: [AccountModule], providers: [LegacyDatabaseImporterService], controllers: [LegacyDatabaseImporterController], exports: [], }) export class LegacyDatabaseImporterModule {} ================================================ FILE: apps/client-server/src/app/legacy-database-importer/legacy-database-importer.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { app } from 'electron'; import { join } from 'path'; import { AccountService } from '../account/account.service'; import { LegacyConverter } from './converters/legacy-converter'; import { LegacyCustomShortcutConverter } from './converters/legacy-custom-shortcut.converter'; import { LegacyTagConverterConverter } from './converters/legacy-tag-converter.converter'; import { LegacyTagGroupConverter } from './converters/legacy-tag-group.converter'; import { LegacyUserAccountConverter } from './converters/legacy-user-account.converter'; import { LegacyWebsiteDataConverter } from './converters/legacy-website-data.converter'; import { LegacyImportDto } from './dtos/legacy-import.dto'; @Injectable() export class LegacyDatabaseImporterService { private readonly logger = Logger(LegacyDatabaseImporterService.name); protected readonly LEGACY_POSTYBIRB_PLUS_PATH = join( app.getPath('documents'), 'PostyBirb', ); constructor(private readonly accountService: AccountService) {} async import(importRequest: LegacyImportDto): Promise<{ errors: Error[] }> { const path = importRequest.customPath || this.LEGACY_POSTYBIRB_PLUS_PATH; const errors: Error[] = []; if (importRequest.accounts) { // Import user accounts const result = await this.processImport( new LegacyUserAccountConverter(path), ); if (result.error) { errors.push(result.error); } // IMPORTANT: WebsiteData must be imported AFTER accounts because // WebsiteData records have a foreign key reference to Account records. // The Account must exist before its associated WebsiteData can be created. const websiteDataResult = await this.processImport( new LegacyWebsiteDataConverter(path), ); if (websiteDataResult.error) { errors.push(websiteDataResult.error); } const allAccounts = await this.accountService.findAll(); allAccounts.forEach((account) => { this.accountService.manuallyExecuteOnLogin(account.id); }); } if (importRequest.tagGroups) { // Import tag groups const result = await this.processImport( new LegacyTagGroupConverter(path), ); if (result.error) { errors.push(result.error); } } if (importRequest.tagConverters) { // Import tag converters const result = await this.processImport( new LegacyTagConverterConverter(path), ); if (result.error) { errors.push(result.error); } } if (importRequest.customShortcuts) { // Import custom shortcuts const result = await this.processImport( new LegacyCustomShortcutConverter(path), ); if (result.error) { errors.push(result.error); } } return { errors }; } private async processImport( converter: LegacyConverter, ): Promise<{ error?: Error }> { try { this.logger.info(`Starting import for ${converter.legacyFileName}...`); await converter.import(); return {}; } catch (error) { this.logger.error( `Import for ${converter.legacyFileName} failed.`, error, ); return { error }; } } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-converter-entity.ts ================================================ import { IEntity } from '@postybirb/types'; export type MinimalEntity = Omit< T, 'createdAt' | 'updatedAt' >; export interface LegacyConverterEntity { _id: string; convert(): Promise | null>; } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-custom-shortcut.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable import/no-extraneous-dependencies */ import { Description, ICustomShortcut, TipTapNode } from '@postybirb/types'; import { Blockquote } from '@tiptap/extension-blockquote'; import { Bold } from '@tiptap/extension-bold'; import { Code } from '@tiptap/extension-code'; import { Document } from '@tiptap/extension-document'; import { HardBreak } from '@tiptap/extension-hard-break'; import { Heading } from '@tiptap/extension-heading'; import { HorizontalRule } from '@tiptap/extension-horizontal-rule'; import { Italic } from '@tiptap/extension-italic'; import { Link } from '@tiptap/extension-link'; import { Paragraph } from '@tiptap/extension-paragraph'; import { Strike } from '@tiptap/extension-strike'; import { Text } from '@tiptap/extension-text'; import { Underline } from '@tiptap/extension-underline'; import { generateJSON } from '@tiptap/html/dist/server'; import { LegacyConverterEntity, MinimalEntity, } from './legacy-converter-entity'; const tiptapExtensions = [ Text, Document, Paragraph, Bold, Italic, Strike, Underline, Code, HardBreak, Blockquote, Heading, HorizontalRule, Link.configure({ openOnClick: false, }), ]; export class LegacyCustomShortcut implements LegacyConverterEntity { _id: string; created: string; lastUpdated: string; shortcut: string; content: string; isDynamic: boolean; constructor(data: Partial) { Object.assign(this, data); } async convert(): Promise> { // Convert legacy format to new format // Legacy: { shortcut: string, content: string, isDynamic: boolean } // New: { name: string, shortcut: Description (TipTap format) } // Step 1: Wrap legacy shortcuts in code tags to preserve them during HTML parsing const contentWithWrappedShortcuts = this.wrapLegacyShortcuts(this.content); // Step 2: Parse HTML with wrapped shortcuts to TipTap JSON format const doc = generateJSON( contentWithWrappedShortcuts || '

', tiptapExtensions, ) as Description; // Step 3: Convert legacy shortcuts to modern format let blocks: TipTapNode[] = doc.content ?? []; blocks = this.convertLegacyToModernShortcut(blocks); // Step 4: Convert default shortcuts to block-level elements blocks = this.convertDefaultToBlock(blocks); const shortcut: Description = { type: 'doc', content: blocks }; return { // eslint-disable-next-line no-underscore-dangle id: this._id, name: this.shortcut, // Legacy shortcut name becomes the name shortcut, }; } /** * Recursively traverses TipTap tree to find code-marked text nodes that are * legacy shortcuts and converts them to modern format in place. */ private convertLegacyToModernShortcut(blocks: TipTapNode[]): TipTapNode[] { // Pattern matches: // {word} or {word:text} or {word[modifier]:text} or {word[modifier]} // Captures: (1) shortcut key, (2) optional modifier (ignored), (3) optional value const shortcutPattern = /^\{([a-zA-Z0-9]+)(?:\[([^\]]+)\])?(?::([^}]+))?\}$/; // Mapping of legacy system shortcuts to new inline shortcut types const systemShortcutMapping: Record = { cw: 'contentWarningShortcut', title: 'titleShortcut', tags: 'tagsShortcut', }; // Mapping of legacy username shortcut keys to modern IDs const usernameShortcutMapping: Record = { ac: 'artconomy', bsky: 'bluesky', da: 'deviantart', db: 'derpibooru', e6: 'e621', fa: 'furaffinity', furb: 'furbooru', hf: 'h-foundry', ib: 'inkbunny', it: 'itaku', mb: 'manebooru', ng: 'newgrounds', pa: 'patreon', pf: 'pillowfort', ptv: 'picarto', pz: 'piczel', sf: 'sofurry', ss: 'subscribe-star', tu: 'tumblr', tw: 'twitter', ws: 'weasyl', }; const hasCodeMark = (item: any): boolean => Array.isArray(item.marks) && item.marks.some((m: any) => m.type === 'code'); const processInlineContent = (content: any[]): any[] => { const result: any[] = []; content.forEach((item: any) => { // Check if this is a code-marked text node (legacy shortcut) if ( item.type === 'text' && hasCodeMark(item) && typeof item.text === 'string' ) { const match = item.text.match(shortcutPattern); if (match) { const shortcutKey = match[1]; const shortcutKeyLower = shortcutKey.toLowerCase(); // Check if this is a system shortcut (cw, title, tags) if (systemShortcutMapping[shortcutKeyLower]) { result.push({ type: systemShortcutMapping[shortcutKeyLower], attrs: {}, }); return; } // match[2] is the modifier block - we ignore it const shortcutValue = match[3]; // Value is now in capture group 3 // Check if this is a username shortcut (has a value after colon) if (shortcutValue) { const modernId = usernameShortcutMapping[shortcutKey.toLowerCase()]; if (modernId) { // Convert to username shortcut format result.push({ type: 'username', attrs: { shortcut: modernId, only: '', username: shortcutValue, }, }); return; } // Has a colon but not a username shortcut - convert to customShortcut result.push({ type: 'customShortcut', attrs: { id: shortcutKey }, content: [{ type: 'text', text: shortcutValue }], }); return; } // Simple shortcut without colon - convert to customShortcut result.push({ type: 'customShortcut', attrs: { id: shortcutKey }, content: [{ type: 'text', text: '' }], }); return; } // If it doesn't match shortcut pattern, return as is result.push(item); return; } result.push(item); }); return result; }; return blocks.map((block: any) => { if (!Array.isArray(block.content)) { return block; } // Check if content contains inline nodes (text) or block nodes (paragraph, etc.) const hasInlineContent = block.content.some( (c: any) => c.type === 'text', ); if (hasInlineContent) { // Process inline content for shortcut conversion // eslint-disable-next-line no-param-reassign block.content = processInlineContent(block.content); } else { // Recursively process nested block content (e.g. blockquote > paragraph) // eslint-disable-next-line no-param-reassign block.content = this.convertLegacyToModernShortcut(block.content); } return block; }); } /** * Converts {default} shortcuts to block-level elements. * - If default is alone in a block, convert the block to type: 'defaultShortcut' * - If default is with other content, remove it and insert a defaultShortcut block before */ private convertDefaultToBlock(blocks: TipTapNode[]): TipTapNode[] { const result: any[] = []; blocks.forEach((block: any) => { // Recursively process nested block content (e.g. blockquote) if ( Array.isArray(block.content) && !block.content.some((c: any) => c.type === 'text') ) { // eslint-disable-next-line no-param-reassign block.content = this.convertDefaultToBlock(block.content); } // Check if this block has content with a default customShortcut if (Array.isArray(block.content)) { const defaultShortcutIndex = block.content.findIndex( (item: any) => item.type === 'customShortcut' && item.attrs?.id === 'default', ); if (defaultShortcutIndex !== -1) { // Found a default shortcut in this block's content const otherContent = block.content.filter( (item: any, idx: number) => idx !== defaultShortcutIndex, ); // Check if there are other content items (text, links, etc.) const hasOtherContent = otherContent.some((item: any) => item.type === 'text' ? item.text.trim().length > 0 : item.type !== 'customShortcut' || item.attrs?.id !== 'default', ); if (hasOtherContent) { // Default was with other content - insert defaultShortcut block before this one result.push({ type: 'defaultShortcut', attrs: {}, }); // Add the current block without the default shortcut result.push({ ...block, content: otherContent, }); } else { // Default was alone - convert this block to defaultShortcut type result.push({ type: 'defaultShortcut', attrs: {}, }); } return; } } // No default shortcut found, keep block as is result.push(block); }); return result; } /** * Wraps legacy shortcuts in code tags to preserve them during HTML parsing. * Supports: * - Simple shortcuts: {default}, {customshortcut} * - Username shortcuts: {fa:username}, {tw:handle} * - Dynamic shortcuts: {myshortcut:text} * - Modifiers (stripped): {fa[only=furaffinity]:username}, {shortcut[modifier]} * * Ignores deprecated shortcuts: {cw}, {title}, {tags} * * Uses tags to mark shortcuts so TipTap will preserve them as * code-marked text nodes that can be detected and converted. */ private wrapLegacyShortcuts(content: string): string { // Pattern matches: // {word} or {word:text} or {word[modifier]:text} or {word[modifier]} // where word is alphanumeric, modifier is anything except ], and text can contain anything except } const shortcutPattern = /\{([a-zA-Z0-9]+)(?:\[([^\]]+)\])?(?::([^}]+))?\}/g; return content.replace( shortcutPattern, (match, key, modifier, additionalText) => // Use tag which TipTap preserves as a code mark on text nodes // This will create a text node with a code mark that we can identify `${match}`, ); } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-tag-converter.ts ================================================ import { ITagConverter } from '@postybirb/types'; import { WebsiteNameMapper } from '../utils/website-name-mapper'; import { LegacyConverterEntity, MinimalEntity, } from './legacy-converter-entity'; /** * Legacy tag converter entity from PostyBirb Plus * Converts a tag to website-specific tags */ export class LegacyTagConverter implements LegacyConverterEntity { _id: string; created: number; lastUpdated: number; tag: string; conversions: Record; // Legacy website ID -> converted tag constructor(data: Partial) { Object.assign(this, data); } async convert(): Promise> { const conversionsMap: Record = {}; for (const [legacyWebsiteId, convertedTag] of Object.entries( this.conversions, )) { const newWebsiteId = WebsiteNameMapper.map(legacyWebsiteId); if (newWebsiteId) { conversionsMap[newWebsiteId] = convertedTag; } } return { // eslint-disable-next-line no-underscore-dangle id: this._id, tag: this.tag, convertTo: conversionsMap, }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-tag-group.ts ================================================ import { ITagGroup } from '@postybirb/types'; import { LegacyConverterEntity, MinimalEntity, } from './legacy-converter-entity'; /** * Legacy tag group entity from PostyBirb Plus * Represents a group of tags that can be applied together */ export class LegacyTagGroup implements LegacyConverterEntity { _id: string; created: number; lastUpdated: number; alias: string; tags: string[]; constructor(data: Partial) { Object.assign(this, data); } async convert(): Promise> { return { // eslint-disable-next-line no-underscore-dangle id: this._id, name: this.alias, tags: this.tags, }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-user-account.ts ================================================ import { IAccount } from '@postybirb/types'; import { WebsiteNameMapper } from '../utils/website-name-mapper'; import { LegacyConverterEntity, MinimalEntity, } from './legacy-converter-entity'; /** * Legacy user account entity from PostyBirb Plus * Represents a user's account on a specific website */ export class LegacyUserAccount implements LegacyConverterEntity { _id: string; created: number; lastUpdated: number; alias: string; website: string; data: unknown; // Website-specific data (handled by LegacyWebsiteData converter) constructor(data: Partial) { Object.assign(this, data); } async convert(): Promise | null> { const newWebsiteId = WebsiteNameMapper.map(this.website); // Skip accounts for deprecated websites if (!newWebsiteId) { return null; } return { // eslint-disable-next-line no-underscore-dangle id: this._id, name: this.alias, website: newWebsiteId, groups: [], // Groups weren't part of legacy accounts }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-website-data.ts ================================================ import { Logger } from '@postybirb/logger'; import { IWebsiteData } from '@postybirb/types'; import { WebsiteDataTransformerRegistry } from '../transformers'; import { WebsiteNameMapper } from '../utils/website-name-mapper'; import { LegacyConverterEntity, MinimalEntity } from './legacy-converter-entity'; const logger = Logger('LegacyWebsiteData'); /** * Legacy website data entity from PostyBirb Plus. * This reads the same legacy account records but extracts and transforms * the website-specific data (OAuth tokens, API keys, credentials) into * WebsiteData records. * * Only websites with registered transformers will produce records. * Websites using browser cookies for authentication (e.g., FurAffinity) * will return null and be skipped. */ export class LegacyWebsiteData implements LegacyConverterEntity { _id: string; created: number; lastUpdated: number; alias: string; website: string; data: unknown; constructor(data: Partial) { Object.assign(this, data); } async convert(): Promise | null> { const newWebsiteId = WebsiteNameMapper.map(this.website); // Skip accounts for deprecated websites if (!newWebsiteId) { return null; } // Only process websites that have a data transformer const transformer = WebsiteDataTransformerRegistry.getTransformer( this.website, ); if (!transformer) { logger.debug( `No transformer for website "${this.website}" (account: ${this.alias}). ` + 'This website likely uses browser cookies for authentication.', ); return null; } if (!this.data) { logger.warn( `Account "${this.alias}" (${this.website}) has transformer but no data to transform.`, ); return null; } const transformedData = transformer.transform(this.data); if (!transformedData) { logger.warn( `Transformer returned null for account "${this.alias}" (${this.website}).`, ); return null; } return { // WebsiteData uses the same ID as the Account (foreign key relationship) // eslint-disable-next-line no-underscore-dangle id: this._id, data: transformedData as Record, }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/implementations/bluesky-data-transformer.ts ================================================ import { BlueskyAccountData } from '@postybirb/types'; import { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer'; /** * Legacy Bluesky account data structure from PostyBirb Plus */ interface LegacyBlueskyAccountData { username: string; password: string; } /** * Transforms legacy Bluesky account data to modern format. * This is a direct passthrough as the structure is identical. * * Field mappings: * - username → username * - password → password */ export class BlueskyDataTransformer implements LegacyWebsiteDataTransformer { transform(legacyData: LegacyBlueskyAccountData): BlueskyAccountData | null { if (!legacyData) { return null; } // Must have credentials to be useful if (!legacyData.username || !legacyData.password) { return null; } return { username: legacyData.username, password: legacyData.password, }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/implementations/custom-data-transformer.ts ================================================ import { CustomAccountData } from '@postybirb/types'; import { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer'; /** * Legacy Custom account data structure from PostyBirb Plus * Note: Legacy has a typo "thumbnaiField" instead of "thumbnailField" */ interface LegacyCustomAccountData { descriptionField?: string; descriptionType?: 'html' | 'text' | 'md' | 'bbcode'; fileField?: string; fileUrl?: string; headers: { name: string; value: string }[]; notificationUrl?: string; ratingField?: string; tagField?: string; thumbnaiField?: string; // Typo in legacy titleField?: string; altTextField?: string; } /** * Transforms legacy Custom account data to modern format. * * Field mappings: * - All fields pass through directly * - thumbnaiField (typo) → thumbnailField */ export class CustomDataTransformer implements LegacyWebsiteDataTransformer { transform(legacyData: LegacyCustomAccountData): CustomAccountData | null { if (!legacyData) { return null; } // Must have at least file URL to be useful if (!legacyData.fileUrl && !legacyData.notificationUrl) { return null; } return { descriptionField: legacyData.descriptionField, descriptionType: legacyData.descriptionType, fileField: legacyData.fileField, fileUrl: legacyData.fileUrl, headers: legacyData.headers ?? [], notificationUrl: legacyData.notificationUrl, ratingField: legacyData.ratingField, tagField: legacyData.tagField, // Fix typo from legacy: thumbnaiField → thumbnailField thumbnailField: legacyData.thumbnaiField, titleField: legacyData.titleField, altTextField: legacyData.altTextField, }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/implementations/discord-data-transformer.ts ================================================ import { DiscordAccountData } from '@postybirb/types'; import { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer'; /** * Legacy Discord account data structure from PostyBirb Plus */ interface LegacyDiscordAccountData { webhook: string; serverBoostLevel: number; name: string; forum: boolean; } /** * Transforms legacy Discord account data to modern format. * * Field mappings: * - webhook → webhook * - serverBoostLevel → serverLevel * - forum → isForum * - name is not used in modern (account name is stored separately) */ export class DiscordDataTransformer implements LegacyWebsiteDataTransformer { transform(legacyData: LegacyDiscordAccountData): DiscordAccountData | null { if (!legacyData) { return null; } // Must have webhook URL to be useful if (!legacyData.webhook) { return null; } return { webhook: legacyData.webhook, serverLevel: legacyData.serverBoostLevel ?? 0, isForum: legacyData.forum ?? false, }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/implementations/e621-data-transformer.ts ================================================ import { E621AccountData } from '@postybirb/types'; import { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer'; /** * Legacy e621 account data structure from PostyBirb Plus */ interface LegacyE621AccountData { username: string; key: string; // API key } /** * Transforms legacy e621 account data to modern format. * This is a direct passthrough as the structure is identical. * * Field mappings: * - username → username * - key → key */ export class E621DataTransformer implements LegacyWebsiteDataTransformer { transform(legacyData: LegacyE621AccountData): E621AccountData | null { if (!legacyData) { return null; } // Must have API key to be useful if (!legacyData.key) { return null; } return { username: legacyData.username ?? '', key: legacyData.key, }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/implementations/index.ts ================================================ export * from './bluesky-data-transformer'; export * from './custom-data-transformer'; export * from './discord-data-transformer'; export * from './e621-data-transformer'; export * from './inkbunny-data-transformer'; export * from './megalodon-data-transformer'; export * from './telegram-data-transformer'; export * from './twitter-data-transformer'; ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/implementations/inkbunny-data-transformer.ts ================================================ import { InkbunnyAccountData } from '@postybirb/types'; import { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer'; /** * Legacy Inkbunny account data structure from PostyBirb Plus */ interface LegacyInkbunnyAccountData { username: string; sid: string; // Session ID } /** * Transforms legacy Inkbunny account data to modern format. * This is mostly a direct passthrough. * * Field mappings: * - username → username * - sid → sid * - folders: undefined (will be fetched on login) */ export class InkbunnyDataTransformer implements LegacyWebsiteDataTransformer { transform(legacyData: LegacyInkbunnyAccountData): InkbunnyAccountData | null { if (!legacyData) { return null; } // Must have session ID to be useful if (!legacyData.sid) { return null; } return { username: legacyData.username, sid: legacyData.sid, folders: undefined, // Will be populated on login }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/implementations/megalodon-data-transformer.ts ================================================ import { MegalodonAccountData } from '@postybirb/types'; import { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer'; /** * Legacy Megalodon account data structure from PostyBirb Plus * Used by Mastodon, Pleroma, and Pixelfed */ interface LegacyMegalodonAccountData { token: string; // Access token website: string; // Instance URL (e.g., "mastodon.social") username: string; } /** * Transforms legacy Megalodon-based account data to modern format. * This transformer is used for Mastodon, Pleroma, and Pixelfed. * * Field mappings: * - token → accessToken * - website → instanceUrl * - username → username * * Note: OAuth client credentials (clientId, clientSecret) are not * preserved from legacy. The token should still work, but users * may need to re-authenticate if the token expires. */ export class MegalodonDataTransformer implements LegacyWebsiteDataTransformer { transform(legacyData: LegacyMegalodonAccountData): MegalodonAccountData | null { if (!legacyData) { return null; } // Must have token and instance to be useful if (!legacyData.token || !legacyData.website) { return null; } return { accessToken: legacyData.token, instanceUrl: this.normalizeInstanceUrl(legacyData.website), username: legacyData.username, // These are not available from legacy but may be populated on next login clientId: undefined, clientSecret: undefined, displayName: undefined, instanceType: undefined, }; } /** * Normalize instance URL to consistent format (without protocol or trailing slash). */ private normalizeInstanceUrl(url: string): string { let normalized = url.trim().toLowerCase(); normalized = normalized.replace(/^(https?:\/\/)/, ''); normalized = normalized.replace(/\/$/, ''); return normalized; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/implementations/telegram-data-transformer.ts ================================================ import { TelegramAccountData } from '@postybirb/types'; import { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer'; /** * Legacy Telegram account data structure from PostyBirb Plus */ interface LegacyTelegramAccountData { appId: string; // Note: stored as string in legacy appHash: string; phoneNumber: string; } /** * Transforms legacy Telegram account data to modern format. * * Field mappings: * - appId (string) → appId (number) * - appHash → appHash * - phoneNumber → phoneNumber * - session: undefined (must be re-authenticated) * - channels: [] (must be re-fetched after login) * * Note: Legacy Telegram sessions cannot be migrated as they use * a different session storage mechanism. Users will need to * re-authenticate after import. */ export class TelegramDataTransformer implements LegacyWebsiteDataTransformer { transform(legacyData: LegacyTelegramAccountData): TelegramAccountData | null { if (!legacyData) { return null; } // Must have app credentials to be useful if (!legacyData.appId || !legacyData.appHash) { return null; } return { appId: parseInt(legacyData.appId, 10), appHash: legacyData.appHash, phoneNumber: legacyData.phoneNumber ?? '', session: undefined, // Cannot migrate session, requires re-auth channels: [], // Will be populated after re-authentication }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/implementations/twitter-data-transformer.ts ================================================ import { TwitterAccountData } from '@postybirb/types'; import { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer'; /** * Legacy Twitter account data structure from PostyBirb Plus */ interface LegacyTwitterAccountData { key: string; // Consumer key (API key) secret: string; // Consumer secret (API secret) oauth_token: string; // Access token oauth_token_secret: string; // Access token secret screen_name: string; // Username user_id: number; // User ID } /** * Transforms legacy Twitter account data to modern format. * * Field mappings: * - key → apiKey * - secret → apiSecret * - oauth_token → accessToken * - oauth_token_secret → accessTokenSecret * - screen_name → screenName * - user_id → userId (number to string) */ export class TwitterDataTransformer implements LegacyWebsiteDataTransformer { transform(legacyData: LegacyTwitterAccountData): TwitterAccountData | null { if (!legacyData) { return null; } // Must have OAuth tokens to be useful if (!legacyData.oauth_token || !legacyData.oauth_token_secret) { return null; } return { apiKey: legacyData.key, apiSecret: legacyData.secret, accessToken: legacyData.oauth_token, accessTokenSecret: legacyData.oauth_token_secret, screenName: legacyData.screen_name, userId: legacyData.user_id?.toString(), }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/index.ts ================================================ export * from './legacy-website-data-transformer'; export * from './website-data-transformer-registry'; ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/legacy-website-data-transformer.ts ================================================ /** * Interface for transforming legacy website-specific account data * to modern WebsiteData format. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface LegacyWebsiteDataTransformer { /** * Transform legacy account data to modern WebsiteData format. * @param legacyData The legacy website-specific data from account.data * @returns The transformed data for modern WebsiteData.data, or null if transformation fails */ transform(legacyData: TLegacy): TModern | null; } /** * Base transformer that passes through data unchanged. * Useful for websites where the data structure is already compatible. */ export class PassthroughTransformer> implements LegacyWebsiteDataTransformer { transform(legacyData: T): T | null { if (!legacyData) { return null; } return { ...legacyData }; } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/transformers/website-data-transformer-registry.ts ================================================ import { BlueskyDataTransformer } from './implementations/bluesky-data-transformer'; import { CustomDataTransformer } from './implementations/custom-data-transformer'; import { DiscordDataTransformer } from './implementations/discord-data-transformer'; import { E621DataTransformer } from './implementations/e621-data-transformer'; import { InkbunnyDataTransformer } from './implementations/inkbunny-data-transformer'; import { MegalodonDataTransformer } from './implementations/megalodon-data-transformer'; import { TelegramDataTransformer } from './implementations/telegram-data-transformer'; import { TwitterDataTransformer } from './implementations/twitter-data-transformer'; import { LegacyWebsiteDataTransformer } from './legacy-website-data-transformer'; /** * Registry that maps legacy website names to their data transformers. * Only websites with custom login flows that store credentials in WebsiteData * need transformers here. */ export class WebsiteDataTransformerRegistry { private static readonly transformers: Record< string, LegacyWebsiteDataTransformer > = { // OAuth/API key websites Twitter: new TwitterDataTransformer(), Discord: new DiscordDataTransformer(), Telegram: new TelegramDataTransformer(), // Megalodon-based fediverse websites (all use same transformer) Mastodon: new MegalodonDataTransformer(), Pleroma: new MegalodonDataTransformer(), Pixelfed: new MegalodonDataTransformer(), // Direct credential websites Bluesky: new BlueskyDataTransformer(), Inkbunny: new InkbunnyDataTransformer(), e621: new E621DataTransformer(), // Custom webhook website Custom: new CustomDataTransformer(), }; /** * Get the transformer for a legacy website name. * @param legacyWebsiteName The legacy website name (e.g., "Twitter", "Mastodon") * @returns The transformer instance, or undefined if no transformer exists */ static getTransformer( legacyWebsiteName: string, ): LegacyWebsiteDataTransformer | undefined { return this.transformers[legacyWebsiteName]; } /** * Check if a legacy website has a data transformer. * Websites without transformers typically use browser cookies for auth. * @param legacyWebsiteName The legacy website name */ static hasTransformer(legacyWebsiteName: string): boolean { return legacyWebsiteName in this.transformers; } /** * Get all legacy website names that have transformers. */ static getTransformableWebsites(): string[] { return Object.keys(this.transformers); } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/utils/ndjson-parser.ts ================================================ import { Injectable } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { promises as fs } from 'fs'; export interface ParseResult { records: T[]; errors: ParseError[]; } export interface ParseError { line: number; content: string; error: string; } /** * Simple NDJSON (Newline Delimited JSON) parser for NeDB files. * Each line in a NeDB file is a separate JSON object. */ @Injectable() export class NdjsonParser { private readonly logger = Logger(NdjsonParser.name); /** * Parse an NDJSON file and instantiate objects of the specified class */ async parseFile( filePath: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any EntityClass: new (data: any) => T, ): Promise> { const records: T[] = []; const errors: ParseError[] = []; try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Skip empty lines if (!line) { continue; } try { const parsed = JSON.parse(line); const instance = new EntityClass(parsed); records.push(instance); } catch (error) { errors.push({ line: i + 1, content: line.substring(0, 100), // Truncate long lines error: error instanceof Error ? error.message : String(error), }); } } if (errors.length > 0) { this.logger.warn( `Parsed ${filePath}: ${records.length} records, ${errors.length} errors`, ); } else { this.logger.info(`Parsed ${filePath}: ${records.length} records`); } return { records, errors }; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { this.logger.warn(`File not found: ${filePath}`); return { records: [], errors: [] }; } this.logger.error(`Error reading ${filePath}: ${error}`); throw error; } } /** * Check if a file exists */ async fileExists(filePath: string): Promise { try { await fs.access(filePath); return true; } catch { return false; } } } ================================================ FILE: apps/client-server/src/app/legacy-database-importer/utils/website-name-mapper.ts ================================================ /** * Maps legacy PostyBirb Plus website names to new PostyBirb V4 website IDs. * The website ID in V4 is the value from the @WebsiteMetadata({ name }) decorator. * Legacy uses PascalCase names, V4 uses kebab-case IDs. */ export class WebsiteNameMapper { private static readonly LEGACY_TO_NEW: Record = { // Direct mappings (kebab-case in V4) Artconomy: 'artconomy', Aryion: 'aryion', Bluesky: 'bluesky', Custom: 'custom', Derpibooru: 'derpibooru', DeviantArt: 'deviant-art', Discord: 'discord', FurAffinity: 'fur-affinity', Furbooru: 'furbooru', FurryNetwork: null, // Deprecated - no longer exists in V4 HentaiFoundry: 'hentai-foundry', Inkbunny: 'inkbunny', Itaku: 'itaku', KoFi: 'ko-fi', Manebooru: 'manebooru', Mastodon: 'mastodon', MissKey: 'misskey', Newgrounds: 'newgrounds', Patreon: 'patreon', Picarto: 'picarto', Piczel: 'piczel', Pillowfort: 'pillowfort', Pixelfed: 'pixelfed', Pixiv: 'pixiv', Pleroma: 'pleroma', SoFurry: 'sofurry', SubscribeStar: 'subscribe-star', SubscribeStarAdult: 'subscribe-star', // Maps to same base (Adult variant handled differently in V4) Telegram: 'telegram', Tumblr: 'tumblr', Twitter: 'twitter', Weasyl: 'weasyl', e621: 'e621', // New websites in V4 that didn't exist in Plus: // cara: 'cara', // firefish: 'firefish', // friendica: 'friendica', // gotosocial: 'gotosocial', // toyhouse: 'toyhouse', }; /** * Map a legacy website name to the new website name */ static map(legacyName: string): string | null { return this.LEGACY_TO_NEW[legacyName] || null; } /** * Map multiple legacy website names */ static mapMany( legacyNames: string[], ): Array<{ legacy: string; new: string | null }> { return legacyNames.map((legacy) => ({ legacy, new: this.map(legacy), })); } /** * Check if a legacy website name has a mapping */ static hasMapping(legacyName: string): boolean { return legacyName in this.LEGACY_TO_NEW; } /** * Get all legacy website names that have mappings */ static getAllLegacyNames(): string[] { return Object.keys(this.LEGACY_TO_NEW); } /** * Get all new website names */ static getAllNewNames(): string[] { return Array.from(new Set(Object.values(this.LEGACY_TO_NEW))); } } ================================================ FILE: apps/client-server/src/app/logs/logs.controller.ts ================================================ import { Controller, Get, Res } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { LogsService } from './logs.service'; /** * Controller for log file operations. * Provides an endpoint to download all logs as a .tar.gz archive. */ @ApiTags('logs') @Controller('logs') export class LogsController { constructor(private readonly service: LogsService) {} @Get('download') @ApiOkResponse({ description: 'Returns a .tar.gz archive of all log files.' }) download(@Res() response) { const archive = this.service.getLogsArchive(); const date = new Date().toISOString().split('T')[0]; response.set({ 'Content-Type': 'application/gzip', 'Content-Disposition': `attachment; filename=postybirb-logs-${date}.tar.gz`, 'Content-Length': archive.length, }); response.send(archive); } } ================================================ FILE: apps/client-server/src/app/logs/logs.module.ts ================================================ import { Module } from '@nestjs/common'; import { LogsController } from './logs.controller'; import { LogsService } from './logs.service'; @Module({ providers: [LogsService], controllers: [LogsController], exports: [LogsService], }) export class LogsModule {} ================================================ FILE: apps/client-server/src/app/logs/logs.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { PostyBirbDirectories } from '@postybirb/fs'; import { Logger } from '@postybirb/logger'; import { readdirSync, readFileSync } from 'fs'; import { join } from 'path'; import { gzipSync } from 'zlib'; /** * Service for managing log file operations. * Provides the ability to bundle all log files into a downloadable .tar.gz archive. */ @Injectable() export class LogsService { private readonly logger = Logger('LogsService'); /** * Creates a gzipped tar archive of all files in the logs directory. * Uses Node.js built-in zlib (no external dependencies). * * @returns A Buffer containing the .tar.gz archive */ getLogsArchive(): Buffer { const logsDir = PostyBirbDirectories.LOGS_DIRECTORY; this.logger.info(`Creating logs archive from: ${logsDir}`); const files = readdirSync(logsDir); const tarBuffer = this.createTar(logsDir, files); return gzipSync(tarBuffer); } /** * Creates a tar archive buffer from the given files. * Implements a minimal tar writer (POSIX ustar format) sufficient for * bundling flat log files. */ private createTar(dir: string, fileNames: string[]): Buffer { const blocks: Buffer[] = []; for (const fileName of fileNames) { try { const filePath = join(dir, fileName); const content = readFileSync(filePath); const header = this.createTarHeader(fileName, content.length); blocks.push(header); blocks.push(content); // Pad content to 512-byte boundary const remainder = content.length % 512; if (remainder > 0) { blocks.push(Buffer.alloc(512 - remainder)); } } catch (err) { this.logger.withError(err).warn(`Skipping file: ${fileName}`); } } // End-of-archive marker: two 512-byte blocks of zeros blocks.push(Buffer.alloc(1024)); return Buffer.concat(blocks); } /** * Creates a 512-byte tar header for a single file entry. */ private createTarHeader(fileName: string, fileSize: number): Buffer { const header = Buffer.alloc(512); // File name (0–99, 100 bytes) header.write(fileName.slice(0, 100), 0, 100, 'utf-8'); // File mode (100–107, 8 bytes) — 0644 header.write('0000644\0', 100, 8, 'utf-8'); // Owner UID (108–115, 8 bytes) header.write('0000000\0', 108, 8, 'utf-8'); // Group GID (116–123, 8 bytes) header.write('0000000\0', 116, 8, 'utf-8'); // File size in octal (124–135, 12 bytes) header.write(`${fileSize.toString(8).padStart(11, '0')}\0`, 124, 12, 'utf-8'); // Modification time in octal (136–147, 12 bytes) const mtime = Math.floor(Date.now() / 1000); header.write(`${mtime.toString(8).padStart(11, '0')}\0`, 136, 12, 'utf-8'); // Type flag (156, 1 byte) — '0' for regular file header.write('0', 156, 1, 'utf-8'); // USTAR magic (257–262, 6 bytes) + version (263–264, 2 bytes) header.write('ustar\0', 257, 6, 'utf-8'); header.write('00', 263, 2, 'utf-8'); // Checksum placeholder: fill with spaces first (148–155, 8 bytes) header.write(' ', 148, 8, 'utf-8'); // Compute checksum (sum of all unsigned bytes in the header) let checksum = 0; for (let i = 0; i < 512; i++) { checksum += header[i]; } // Write checksum in octal, null-terminated, space-padded header.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'utf-8'); return header; } } ================================================ FILE: apps/client-server/src/app/notifications/dtos/create-notification.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ICreateNotificationDto } from '@postybirb/types'; import { IsArray, IsObject, IsString } from 'class-validator'; export class CreateNotificationDto implements ICreateNotificationDto { @ApiProperty() @IsObject() data: Record = {}; @ApiProperty() @IsString() title: string; @ApiProperty() @IsString() message: string; @ApiProperty() @IsArray() @IsString({ each: true }) tags: string[] = []; @ApiProperty() @IsString() type: 'warning' | 'error' | 'info' | 'success' = 'info'; } ================================================ FILE: apps/client-server/src/app/notifications/dtos/update-notification.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IUpdateNotificationDto } from '@postybirb/types'; import { IsBoolean } from 'class-validator'; export class UpdateNotificationDto implements IUpdateNotificationDto { @ApiProperty() @IsBoolean() isRead: boolean; @ApiProperty() @IsBoolean() hasEmitted: boolean; } ================================================ FILE: apps/client-server/src/app/notifications/notification.events.ts ================================================ import { NOTIFICATION_UPDATES } from '@postybirb/socket-events'; import { INotification } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type NotificationEventTypes = NotificationEvent; class NotificationEvent implements WebsocketEvent { event: string = NOTIFICATION_UPDATES; data: INotification[]; } ================================================ FILE: apps/client-server/src/app/notifications/notifications.controller.ts ================================================ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { EntityId } from '@postybirb/types'; import { CreateNotificationDto } from './dtos/create-notification.dto'; import { UpdateNotificationDto } from './dtos/update-notification.dto'; import { NotificationsService } from './notifications.service'; /** * @class NotificationsController */ @ApiTags('notifications') @Controller('notifications') export class NotificationsController { constructor(readonly service: NotificationsService) {} @Post() @ApiOkResponse({ description: 'Notification created.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) create(@Body() createDto: CreateNotificationDto) { return this.service.create(createDto).then((entity) => entity.toDTO()); } @Patch(':id') @ApiOkResponse({ description: 'Notification updated.' }) @ApiNotFoundResponse({ description: 'Notification not found.' }) update(@Param('id') id: EntityId, @Body() updateDto: UpdateNotificationDto) { return this.service.update(id, updateDto).then((entity) => entity.toDTO()); } @Get() @ApiOkResponse({ description: 'A list of all records.' }) findAll() { return this.service .findAll() .then((records) => records.map((record) => record.toDTO())); } @Delete() @ApiOkResponse({ description: 'Notification deleted.' }) @ApiNotFoundResponse({ description: 'Notification not found.' }) async remove(@Query('ids') ids: EntityId | EntityId[]) { return Promise.all( (Array.isArray(ids) ? ids : [ids]).map((id) => this.service.remove(id)), ).then(() => ({ success: true, })); } } ================================================ FILE: apps/client-server/src/app/notifications/notifications.module.ts ================================================ import { Module } from '@nestjs/common'; import { AccountModule } from '../account/account.module'; import { SettingsModule } from '../settings/settings.module'; import { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module'; import { WebsitesModule } from '../websites/websites.module'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; @Module({ imports: [ WebsitesModule, UserSpecifiedWebsiteOptionsModule, AccountModule, SettingsModule, ], providers: [NotificationsService], controllers: [NotificationsController], exports: [NotificationsService], }) export class NotificationsModule {} ================================================ FILE: apps/client-server/src/app/notifications/notifications.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { SettingsService } from '../settings/settings.service'; import { WSGateway } from '../web-socket/web-socket-gateway'; import { CreateNotificationDto } from './dtos/create-notification.dto'; import { UpdateNotificationDto } from './dtos/update-notification.dto'; import { NotificationsService } from './notifications.service'; describe('NotificationsService', () => { let service: NotificationsService; let module: TestingModule; // eslint-disable-next-line @typescript-eslint/no-explicit-any let webSocketMock: any; beforeEach(async () => { clearDatabase(); webSocketMock = { emit: jest.fn(), }; module = await Test.createTestingModule({ providers: [ NotificationsService, SettingsService, { provide: WSGateway, useValue: webSocketMock, }, ], }).compile(); service = module.get(NotificationsService); }); afterAll(async () => { await module?.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should create a notification', async () => { const dto = new CreateNotificationDto(); dto.title = 'Test Notification'; dto.message = 'This is a test notification'; dto.type = 'info'; const notification = await service.create(dto); expect(notification).toBeDefined(); expect(notification.title).toBe(dto.title); expect(notification.message).toBe(dto.message); expect(notification.type).toBe(dto.type); const notifications = await service.findAll(); expect(notifications).toHaveLength(1); expect(notifications[0].id).toBe(notification.id); }); it('should update a notification', async () => { const createDto = new CreateNotificationDto(); createDto.title = 'Initial Title'; createDto.message = 'Initial Message'; createDto.type = 'info'; const notification = await service.create(createDto); const updateDto = new UpdateNotificationDto(); updateDto.isRead = true; await service.update(notification.id, updateDto); const updatedNotification = await service.findById(notification.id); expect(updatedNotification.message).toBe(createDto.message); // unchanged expect(updatedNotification.isRead).toBe(true); }); it('should emit notification updates when changes occur', async () => { // Create a notification which should trigger an emit const dto = new CreateNotificationDto(); dto.title = 'Test Notification'; dto.message = 'This is a test notification'; dto.type = 'info'; await service.create(dto); // Verify websocket emit was called with the correct event expect(webSocketMock.emit).toHaveBeenCalled(); const emitArgs = webSocketMock.emit.mock.calls[0]; expect(emitArgs[0].data[0].title).toBe(dto.title); }); it('should initialize without websocket and not throw error', () => { // @ts-expect-error Test case const serviceWithoutWebsocket = new NotificationsService(); expect(serviceWithoutWebsocket).toBeDefined(); }); }); ================================================ FILE: apps/client-server/src/app/notifications/notifications.service.ts ================================================ import { Injectable, Optional } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NOTIFICATION_UPDATES } from '@postybirb/socket-events'; import { EntityId } from '@postybirb/types'; import { Notification as ElectronNotification } from 'electron'; import { PostyBirbService } from '../common/service/postybirb-service'; import { Notification } from '../drizzle/models/notification.entity'; import { SettingsService } from '../settings/settings.service'; import { WSGateway } from '../web-socket/web-socket-gateway'; import { CreateNotificationDto } from './dtos/create-notification.dto'; import { UpdateNotificationDto } from './dtos/update-notification.dto'; /** * Service responsible for managing application notifications. * Handles creation, updating, and removal of notifications, as well as * sending desktop notifications based on user settings. */ @Injectable() export class NotificationsService extends PostyBirbService<'NotificationSchema'> { /** * Creates a new instance of the NotificationsService. * * @param settingsService - Service for accessing application settings * @param webSocket - Optional websocket gateway for emitting events */ constructor( private readonly settingsService: SettingsService, @Optional() webSocket?: WSGateway, ) { super('NotificationSchema', webSocket); this.repository.subscribe('NotificationSchema', () => this.emit()); this.removeStaleNotifications(); } /** * Removes notifications older than one month. * Runs automatically every hour via cron job. */ @Cron(CronExpression.EVERY_HOUR) private async removeStaleNotifications() { const notifications = await this.repository.findAll(); const aMonthAgo = new Date(); aMonthAgo.setMonth(aMonthAgo.getMonth() - 1); const staleNotifications = notifications.filter( (notification) => new Date(notification.createdAt).getTime() < aMonthAgo.getTime(), ); if (staleNotifications.length) { await this.repository.deleteById(staleNotifications.map((n) => n.id)); } } /** * Creates a new notification and optionally sends a desktop notification. * * @param createDto - The notification data to create * @param sendDesktopNotification - Whether to also send a desktop notification * @returns The created notification entity */ async create( createDto: CreateNotificationDto, sendDesktopNotification = false, ): Promise { this.logger .withMetadata(createDto) .info(`Creating notification '${createDto.title}'`); if (sendDesktopNotification) { this.sendDesktopNotification(createDto); } return this.repository.insert(createDto); } /** * Trims notifications to a maximum of 250, removing the oldest first. * Runs every 5 minutes. */ @Cron(CronExpression.EVERY_5_MINUTES) private async trimNotifications() { const notifications = await this.repository.findAll(); if (notifications.length <= 250) { return; } const sorted = notifications.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); const toRemove = sorted.slice(0, notifications.length - 250); await this.repository.deleteById(toRemove.map((n) => n.id)); } /** * Sends a desktop notification based on user settings and notification type. * * @param notification - The notification data to display */ async sendDesktopNotification( notification: CreateNotificationDto, ): Promise { const { settings } = await this.settingsService.getDefaultSettings(); const { desktopNotifications } = settings; const { tags, title, message, type } = notification; if (!desktopNotifications.enabled) { return; } if ( desktopNotifications.showOnDirectoryWatcherError && tags.includes('directory-watcher') && type === 'error' ) { new ElectronNotification({ title, body: message, }).show(); } if ( desktopNotifications.showOnDirectoryWatcherSuccess && tags.includes('directory-watcher') && type === 'success' ) { new ElectronNotification({ title, body: message, }).show(); } if ( desktopNotifications.showOnPostError && tags.includes('post') && type === 'error' ) { new ElectronNotification({ title, body: message, }).show(); } if ( desktopNotifications.showOnPostSuccess && tags.includes('post') && type === 'success' ) { new ElectronNotification({ title, body: message, }).show(); } } /** * Updates an existing notification. * * @param id - The ID of the notification to update * @param update - The data to update * @returns The updated notification */ update(id: EntityId, update: UpdateNotificationDto) { this.logger.withMetadata(update).info(`Updating notification '${id}'`); return this.repository.update(id, update); } /** * Emits notification updates to connected clients. * Converts entities to DTOs before sending. */ protected async emit() { super.emit({ event: NOTIFICATION_UPDATES, data: (await this.repository.findAll()).map((entity) => entity.toDTO()), }); } } ================================================ FILE: apps/client-server/src/app/post/dtos/post-queue-action.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IPostQueueActionDto, PostRecordResumeMode } from '@postybirb/types'; import { ArrayNotEmpty, IsArray, IsEnum, IsOptional } from 'class-validator'; export class PostQueueActionDto implements IPostQueueActionDto { @ApiProperty() @IsArray() @ArrayNotEmpty() submissionIds: string[]; @ApiProperty({ enum: PostRecordResumeMode, required: false }) @IsOptional() @IsEnum(PostRecordResumeMode) resumeMode?: PostRecordResumeMode; } ================================================ FILE: apps/client-server/src/app/post/dtos/queue-post-record.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IQueuePostRecordRequestDto, SubmissionId } from '@postybirb/types'; import { ArrayNotEmpty, IsArray } from 'class-validator'; /** @inheritdoc */ export class QueuePostRecordRequestDto implements IQueuePostRecordRequestDto { @ApiProperty() @IsArray() @ArrayNotEmpty() ids: SubmissionId[]; } ================================================ FILE: apps/client-server/src/app/post/errors/index.ts ================================================ export * from './invalid-post-chain.error'; ================================================ FILE: apps/client-server/src/app/post/errors/invalid-post-chain.error.ts ================================================ import { EntityId, PostRecordResumeMode } from '@postybirb/types'; /** * Error thrown when attempting to create a PostRecord in an invalid state. * * This indicates a bug in the calling code - the caller should have verified * the submission state before requesting a PostRecord creation. * * Error reasons: * - no_origin: CONTINUE/RETRY requested but no prior NEW PostRecord exists * - origin_done: CONTINUE/RETRY requested but the origin NEW record is DONE (chain closed) * - in_progress: A PostRecord for this submission is already PENDING or RUNNING */ export class InvalidPostChainError extends Error { public readonly submissionId: EntityId; public readonly requestedResumeMode: PostRecordResumeMode; public readonly reason: 'no_origin' | 'origin_done' | 'in_progress'; constructor( submissionId: EntityId, requestedResumeMode: PostRecordResumeMode, reason: 'no_origin' | 'origin_done' | 'in_progress', ) { let message: string; // eslint-disable-next-line default-case switch (reason) { case 'no_origin': message = `Cannot create ${requestedResumeMode} PostRecord for submission ${submissionId}: no prior NEW PostRecord found to chain from`; break; case 'origin_done': message = `Cannot create ${requestedResumeMode} PostRecord for submission ${submissionId}: the origin NEW PostRecord is already DONE (chain is closed)`; break; case 'in_progress': message = `Cannot create ${requestedResumeMode} PostRecord for submission ${submissionId}: a PostRecord is already PENDING or RUNNING`; break; } super(message); this.name = 'InvalidPostChainError'; this.submissionId = submissionId; this.requestedResumeMode = requestedResumeMode; this.reason = reason; } } ================================================ FILE: apps/client-server/src/app/post/models/cancellable-token.ts ================================================ import { CancellationError } from './cancellation-error'; /** * CancellableToken is a simple class that can be used to cancel a task. * @class CancellableToken */ export class CancellableToken { private cancelled = false; public get isCancelled(): boolean { return this.cancelled; } public cancel(): void { this.cancelled = true; } public throwIfCancelled(): void { if (this.cancelled) { throw new CancellationError(); } } } ================================================ FILE: apps/client-server/src/app/post/models/cancellation-error.ts ================================================ /** * CancellationError is thrown when a task is cancelled. * @class CancellationError */ export class CancellationError extends Error { constructor(message = 'Task was cancelled.') { super(message); this.name = 'CancellationError'; // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, CancellationError); } } } ================================================ FILE: apps/client-server/src/app/post/models/posting-file.ts ================================================ import { FormFile } from '@postybirb/http'; import { FileType, IFileBuffer, SubmissionFileId, SubmissionFileMetadata, } from '@postybirb/types'; import { getFileType } from '@postybirb/utils/file-type'; import { parse } from 'path'; export type ThumbnailOptions = Pick< IFileBuffer, 'buffer' | 'height' | 'width' | 'mimeType' | 'fileName' >; export type FormDataFileFormat = { value: Buffer; options: { contentType: string; filename: string; }; }; export class PostingFile { public readonly id: SubmissionFileId; public readonly buffer: Buffer; public readonly mimeType: string; public readonly fileType: FileType; public readonly fileName: string; public readonly width: number; public readonly height: number; public metadata: SubmissionFileMetadata; public readonly thumbnail?: ThumbnailOptions; public constructor( id: SubmissionFileId, file: IFileBuffer, thumbnail?: ThumbnailOptions, ) { this.id = id; this.buffer = file.buffer; this.mimeType = file.mimeType; this.width = file.width; this.height = file.height; this.fileType = getFileType(file.fileName); this.fileName = this.normalizeFileName(file); this.thumbnail = thumbnail; } private normalizeFileName(file: IFileBuffer): string { const { ext } = parse(file.fileName); return `${file.id}${ext}`; } public withMetadata(metadata: SubmissionFileMetadata): PostingFile { this.metadata = metadata; return this; } public toPostFormat(): FormFile { return new FormFile(this.buffer, { contentType: this.mimeType, filename: this.fileName, }); } public thumbnailToPostFormat(): FormFile | undefined { if (!this.thumbnail) { return undefined; } return new FormFile(this.thumbnail.buffer, { contentType: this.thumbnail.mimeType, filename: this.thumbnail.fileName, }); } } ================================================ FILE: apps/client-server/src/app/post/post.controller.ts ================================================ import { Controller, Get, Param } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { EntityId } from '@postybirb/types'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { PostService } from './post.service'; /** * Queue operations for Post data. * @class PostController */ @ApiTags('post') @Controller('post') export class PostController extends PostyBirbController<'PostRecordSchema'> { constructor(readonly service: PostService) { super(service); } /** * Get all events for a specific post record. * Returns the immutable event ledger showing all posting actions. * * @param {EntityId} id - The post record ID * @returns {Promise} Array of post events */ @Get(':id/events') @ApiOkResponse({ description: 'Events for the post record.' }) async getEvents(@Param('id') id: EntityId) { return this.service.getEvents(id); } } ================================================ FILE: apps/client-server/src/app/post/post.module.ts ================================================ import { Module } from '@nestjs/common'; import { FileConverterModule } from '../file-converter/file-converter.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PostParsersModule } from '../post-parsers/post-parsers.module'; import { SettingsModule } from '../settings/settings.module'; import { SubmissionModule } from '../submission/submission.module'; import { ValidationModule } from '../validation/validation.module'; import { WebsiteOptionsModule } from '../website-options/website-options.module'; import { WebsiteImplProvider } from '../websites/implementations/provider'; import { WebsitesModule } from '../websites/websites.module'; import { PostController } from './post.controller'; import { PostService } from './post.service'; import { PostFileResizerService } from './services/post-file-resizer/post-file-resizer.service'; import { FileSubmissionPostManager, MessageSubmissionPostManager, PostManagerRegistry, } from './services/post-manager-v2'; import { PostManagerController } from './services/post-manager/post-manager.controller'; import { PostQueueController } from './services/post-queue/post-queue.controller'; import { PostQueueService } from './services/post-queue/post-queue.service'; import { PostEventRepository, PostRecordFactory, } from './services/post-record-factory'; @Module({ imports: [ WebsiteOptionsModule, WebsitesModule, PostParsersModule, ValidationModule, FileConverterModule, SettingsModule, SubmissionModule, NotificationsModule, ], controllers: [PostController, PostQueueController, PostManagerController], providers: [ PostService, PostFileResizerService, WebsiteImplProvider, PostQueueService, PostEventRepository, PostRecordFactory, FileSubmissionPostManager, MessageSubmissionPostManager, PostManagerRegistry, ], exports: [PostEventRepository, PostRecordFactory, PostManagerRegistry], }) export class PostModule {} ================================================ FILE: apps/client-server/src/app/post/post.service.ts ================================================ import { Injectable, Optional } from '@nestjs/common'; import { EntityId, PostEventDto } from '@postybirb/types'; import { PostyBirbService } from '../common/service/postybirb-service'; import { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database'; import { WSGateway } from '../web-socket/web-socket-gateway'; /** * Simple entity service for post records. * @class PostService */ @Injectable() export class PostService extends PostyBirbService<'PostRecordSchema'> { private readonly postEventRepository = new PostyBirbDatabase( 'PostEventSchema', ); constructor(@Optional() webSocket?: WSGateway) { super('PostRecordSchema', webSocket); } /** * Get all events for a specific post record. * * @param {EntityId} postRecordId - The post record ID * @returns {Promise} Array of post events */ async getEvents(postRecordId: EntityId): Promise { const events = await this.postEventRepository.find({ where: (event, { eq }) => eq(event.postRecordId, postRecordId), orderBy: (event, { asc }) => asc(event.createdAt), with: { account: true, }, }); return events.map((event) => event.toDTO()); } } ================================================ FILE: apps/client-server/src/app/post/services/post-file-resizer/post-file-resizer.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { DefaultSubmissionFileMetadata, ISubmission, ISubmissionFile, } from '@postybirb/types'; import { readFileSync } from 'fs'; import { join } from 'path'; import { SharpInstanceManager } from '../../../image-processing/sharp-instance-manager'; import { PostFileResizerService } from './post-file-resizer.service'; describe('PostFileResizerService', () => { let service: PostFileResizerService; let sharpManager: SharpInstanceManager; let module: TestingModule; let testFile: Buffer; let file: ISubmissionFile; function createFile( fileName: string, mimeType: string, height: number, width: number, buf: Buffer, ): ISubmissionFile { return { id: 'test', fileName, hash: 'test', mimeType, size: buf.length, hasThumbnail: false, hasCustomThumbnail: false, hasAltFile: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), submission: {} as ISubmission, width, height, primaryFileId: 'test', submissionId: 'test', metadata: DefaultSubmissionFileMetadata(), order: 0, file: { submissionFileId: 'test', fileName, mimeType, id: 'test', buffer: buf, size: buf.length, width, height, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, }; } beforeAll(async () => { testFile = readFileSync( join(__dirname, '../../../../test-files/small_image.jpg'), ); file = createFile('test.jpg', 'image/jpeg', 202, 138, testFile); module = await Test.createTestingModule({ providers: [PostFileResizerService, SharpInstanceManager], }).compile(); service = module.get(PostFileResizerService); sharpManager = module.get(SharpInstanceManager); }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should resize image', async () => { const resized = await service.resize({ file, resize: { width: 100 } }); expect(resized.buffer.length).toBeLessThan(testFile.length); const metadata = await sharpManager.getMetadata(resized.buffer); expect(metadata.width).toBe(100); expect(metadata.height).toBeLessThan(202); expect(resized.fileName).toBe('test.jpeg'); expect(resized.thumbnail).toBeDefined(); }); it('should not resize image', async () => { const resized = await service.resize({ file, resize: { width: 300 } }); expect(resized.buffer.length).toBe(testFile.length); const metadata = await sharpManager.getMetadata(resized.buffer); expect(metadata.width).toBe(file.width); expect(metadata.height).toBe(file.height); expect(resized.fileName).toBe('test.jpg'); }); it('should scale down image', async () => { const resized = await service.resize({ file, resize: { maxBytes: testFile.length - 1000 }, }); expect(resized.buffer.length).toBeLessThan(testFile.length); expect(resized.thumbnail?.buffer.length).toBeLessThan(testFile.length); expect(resized.fileName).toBe('test.jpeg'); expect(resized.thumbnail?.fileName).toBe('test.jpg'); expect(resized.mimeType).toBe('image/jpeg'); }); it('should fail to scale down image', async () => { await expect( service.resize({ file, resize: { maxBytes: -1 }, }), ).rejects.toThrow(); }); it('should not convert png thumbnail with alpha to jpeg', async () => { const noAlphaFile = readFileSync( join(__dirname, '../../../../test-files/png_with_alpha.png'), ); const tf = createFile('test.png', 'image/png', 600, 600, noAlphaFile); const resized = await service.resize({ file: tf, resize: { maxBytes: noAlphaFile.length - 1000 }, }); expect(resized.buffer.length).toBeLessThan(noAlphaFile.length); expect(resized.fileName).toBe('test.png'); expect(resized.thumbnail?.buffer.length).toBeLessThan(noAlphaFile.length); expect(resized.thumbnail?.fileName).toBe('test.png'); }); }); ================================================ FILE: apps/client-server/src/app/post/services/post-file-resizer/post-file-resizer.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { FileType, IFileBuffer, ImageResizeProps, ISubmissionFile, } from '@postybirb/types'; import { getFileType } from '@postybirb/utils/file-type'; import { SharpInstanceManager } from '../../../image-processing/sharp-instance-manager'; import { PostingFile, ThumbnailOptions } from '../../models/posting-file'; type ResizeRequest = { file: ISubmissionFile; resize?: ImageResizeProps; }; /** * Responsible for resizing an image file to a smaller size before * posting to a website. * * All sharp/libvips work is delegated to the SharpInstanceManager, * which runs sharp in isolated worker threads. If libvips crashes * (e.g. after long idle), only the worker dies — the main process * survives. * * @class PostFileResizer */ @Injectable() export class PostFileResizerService { private readonly logger = Logger(); constructor( private readonly sharpInstanceManager: SharpInstanceManager, ) {} public async resize(request: ResizeRequest): Promise { return this.process(request); } private async process(request: ResizeRequest): Promise { const { resize } = request; const { file } = request; this.logger.withMetadata({ resize }).info('Resizing image...'); if (!file.file) { throw new Error('File buffer is missing'); } const primaryFile = await this.processPrimaryFile(file, resize); const thumbnail = await this.processThumbnailFile(file); const newPostingFile = new PostingFile( file.id, primaryFile, thumbnail, ); newPostingFile.metadata = file.metadata; return newPostingFile; } private async processPrimaryFile( file: ISubmissionFile, resize?: ImageResizeProps, ): Promise { if (!resize) return file.file; const result = await this.sharpInstanceManager.resizeForPost({ buffer: file.file.buffer, resize, mimeType: file.mimeType, fileName: file.fileName, fileId: file.id, fileWidth: file.file.width, fileHeight: file.file.height, generateThumbnail: false, }); if (result.modified && result.buffer) { return { ...file.file, fileName: result.fileName || `${file.id}.${result.format}`, buffer: result.buffer, mimeType: result.mimeType || file.mimeType, height: result.height || file.file.height, width: result.width || file.file.width, }; } return file.file; } private async processThumbnailFile( file: ISubmissionFile, ): Promise { let thumb = file.thumbnail; const shouldProcessThumbnail = !!thumb || getFileType(file.fileName) === FileType.IMAGE; if (!shouldProcessThumbnail) { return undefined; } thumb = thumb ?? { ...file.file }; // Ensure file to process const result = await this.sharpInstanceManager.generateThumbnail( thumb.buffer, thumb.mimeType, thumb.fileName, 500, ); return { buffer: result.buffer, fileName: thumb.fileName, mimeType: thumb.mimeType, height: result.height, width: result.width, }; } } ================================================ FILE: apps/client-server/src/app/post/services/post-manager/post-manager.controller.ts ================================================ import { Controller, Get, Param, Post } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { SubmissionId, SubmissionType } from '@postybirb/types'; import { PostManagerRegistry } from '../post-manager-v2'; import { PostQueueService } from '../post-queue/post-queue.service'; @ApiTags('post-manager') @Controller('post-manager') export class PostManagerController { constructor( readonly service: PostManagerRegistry, private readonly postQueueService: PostQueueService, ) {} @Post('cancel/:id') @ApiOkResponse({ description: 'Post cancelled if running.' }) async cancelIfRunning(@Param('id') id: SubmissionId) { // Use post-queue's dequeue which ensures all db records are properly handled await this.postQueueService.dequeue([id]); return true; } @Get('is-posting/:submissionType') @ApiOkResponse({ description: 'Check if a post is in progress.' }) async isPosting(@Param('submissionType') submissionType: SubmissionType) { return { isPosting: this.service.getManager(submissionType).isPosting() }; } } ================================================ FILE: apps/client-server/src/app/post/services/post-manager-v2/base-post-manager.service.ts ================================================ import { Logger, trackEvent, trackException, trackMetric, } from '@postybirb/logger'; import { AccountId, EntityId, PostData, PostEventType, PostRecordState, PostResponse, SubmissionType, } from '@postybirb/types'; import { PostRecord, Submission, WebsiteOptions, } from '../../../drizzle/models'; import { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database'; import { NotificationsService } from '../../../notifications/notifications.service'; import { PostParsersService } from '../../../post-parsers/post-parsers.service'; import { ValidationService } from '../../../validation/validation.service'; import { UnknownWebsite, Website } from '../../../websites/website'; import { WebsiteRegistryService } from '../../../websites/website-registry.service'; import { CancellableToken } from '../../models/cancellable-token'; import { CancellationError } from '../../models/cancellation-error'; import { PostEventRepository, ResumeContext } from '../post-record-factory'; /** * Website info for posting order. */ interface WebsiteInfo { accountId: AccountId; instance: Website; } /** * Abstract base class for PostManager implementations. * Handles common posting logic and event emission. * @abstract * @class BasePostManager */ export abstract class BasePostManager { protected readonly logger = Logger(this.constructor.name); protected readonly lastTimePostedToWebsite: Record = {}; /** * The current post being processed. */ protected currentPost: PostRecord | null = null; /** * The current cancel token for the current post. */ protected cancelToken: CancellableToken | null = null; /** * Resume context from prior attempts. */ protected resumeContext: ResumeContext | null = null; protected readonly postRepository: PostyBirbDatabase<'PostRecordSchema'>; constructor( protected readonly postEventRepository: PostEventRepository, protected readonly websiteRegistry: WebsiteRegistryService, protected readonly postParserService: PostParsersService, protected readonly validationService: ValidationService, protected readonly notificationService: NotificationsService, ) { this.postRepository = new PostyBirbDatabase('PostRecordSchema'); } /** * Get the submission type this manager handles. * @abstract * @returns {SubmissionType} The submission type */ abstract getSupportedType(): SubmissionType; /** * Cancels the current post if it is running and matches the Id. * @param {EntityId} submissionId - The submission ID to check * @returns {Promise} True if the post was cancelled */ public async cancelIfRunning(submissionId: EntityId): Promise { if (this.currentPost && this.currentPost.submissionId === submissionId) { this.logger.info(`Cancelling current post`); this.cancelToken?.cancel(); return true; } return false; } /** * Check if this manager is currently posting. * @returns {boolean} True if posting */ public isPosting(): boolean { return !!this.currentPost; } /** * Starts a post attempt. * @param {PostRecord} entity - The post record to start * @param {ResumeContext} [resumeContext] - Optional resume context from prior attempts */ public async startPost( entity: PostRecord, resumeContext?: ResumeContext, ): Promise { try { if (this.currentPost) { this.logger.warn( `PostManager is already posting, cannot start new post`, ); return; } this.cancelToken = new CancellableToken(); this.resumeContext = resumeContext || null; this.logger.withMetadata(entity.toDTO()).info(`Initializing post`); this.currentPost = entity; await this.postRepository.update(entity.id, { state: PostRecordState.RUNNING, }); // Posts order occurs in batched groups // Standard websites first, then websites that accept external source urls this.logger.info(`Creating post order`); const postOrderGroups = this.getPostOrder(entity); this.logger.info(`Posting to websites`); for (const websites of postOrderGroups) { this.cancelToken.throwIfCancelled(); await Promise.allSettled( websites.map((w) => this.postToWebsite(entity, w)), ); } this.logger.info(`Finished posting to websites`); } catch (error) { this.logger.withError(error).error(`Error posting`); } finally { await this.finishPost(entity); } } /** * Post to a single website. * @protected * @param {PostRecord} entity - The post record * @param {WebsiteInfo} websiteInfo - The website information */ protected async postToWebsite( entity: PostRecord, websiteInfo: WebsiteInfo, ): Promise { const { submission } = entity; const { accountId, instance } = websiteInfo; let data: PostData | undefined; const option = submission.options.find((o) => o.accountId === accountId); try { await this.ensureLoggedIn(instance); const supportedTypes = instance.getSupportedTypes(); if (!supportedTypes.includes(submission.type)) { throw new Error( `Website '${instance.decoratedProps.metadata.displayName}' does not support ${submission.type}`, ); } this.logger.info('Preparing post data'); data = await this.preparePostData(submission, instance, option); this.logger.withMetadata(data).info('Post data prepared'); // Emit POST_ATTEMPT_STARTED event with post data await this.emitPostAttemptStarted( entity.id, accountId, instance, data, option, ); this.logger.info('Validating submission'); const validationResult = await this.validationService.validateSubmission(submission); if (validationResult.some((v) => v.errors.length > 0)) { throw new Error('Submission contains validation errors'); } await this.attemptToPost(entity, accountId, instance, data); // Emit POST_ATTEMPT_COMPLETED event await this.postEventRepository.insert({ postRecordId: entity.id, accountId, eventType: PostEventType.POST_ATTEMPT_COMPLETED, metadata: { accountSnapshot: { name: instance.account.name, website: instance.decoratedProps.metadata.name, }, }, }); this.lastTimePostedToWebsite[accountId] = new Date(); // Track successful post in App Insights (detailed) const websiteName = instance.decoratedProps.metadata.name; trackEvent('PostSuccess', { website: websiteName, accountId, submissionId: entity.submissionId, submissionType: submission.type, hasSourceUrl: 'unknown', // Individual managers track this fileCount: '0', options: this.redactPostDataForLogging(data), }); // Track success metric per website trackMetric(`post.success.${websiteName}`, 1, { website: websiteName, submissionType: submission.type, }); } catch (error) { this.logger .withError(error) .error(`Error posting to website: ${instance.id}`); const errorResponse = error instanceof PostResponse ? error : PostResponse.fromWebsite(instance) .withException(error) .withMessage( `${ instance.decoratedProps.metadata.displayName || instance.decoratedProps.metadata.name }: ${String(error)}`, ); await this.handlePostFailure( entity.id, accountId, instance, errorResponse, data, ); // Track failure in App Insights (detailed) const websiteName = instance.decoratedProps.metadata.name; // Only track non-cancellation failures if (!(error instanceof CancellationError)) { trackEvent('PostFailure', { website: websiteName, accountId, submissionId: entity.submissionId, submissionType: submission.type, errorMessage: errorResponse.message ?? 'unknown', stage: errorResponse.stage ?? 'unknown', hasException: errorResponse.exception ? 'true' : 'false', fileCount: '0', options: data ? this.redactPostDataForLogging(data) : '', }); // Track failure metric per website trackMetric(`post.failure.${websiteName}`, 1, { website: websiteName, submissionType: submission.type, }); // Track the exception if present if (errorResponse.exception) { trackException(errorResponse.exception, { website: websiteName, accountId, submissionId: entity.submissionId, stage: errorResponse.stage ?? 'unknown', errorMessage: errorResponse.message ?? 'unknown', }); } } } } /** * Emit POST_ATTEMPT_STARTED event. * @protected * @param {EntityId} postRecordId - The post record ID * @param {AccountId} accountId - The account ID * @param {Website} instance - The website instance * @param {PostData} [data] - The post data * @param {WebsiteOptions} [option] - The website options */ protected async emitPostAttemptStarted( postRecordId: EntityId, accountId: AccountId, instance: Website, data?: PostData, option?: WebsiteOptions, ): Promise { await this.postEventRepository.insert({ postRecordId, accountId, eventType: PostEventType.POST_ATTEMPT_STARTED, metadata: { accountSnapshot: { name: instance.account.name, website: instance.decoratedProps.metadata.name, }, postData: data ? { parsedOptions: data.options, websiteOptions: option ? [] : [], // Blank for now; populate if needed later } : undefined, }, }); } /** * Handle post failure and emit appropriate events. * @protected * @param {EntityId} postRecordId - The post record ID * @param {AccountId} accountId - The account ID * @param {Website} instance - The website instance * @param {PostResponse} errorResponse - The error response * @param {PostData} [postData] - The post data */ protected async handlePostFailure( postRecordId: EntityId, accountId: AccountId, instance: Website, errorResponse: PostResponse, postData?: PostData, ): Promise { await this.postEventRepository.insert({ postRecordId, accountId, eventType: PostEventType.POST_ATTEMPT_FAILED, error: { message: errorResponse.message || 'Unknown error', stack: errorResponse.exception?.stack, stage: errorResponse.stage, additionalInfo: errorResponse.additionalInfo, }, metadata: { accountSnapshot: { name: instance.account.name, website: instance.decoratedProps.metadata.name, }, }, }); await this.notificationService.create({ type: 'error', title: `Failed to post to ${instance.decoratedProps.metadata.displayName}`, message: errorResponse.message || 'Unknown error', tags: ['post-failure', instance.decoratedProps.metadata.name], data: {}, }); } /** * Attempt to post to the website based on submission type. * @abstract * @protected * @param {PostRecord} entity - The post record * @param {AccountId} accountId - The account ID * @param {UnknownWebsite} instance - The website instance * @param {PostData} data - The post data */ protected abstract attemptToPost( entity: PostRecord, accountId: AccountId, instance: UnknownWebsite, data: PostData, ): Promise; /** * Prepare post data for a website. * @protected * @param {Submission} submission - The submission * @param {Website} instance - The website instance * @param {WebsiteOptions} [option] - The website options * @returns {Promise} The prepared post data */ protected async preparePostData( submission: Submission, instance: Website, option?: WebsiteOptions, ): Promise { return this.postParserService.parse(submission, instance, option); } /** * Get post order for websites. * Groups websites into batches - standard websites first, then websites that accept external sources. * @protected * @param {PostRecord} entity - The post record * @returns {WebsiteInfo[][]} Batched website info */ protected getPostOrder(entity: PostRecord): WebsiteInfo[][] { const { submission } = entity; const websiteInfos: WebsiteInfo[] = []; for (const option of submission.options) { const instance = this.websiteRegistry.findInstance(option.account); if (!instance) { this.logger.warn(`Website instance not found for ${option.accountId}`); continue; } if (!instance.getSupportedTypes().includes(submission.type)) { this.logger.warn( `Website ${instance.id} does not support ${submission.type}`, ); continue; } // Skip if account is completed (based on resume context) if ( this.resumeContext && this.resumeContext.completedAccountIds.has(option.accountId) ) { this.logger.info( `Skipping account ${option.accountId} - already completed`, ); continue; } websiteInfos.push({ accountId: option.accountId, instance, }); } // Split into batches: standard websites first, then websites that accept external sources const standard: WebsiteInfo[] = []; const acceptsExternal: WebsiteInfo[] = []; for (const info of websiteInfos) { // Check if website accepts source URLs by looking at fileOptions const acceptsSourceUrls = info.instance.decoratedProps.fileOptions?.acceptsExternalSourceUrls ?? false; if (acceptsSourceUrls) { acceptsExternal.push(info); } else { standard.push(info); } } const batches: WebsiteInfo[][] = []; if (standard.length > 0) batches.push(standard); if (acceptsExternal.length > 0) batches.push(acceptsExternal); return batches; } /** * Finish the post and update the post record state. * @protected * @param {PostRecord} entity - The post record */ protected async finishPost(entity: PostRecord): Promise { this.currentPost = null; this.cancelToken = null; this.resumeContext = null; const entityInDb = await this.postRepository.findById(entity.id); if (!entityInDb) { this.logger.error( `Entity ${entity.id} not found in database. It may have been deleted while posting.`, ); return; } // Query events to determine if post was successful const failedEvents = await this.postEventRepository.getFailedEvents( entity.id, ); // DONE only if there are zero failures; any failure (including partial) means FAILED const state = failedEvents.length > 0 ? PostRecordState.FAILED : PostRecordState.DONE; await this.postRepository.update(entity.id, { state, completedAt: new Date().toISOString(), }); trackMetric( 'Post Duration', Date.now() - new Date(entity.createdAt).getTime(), { submissionType: entity.submission.type, state, }, ); this.logger.info(`Post ${entity.id} finished with state: ${state}`); } /** * Wait for posting wait interval to avoid rate limiting. * Uses the website's configured minimumPostWaitInterval. * @protected * @param {AccountId} accountId - The account ID * @param {Website} instance - The website instance */ protected async waitForPostingWaitInterval( accountId: AccountId, instance: Website, ): Promise { const lastTime = this.lastTimePostedToWebsite[accountId]; if (!lastTime) return; const waitInterval = instance.decoratedProps.metadata.minimumPostWaitInterval ?? 0; if (!waitInterval) return; const now = new Date(); const timeSinceLastPost = now.getTime() - lastTime.getTime(); if (timeSinceLastPost < waitInterval) { const waitTime = waitInterval - timeSinceLastPost; this.logger.info( `Waiting ${waitTime}ms before posting to ${instance.id}`, ); await new Promise((resolve) => { setTimeout(resolve, waitTime); }); } } /** * Ensures the website instance is logged in before posting. * Delegates to website.login() which is mutex-guarded: * - If a login is already in progress, waits for it to finish. * - If not logged in and no login in progress, triggers a fresh login. * @protected * @param {Website} instance - The website instance * @throws {Error} If the website is still not logged in after the login attempt */ protected async ensureLoggedIn(instance: Website): Promise { const state = instance.getLoginState(); if (state.isLoggedIn) { return; } this.logger.info( `Not logged in for ${instance.id}, triggering login...`, ); const result = await instance.login(); if (!result.isLoggedIn) { throw new Error('Not logged in'); } this.logger.info(`Login resolved for ${instance.id} — now logged in`); } /** * Redact sensitive information from post data for logging. * @protected * @param {PostData} postData - The post data * @returns {string} Redacted post data as JSON string */ protected redactPostDataForLogging(postData: PostData): string { const opts = { ...postData.options }; // Redact sensitive information if (opts.description) { opts.description = `[REDACTED ${opts.description.length}]`; } return JSON.stringify({ options: opts }); } } ================================================ FILE: apps/client-server/src/app/post/services/post-manager-v2/file-submission-post-manager.service.spec.ts ================================================ import { clearDatabase } from '@postybirb/database'; import { AccountId, DefaultSubmissionFileMetadata, EntityId, FileSubmissionMetadata, FileType, IPostResponse, PostData, PostEventType, PostRecordState, SubmissionRating, SubmissionType, } from '@postybirb/types'; import 'reflect-metadata'; import { FileBuffer, PostRecord, Submission, SubmissionFile, } from '../../../drizzle/models'; import { FileConverterService } from '../../../file-converter/file-converter.service'; import { NotificationsService } from '../../../notifications/notifications.service'; import { PostParsersService } from '../../../post-parsers/post-parsers.service'; import { ValidationService } from '../../../validation/validation.service'; import { FileWebsite, ImplementedFileWebsite, PostBatchData, } from '../../../websites/models/website-modifiers/file-website'; import { UnknownWebsite } from '../../../websites/website'; import { WebsiteRegistryService } from '../../../websites/website-registry.service'; import { CancellableToken } from '../../models/cancellable-token'; import { PostingFile } from '../../models/posting-file'; import { PostFileResizerService } from '../post-file-resizer/post-file-resizer.service'; import { PostEventRepository } from '../post-record-factory'; import { FileSubmissionPostManager } from './file-submission-post-manager.service'; describe('FileSubmissionPostManager', () => { let manager: FileSubmissionPostManager; let postEventRepositoryMock: jest.Mocked; let websiteRegistryMock: jest.Mocked; let postParserServiceMock: jest.Mocked; let validationServiceMock: jest.Mocked; let notificationServiceMock: jest.Mocked; let resizerServiceMock: jest.Mocked; let fileConverterServiceMock: jest.Mocked; beforeEach(() => { clearDatabase(); postEventRepositoryMock = { insert: jest.fn().mockResolvedValue({}), findByPostRecordId: jest.fn().mockResolvedValue([]), getCompletedAccounts: jest.fn().mockResolvedValue([]), getFailedEvents: jest.fn().mockResolvedValue([]), getSourceUrlsFromPost: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; websiteRegistryMock = { findInstance: jest.fn(), } as unknown as jest.Mocked; postParserServiceMock = { parse: jest.fn(), } as unknown as jest.Mocked; validationServiceMock = { validateSubmission: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; notificationServiceMock = { create: jest.fn(), sendDesktopNotification: jest.fn(), } as unknown as jest.Mocked; resizerServiceMock = { resize: jest.fn(), } as unknown as jest.Mocked; fileConverterServiceMock = { convert: jest.fn(), canConvert: jest.fn().mockResolvedValue(false), } as unknown as jest.Mocked; manager = new FileSubmissionPostManager( postEventRepositoryMock, websiteRegistryMock, postParserServiceMock, validationServiceMock, notificationServiceMock, resizerServiceMock, fileConverterServiceMock, ); }); function createFileBuffer(): FileBuffer { return new FileBuffer({ id: 'test-file-buffer', fileName: 'test.png', mimeType: 'image/png', buffer: Buffer.from('fake-image-data'), size: 100, width: 600, height: 600, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); } function createSubmissionFile( id: string = 'test-file', order: number = 1, ): SubmissionFile { const fileBuffer = createFileBuffer(); return new SubmissionFile({ id, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), fileName: 'test.png', hash: 'fake-hash', mimeType: 'image/png', size: fileBuffer.size, width: 600, height: 600, hasAltFile: false, hasThumbnail: false, hasCustomThumbnail: false, file: fileBuffer, order, metadata: DefaultSubmissionFileMetadata(), }); } function createSubmission( files: SubmissionFile[] = [], ): Submission { return new Submission({ id: 'test-submission', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, type: SubmissionType.FILE, files, options: [], isScheduled: false, schedule: {} as never, order: 1, posts: [] as never, isTemplate: false, isMultiSubmission: false, isArchived: false, }); } function createPostRecord( submission: Submission, ): PostRecord { return new PostRecord({ id: 'test-post-record' as EntityId, submission, state: PostRecordState.PENDING, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); } function createMockFileWebsite(accountId: AccountId): UnknownWebsite { return { id: 'test-website', accountId, account: { id: accountId, name: 'test-account', }, decoratedProps: { metadata: { name: 'Test Website', displayName: 'Test Website', }, fileOptions: { acceptedMimeTypes: ['image/png', 'image/jpeg'], fileBatchSize: 1, supportedFileTypes: [FileType.IMAGE], }, }, onPostFileSubmission: jest.fn(), calculateImageResize: jest.fn().mockReturnValue(undefined), supportsFile: true, } as unknown as UnknownWebsite; } function createPostingFile(file: SubmissionFile): PostingFile { const postingFile = new PostingFile(file.id, file.file, file.thumbnail); postingFile.metadata = file.metadata; return postingFile; } describe('getSupportedType', () => { it('should return FILE submission type', () => { expect(manager.getSupportedType()).toBe(SubmissionType.FILE); }); }); describe('attemptToPost', () => { let mockWebsite: UnknownWebsite; let postRecord: PostRecord; let accountId: AccountId; let postData: PostData; let cancelToken: CancellableToken; let submissionFile: SubmissionFile; beforeEach(() => { accountId = 'test-account-id' as AccountId; submissionFile = createSubmissionFile(); const submission = createSubmission([submissionFile]); postRecord = createPostRecord(submission); mockWebsite = createMockFileWebsite(accountId); cancelToken = new CancellableToken(); (manager as any).cancelToken = cancelToken; (manager as any).lastTimePostedToWebsite = {}; postData = { submission, options: { title: 'Test Title', description: 'Test Description', rating: SubmissionRating.GENERAL, tags: ['test'], }, } as PostData; // Setup resizer mock to return a PostingFile resizerServiceMock.resize.mockImplementation(async (request) => { const file = request.file as SubmissionFile; return createPostingFile(file); }); }); it('should throw error if website does not support file submissions', async () => { const nonFileWebsite = { ...mockWebsite, supportsFile: false, decoratedProps: { ...mockWebsite.decoratedProps, metadata: { name: 'Message Only Website', displayName: 'Message Only Website', }, }, } as unknown as UnknownWebsite; delete (nonFileWebsite as any).onPostFileSubmission; await expect( (manager as any).attemptToPost( postRecord, accountId, nonFileWebsite, postData, ), ).rejects.toThrow('does not support file submissions'); }); it('should successfully post a file and emit FILE_POSTED event', async () => { const sourceUrl = 'https://example.com/file/123'; const responseMessage = 'File posted successfully'; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', sourceUrl, message: responseMessage, }); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); // Verify website method was called expect( (mockWebsite as unknown as FileWebsite).onPostFileSubmission, ).toHaveBeenCalledWith(postData, expect.any(Array), cancelToken, { index: 0, totalBatches: 1, } satisfies PostBatchData); // Verify FILE_POSTED event was emitted expect(postEventRepositoryMock.insert).toHaveBeenCalledWith( expect.objectContaining({ postRecordId: postRecord.id, accountId, eventType: PostEventType.FILE_POSTED, fileId: submissionFile.id, sourceUrl, metadata: expect.objectContaining({ batchNumber: 0, accountSnapshot: { name: 'test-account', website: 'Test Website', }, fileSnapshot: expect.objectContaining({ fileName: submissionFile.fileName, mimeType: submissionFile.mimeType, }), }), }), ); }); it('should emit FILE_FAILED event and throw on error', async () => { const errorMessage = 'Failed to upload file'; const exception = new Error('API Error'); const stage = 'upload'; const additionalInfo = { code: 'ERR_API' }; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', message: errorMessage, exception, stage, additionalInfo, } as IPostResponse); await expect( (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ), ).rejects.toMatchObject({ exception, message: errorMessage, }); // Verify FILE_FAILED event was emitted expect(postEventRepositoryMock.insert).toHaveBeenCalledWith( expect.objectContaining({ postRecordId: postRecord.id, accountId, eventType: PostEventType.FILE_FAILED, fileId: submissionFile.id, error: expect.objectContaining({ message: errorMessage, stage, additionalInfo, }), }), ); }); it('should emit FILE_FAILED with unknown error when no message provided', async () => { const exception = new Error('Unknown API Error'); (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', exception, } as IPostResponse); await expect( (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ), ).rejects.toMatchObject({ exception, }); // Verify FILE_FAILED event was emitted with default message expect(postEventRepositoryMock.insert).toHaveBeenCalledWith( expect.objectContaining({ eventType: PostEventType.FILE_FAILED, error: expect.objectContaining({ message: 'Unknown error', }), }), ); }); it('should return early if no files to post', async () => { const emptySubmission = createSubmission([]); const emptyPostRecord = createPostRecord(emptySubmission); postData.submission = emptySubmission; await (manager as any).attemptToPost( emptyPostRecord, accountId, mockWebsite, postData, ); // Website method should not have been called expect( (mockWebsite as unknown as FileWebsite).onPostFileSubmission, ).not.toHaveBeenCalled(); // No events should have been emitted expect(postEventRepositoryMock.insert).not.toHaveBeenCalled(); }); it('should check cancel token during posting', async () => { // Cancel immediately cancelToken.cancel(); await expect( (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ), ).rejects.toThrow('Task was cancelled'); // Website method should not have been called expect( (mockWebsite as unknown as FileWebsite).onPostFileSubmission, ).not.toHaveBeenCalled(); }); it('should filter out files ignored for this website', async () => { const ignoredFile = createSubmissionFile('ignored-file', 1); ignoredFile.metadata.ignoredWebsites = [accountId]; const validFile = createSubmissionFile('valid-file', 2); const submission = createSubmission([ignoredFile, validFile]); const postRecordWithIgnored = createPostRecord(submission); postData.submission = submission; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', sourceUrl: 'https://example.com/file/456', }); await (manager as any).attemptToPost( postRecordWithIgnored, accountId, mockWebsite, postData, ); // Should only post one file (the valid one) expect( (mockWebsite as unknown as FileWebsite).onPostFileSubmission, ).toHaveBeenCalledTimes(1); // Verify FILE_POSTED event only for valid file expect(postEventRepositoryMock.insert).toHaveBeenCalledWith( expect.objectContaining({ fileId: validFile.id, eventType: PostEventType.FILE_POSTED, }), ); }); it('should sort files by order before posting', async () => { const file1 = createSubmissionFile('file-1', 3); const file2 = createSubmissionFile('file-2', 1); const file3 = createSubmissionFile('file-3', 2); const submission = createSubmission([file1, file2, file3]); const postRecordWithMultiple = createPostRecord(submission); postData.submission = submission; // Set batch size to 3 to get all files in one batch ( mockWebsite as unknown as ImplementedFileWebsite ).decoratedProps.fileOptions.fileBatchSize = 3; let capturedFiles: PostingFile[] = []; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockImplementation((data, files) => { capturedFiles = files; return { instanceId: 'test-website', sourceUrl: 'https://example.com/file/all', }; }); await (manager as any).attemptToPost( postRecordWithMultiple, accountId, mockWebsite, postData, ); // Verify files are sorted by order expect(capturedFiles.map((f) => f.id)).toEqual([ 'file-2', // order 1 'file-3', // order 2 'file-1', // order 3 ]); }); }); describe('file batching', () => { let mockWebsite: UnknownWebsite; let accountId: AccountId; let postData: PostData; let cancelToken: CancellableToken; beforeEach(() => { accountId = 'test-account-id' as AccountId; mockWebsite = createMockFileWebsite(accountId); cancelToken = new CancellableToken(); (manager as any).cancelToken = cancelToken; (manager as any).lastTimePostedToWebsite = {}; resizerServiceMock.resize.mockImplementation(async (request) => { const file = request.file as SubmissionFile; return createPostingFile(file); }); }); it('should batch files according to website batch size', async () => { const files = [ createSubmissionFile('file-1', 1), createSubmissionFile('file-2', 2), createSubmissionFile('file-3', 3), ]; const submission = createSubmission(files); const postRecord = createPostRecord(submission); postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; // Set batch size to 2 ( mockWebsite as unknown as ImplementedFileWebsite ).decoratedProps.fileOptions.fileBatchSize = 2; const batchIndices: number[] = []; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockImplementation((data, files, token, { index }) => { batchIndices.push(index); return { instanceId: 'test-website', sourceUrl: `https://example.com/batch/${index}`, }; }); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); // Should have 2 batches: [file-1, file-2] and [file-3] expect( (mockWebsite as unknown as FileWebsite).onPostFileSubmission, ).toHaveBeenCalledTimes(2); expect(batchIndices).toEqual([0, 1]); }); it('should use minimum batch size of 1', async () => { const file = createSubmissionFile(); const submission = createSubmission([file]); const postRecord = createPostRecord(submission); postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; // Set invalid batch size ( mockWebsite as unknown as ImplementedFileWebsite ).decoratedProps.fileOptions.fileBatchSize = 0; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', sourceUrl: 'https://example.com/file', }); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); // Should still work with batch size 1 expect( (mockWebsite as unknown as FileWebsite).onPostFileSubmission, ).toHaveBeenCalledTimes(1); }); it('should stop posting if a batch fails', async () => { const files = [ createSubmissionFile('file-1', 1), createSubmissionFile('file-2', 2), createSubmissionFile('file-3', 3), ]; const submission = createSubmission(files); const postRecord = createPostRecord(submission); postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; // Set batch size to 1 to have 3 separate batches ( mockWebsite as unknown as ImplementedFileWebsite ).decoratedProps.fileOptions.fileBatchSize = 1; let callCount = 0; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockImplementation(() => { callCount++; if (callCount === 2) { return { instanceId: 'test-website', exception: new Error('Batch 2 failed'), message: 'Failed on second batch', }; } return { instanceId: 'test-website', sourceUrl: `https://example.com/file/${callCount}`, }; }); await expect( (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ), ).rejects.toMatchObject({ message: 'Failed on second batch', }); // Should have stopped at batch 2 expect( (mockWebsite as unknown as FileWebsite).onPostFileSubmission, ).toHaveBeenCalledTimes(2); // Should have emitted FILE_POSTED for first file and FILE_FAILED for second expect(postEventRepositoryMock.insert).toHaveBeenCalledWith( expect.objectContaining({ fileId: 'file-1', eventType: PostEventType.FILE_POSTED, }), ); expect(postEventRepositoryMock.insert).toHaveBeenCalledWith( expect.objectContaining({ fileId: 'file-2', eventType: PostEventType.FILE_FAILED, }), ); }); }); describe('source URL propagation', () => { let mockWebsite: UnknownWebsite; let accountId: AccountId; let postData: PostData; let cancelToken: CancellableToken; beforeEach(() => { accountId = 'test-account-id' as AccountId; mockWebsite = createMockFileWebsite(accountId); cancelToken = new CancellableToken(); (manager as any).cancelToken = cancelToken; (manager as any).lastTimePostedToWebsite = {}; resizerServiceMock.resize.mockImplementation(async (request) => { const file = request.file as SubmissionFile; return createPostingFile(file); }); }); it('should include source URLs from previous posts in current batch', async () => { const file = createSubmissionFile(); const submission = createSubmission([file]); const postRecord = createPostRecord(submission); postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; // Mock that another account has already posted postEventRepositoryMock.getSourceUrlsFromPost.mockResolvedValue([ 'https://other-site.com/post/1', 'https://other-site.com/post/2', ]); let capturedFiles: PostingFile[] = []; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockImplementation((data, files) => { capturedFiles = files; return { instanceId: 'test-website', sourceUrl: 'https://example.com/file', }; }); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); // Verify source URLs were included in file metadata expect(capturedFiles[0].metadata.sourceUrls).toContain( 'https://other-site.com/post/1', ); expect(capturedFiles[0].metadata.sourceUrls).toContain( 'https://other-site.com/post/2', ); }); it('should include source URLs from resume context', async () => { const file = createSubmissionFile(); const submission = createSubmission([file]); const postRecord = createPostRecord(submission); postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; // Set resume context with source URLs from prior attempts const resumeContext = { sourceUrlsByAccount: new Map([ ['other-account' as AccountId, ['https://prior-site.com/post/1']], [accountId, ['https://self-url.com/post/1']], // Should be excluded ]), postedFilesByAccount: new Map(), }; (manager as any).resumeContext = resumeContext; let capturedFiles: PostingFile[] = []; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockImplementation((data, files) => { capturedFiles = files; return { instanceId: 'test-website', sourceUrl: 'https://example.com/file', }; }); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); // Verify source URLs from prior attempts were included (excluding self) expect(capturedFiles[0].metadata.sourceUrls).toContain( 'https://prior-site.com/post/1', ); expect(capturedFiles[0].metadata.sourceUrls).not.toContain( 'https://self-url.com/post/1', ); }); it('should deduplicate source URLs', async () => { const file = createSubmissionFile(); const submission = createSubmission([file]); const postRecord = createPostRecord(submission); postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; // Same URL from both sources postEventRepositoryMock.getSourceUrlsFromPost.mockResolvedValue([ 'https://duplicate.com/post/1', ]); const resumeContext = { sourceUrlsByAccount: new Map([ ['other-account' as AccountId, ['https://duplicate.com/post/1']], ]), postedFilesByAccount: new Map(), }; (manager as any).resumeContext = resumeContext; let capturedFiles: PostingFile[] = []; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockImplementation((data, files) => { capturedFiles = files; return { instanceId: 'test-website', sourceUrl: 'https://example.com/file', }; }); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); // Verify URL appears only once const duplicateCount = capturedFiles[0].metadata.sourceUrls.filter( (url) => url === 'https://duplicate.com/post/1', ).length; expect(duplicateCount).toBe(1); }); }); describe('resume context handling', () => { let mockWebsite: UnknownWebsite; let accountId: AccountId; let postData: PostData; let cancelToken: CancellableToken; beforeEach(() => { accountId = 'test-account-id' as AccountId; mockWebsite = createMockFileWebsite(accountId); cancelToken = new CancellableToken(); (manager as any).cancelToken = cancelToken; (manager as any).lastTimePostedToWebsite = {}; resizerServiceMock.resize.mockImplementation(async (request) => { const file = request.file as SubmissionFile; return createPostingFile(file); }); }); it('should skip files that were already posted according to resume context', async () => { const alreadyPostedFile = createSubmissionFile('already-posted', 1); const newFile = createSubmissionFile('new-file', 2); const submission = createSubmission([alreadyPostedFile, newFile]); const postRecord = createPostRecord(submission); postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; // Set resume context indicating one file was already posted const resumeContext = { sourceUrlsByAccount: new Map(), postedFilesByAccount: new Map([ [accountId, new Set(['already-posted'])], ]), }; (manager as any).resumeContext = resumeContext; let capturedFiles: PostingFile[] = []; (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockImplementation((data, files) => { capturedFiles = files; return { instanceId: 'test-website', sourceUrl: 'https://example.com/file', }; }); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); // Should only post the new file expect(capturedFiles).toHaveLength(1); expect(capturedFiles[0].id).toBe('new-file'); }); }); describe('file verification', () => { let mockWebsite: UnknownWebsite; let accountId: AccountId; let postData: PostData; let cancelToken: CancellableToken; beforeEach(() => { accountId = 'test-account-id' as AccountId; mockWebsite = createMockFileWebsite(accountId); cancelToken = new CancellableToken(); (manager as any).cancelToken = cancelToken; (manager as any).lastTimePostedToWebsite = {}; }); it('should throw error if file type is not supported after processing', async () => { const unsupportedFile = createSubmissionFile(); const submission = createSubmission([unsupportedFile]); const postRecord = createPostRecord(submission); postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; // Website only accepts JPEG ( mockWebsite as unknown as ImplementedFileWebsite ).decoratedProps.fileOptions.acceptedMimeTypes = ['image/jpeg']; // Resizer returns PNG (which website doesn't accept) resizerServiceMock.resize.mockImplementation(async (request) => { const file = request.file as SubmissionFile; return createPostingFile(file); // Returns PNG }); await expect( (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ), ).rejects.toThrow('does not support the file type image/png'); }); it('should not verify if no accepted mime types are specified', async () => { const file = createSubmissionFile(); const submission = createSubmission([file]); const postRecord = createPostRecord(submission); postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; // Website accepts all file types ( mockWebsite as unknown as ImplementedFileWebsite ).decoratedProps.fileOptions.acceptedMimeTypes = []; resizerServiceMock.resize.mockImplementation(async (request) => { const f = request.file as SubmissionFile; return createPostingFile(f); }); (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', sourceUrl: 'https://example.com/file', }); await expect( (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ), ).resolves.not.toThrow(); }); }); describe('event metadata structure', () => { it('should create events with proper file snapshot metadata', async () => { const accountId = 'metadata-account' as AccountId; const submissionFile = createSubmissionFile(); submissionFile.hash = 'test-hash-123'; const submission = createSubmission([submissionFile]); const postRecord = createPostRecord(submission); const mockWebsite = createMockFileWebsite(accountId); (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', sourceUrl: 'https://example.com/file/456', message: 'Successfully posted file', }); resizerServiceMock.resize.mockImplementation(async (request) => { const file = request.file as SubmissionFile; return createPostingFile(file); }); const cancelToken = new CancellableToken(); (manager as any).cancelToken = cancelToken; (manager as any).lastTimePostedToWebsite = {}; const postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); expect(postEventRepositoryMock.insert).toHaveBeenCalledWith({ postRecordId: postRecord.id, accountId, eventType: PostEventType.FILE_POSTED, fileId: submissionFile.id, sourceUrl: 'https://example.com/file/456', metadata: { batchNumber: 0, accountSnapshot: { name: 'test-account', website: 'Test Website', }, fileSnapshot: { fileName: submissionFile.fileName, mimeType: submissionFile.mimeType, size: submissionFile.size, hash: submissionFile.hash, }, responseMessage: 'Successfully posted file', }, }); }); }); }); ================================================ FILE: apps/client-server/src/app/post/services/post-manager-v2/file-submission-post-manager.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { AccountId, FileSubmission, FileSubmissionMetadata, FileType, ImageResizeProps, PostData, PostEventType, SubmissionFileMetadata, SubmissionType, } from '@postybirb/types'; import { getFileType } from '@postybirb/utils/file-type'; import { chunk } from 'lodash'; import { FileBuffer, PostRecord, Submission, SubmissionFile, } from '../../../drizzle/models'; import { FileConverterService } from '../../../file-converter/file-converter.service'; import { NotificationsService } from '../../../notifications/notifications.service'; import { PostParsersService } from '../../../post-parsers/post-parsers.service'; import { ValidationService } from '../../../validation/validation.service'; import { getSupportedFileSize } from '../../../websites/decorators/supports-files.decorator'; import { ImplementedFileWebsite, isFileWebsite, } from '../../../websites/models/website-modifiers/file-website'; import { UnknownWebsite } from '../../../websites/website'; import { WebsiteRegistryService } from '../../../websites/website-registry.service'; import { PostingFile } from '../../models/posting-file'; import { PostFileResizerService } from '../post-file-resizer/post-file-resizer.service'; import { PostEventRepository } from '../post-record-factory'; import { BasePostManager } from './base-post-manager.service'; /** * Returns true if `mimeType` is accepted by any entry in `patterns`. * Handles exact matches, prefix patterns ("image/"), and wildcard patterns ("image/*"). */ function mimeTypeIsAccepted(mimeType: string, patterns: string[]): boolean { return patterns.some((pattern) => { if (pattern === mimeType) return true; if (pattern.endsWith('/*')) { return mimeType.startsWith(pattern.slice(0, -1)); // 'image/*' → prefix 'image/' } if (pattern.endsWith('/')) { return mimeType.startsWith(pattern); // 'image/' → matches 'image/jpeg' } return false; }); } /** * PostManager for file submissions. * Handles file batching, resizing, and conversion. * @class FileSubmissionPostManager */ @Injectable() export class FileSubmissionPostManager extends BasePostManager { protected readonly logger = Logger(this.constructor.name); constructor( postEventRepository: PostEventRepository, websiteRegistry: WebsiteRegistryService, postParserService: PostParsersService, validationService: ValidationService, notificationService: NotificationsService, private readonly resizerService: PostFileResizerService, private readonly fileConverterService: FileConverterService, ) { super( postEventRepository, websiteRegistry, postParserService, validationService, notificationService, ); } getSupportedType(): SubmissionType { return SubmissionType.FILE; } protected async attemptToPost( entity: PostRecord, accountId: AccountId, instance: UnknownWebsite, data: PostData, ): Promise { if (!isFileWebsite(instance)) { throw new Error( `Website '${instance.decoratedProps.metadata.displayName}' does not support file submissions`, ); } const submission = entity.submission as Submission; // Order files based on submission order const fileBatchSize = Math.max( instance.decoratedProps.fileOptions.fileBatchSize ?? 1, 1, ); const files = this.getFilesToPost(submission, accountId, instance); if (files.length === 0) { this.logger.info(`No files to post for account ${accountId}`); return; } // Split files into batches based on instance file batch size const batches = chunk(files, fileBatchSize); for (const [batchIndex, batch] of batches.entries()) { this.cancelToken.throwIfCancelled(); // Get source URLs from other accounts for cross-website propagation // 1. From current post attempt (other accounts that have already posted) const currentPostUrls = await this.postEventRepository.getSourceUrlsFromPost( entity.id, accountId, ); // 2. From prior attempts (resume context, excluding self) const priorUrls: string[] = []; if (this.resumeContext) { for (const [ priorAccountId, urls, ] of this.resumeContext.sourceUrlsByAccount.entries()) { if (priorAccountId !== accountId) { priorUrls.push(...urls); } } } // Merge and deduplicate const allSourceUrls = [...new Set([...currentPostUrls, ...priorUrls])]; // Resize files if necessary const processedFiles: PostingFile[] = ( await Promise.all( batch.map((submissionFile) => this.resizeOrModifyFile(submissionFile, submission, instance), ), ) ).map((f) => { const fileWithMetadata = f.withMetadata(f.metadata); fileWithMetadata.metadata.sourceUrls = [ ...(fileWithMetadata.metadata.sourceUrls ?? []), ...allSourceUrls, ].filter((s) => !!s?.trim()); return fileWithMetadata; }); // Verify files are supported by the website after all processing this.verifyPostingFiles(instance, processedFiles); // Post this.cancelToken.throwIfCancelled(); const fileIds = batch.map((f) => f.id); this.logger .withMetadata({ batchIndex, totalBatches: batches.length, totalFiles: files.length, totalFilesInBatch: batch.length, fileIds, }) .info(`Posting file batch to ${instance.id}`); await this.waitForPostingWaitInterval(accountId, instance); this.cancelToken.throwIfCancelled(); const result = await instance.onPostFileSubmission( data, processedFiles, this.cancelToken, { totalBatches: batch.length, index: batchIndex }, ); if (result.exception) { // Emit FILE_FAILED events for each file in the batch const storedBatchIndex = batchIndex; // Capture for closure await Promise.all( batch.map((file) => this.postEventRepository.insert({ postRecordId: entity.id, accountId, eventType: PostEventType.FILE_FAILED, fileId: file.id, error: { message: result.message || 'Unknown error', stack: result.exception?.stack, stage: result.stage, additionalInfo: result.additionalInfo, }, metadata: { batchNumber: storedBatchIndex, accountSnapshot: { name: instance.account.name, website: instance.decoratedProps.metadata.name, }, fileSnapshot: { fileName: file.fileName, mimeType: file.mimeType, size: file.size, hash: file.hash, }, }, }), ), ); // Behavior is to stop posting if a batch fails // eslint-disable-next-line @typescript-eslint/no-throw-literal throw result; } // Emit FILE_POSTED events for each file in the batch const storedBatchIndex = batchIndex; // Capture for closure await Promise.all( batch.map((file) => this.postEventRepository.insert({ postRecordId: entity.id, accountId, eventType: PostEventType.FILE_POSTED, fileId: file.id, sourceUrl: result.sourceUrl, metadata: { batchNumber: storedBatchIndex, accountSnapshot: { name: instance.account.name, website: instance.decoratedProps.metadata.name, }, fileSnapshot: { fileName: file.fileName, mimeType: file.mimeType, size: file.size, hash: file.hash, }, responseMessage: result.message, }, }), ), ); this.logger .withMetadata(result) .info(`File batch posted to ${instance.id}`); } } /** * Get files to post, filtered by resume context and user settings. * @private * @param {Submission} submission - The submission * @param {AccountId} accountId - The account ID * @param {ImplementedFileWebsite} instance - The website instance * @returns {SubmissionFile[]} Files to post */ private getFilesToPost( submission: Submission, accountId: AccountId, instance: ImplementedFileWebsite, ): SubmissionFile[] { return ( submission.files ?.filter( // Filter out files that have been marked by the user as ignored for this website (f) => !f.metadata.ignoredWebsites?.includes(accountId), ) .filter( // Only post files that haven't been posted (based on resume context) (f) => { if (!this.resumeContext) return true; const postedFiles = this.resumeContext.postedFilesByAccount.get(accountId); return !postedFiles?.has(f.id); }, ) .sort((a, b) => { const aOrder = a.order ?? Number.MAX_SAFE_INTEGER; const bOrder = b.order ?? Number.MAX_SAFE_INTEGER; return aOrder - bOrder; }) ?? [] ); } /** * Verify that files are supported by the website. * @private * @param {UnknownWebsite} websiteInstance - The website instance * @param {PostingFile[]} postingFiles - The posting files */ private verifyPostingFiles( websiteInstance: UnknownWebsite, postingFiles: PostingFile[], ): void { const acceptedMimeTypes = websiteInstance.decoratedProps.fileOptions.acceptedMimeTypes ?? []; if (acceptedMimeTypes.length === 0) return; postingFiles.forEach((f) => { if (!mimeTypeIsAccepted(f.mimeType, acceptedMimeTypes)) { throw new Error( `Website '${websiteInstance.decoratedProps.metadata.displayName}' does not support the file type ${f.mimeType} and attempts to convert it did not resolve the issue`, ); } }); } /** * Resize or modify a file for posting. * @private * @param {SubmissionFile} file - The submission file * @param {FileSubmission} submission - The file submission * @param {ImplementedFileWebsite} instance - The website instance * @returns {Promise} The posting file */ private async resizeOrModifyFile( file: SubmissionFile, submission: FileSubmission, instance: ImplementedFileWebsite, ): Promise { if (!file.file) { await file.load(); } const fileMetadata: SubmissionFileMetadata = file.metadata; let resizeParams: ImageResizeProps | undefined; const { fileOptions } = instance.decoratedProps; const allowedMimeTypes = fileOptions.acceptedMimeTypes ?? []; const fileType = getFileType(file.mimeType); if (fileType === FileType.IMAGE) { if ( await this.fileConverterService.canConvert( file.mimeType, allowedMimeTypes, ) ) { // eslint-disable-next-line no-param-reassign file.file = new FileBuffer( await this.fileConverterService.convert(file.file, allowedMimeTypes), ); } resizeParams = this.getResizeParameters(submission, instance, file); // User defined dimensions const userDefinedDimensions = // eslint-disable-next-line @typescript-eslint/dot-notation fileMetadata?.dimensions['default'] ?? fileMetadata?.dimensions[instance.accountId]; if (userDefinedDimensions) { if (userDefinedDimensions.width && userDefinedDimensions.height) { resizeParams = resizeParams ?? {}; if ( userDefinedDimensions.width > resizeParams.width && userDefinedDimensions.height > resizeParams.height ) { resizeParams = { ...resizeParams, width: userDefinedDimensions.width, height: userDefinedDimensions.height, }; } } } // We pass to resize even if no resize parameters are set // as it handles the bundling to PostingFile return this.resizerService.resize({ file, resize: resizeParams, }); } if ( fileType === FileType.TEXT && file.hasAltFile && !mimeTypeIsAccepted(file.mimeType, allowedMimeTypes) ) { // Use alt file if it exists and is a text file if ( await this.fileConverterService.canConvert( file.altFile.mimeType, allowedMimeTypes, ) ) { // eslint-disable-next-line no-param-reassign file.file = new FileBuffer( await this.fileConverterService.convert( file.altFile, allowedMimeTypes, ), ); } } return new PostingFile(file.id, file.file, file.thumbnail).withMetadata( file.metadata, ); } /** * Get resize parameters for a file. * @private * @param {FileSubmission} submission - The file submission * @param {ImplementedFileWebsite} instance - The website instance * @param {SubmissionFile} file - The submission file * @returns {ImageResizeProps | undefined} The resize parameters */ private getResizeParameters( submission: FileSubmission, instance: ImplementedFileWebsite, file: SubmissionFile, ): ImageResizeProps | undefined { // Use website's own calculation method let resizeParams = instance.calculateImageResize(file); // Apply user-defined dimensions if set const fileParams = file.metadata.dimensions?.[instance.accountId]; if (fileParams) { if (fileParams.width) { if (!resizeParams) { resizeParams = {}; } resizeParams.width = Math.min( file.width, fileParams.width, resizeParams.width ?? Infinity, ); } if (fileParams.height) { if (!resizeParams) { resizeParams = {}; } resizeParams.height = Math.min( file.height, fileParams.height, resizeParams.height ?? Infinity, ); } } // Fall back to supported file size if needed if (!resizeParams?.maxBytes) { const supportedFileSize = getSupportedFileSize(instance, file); if (supportedFileSize && file.size > supportedFileSize) { if (!resizeParams) { resizeParams = {}; } resizeParams.maxBytes = supportedFileSize; } } return resizeParams; } } ================================================ FILE: apps/client-server/src/app/post/services/post-manager-v2/index.ts ================================================ export * from './base-post-manager.service'; export * from './file-submission-post-manager.service'; export * from './message-submission-post-manager.service'; export * from './post-manager-registry.service'; ================================================ FILE: apps/client-server/src/app/post/services/post-manager-v2/message-submission-post-manager.service.spec.ts ================================================ import { clearDatabase } from '@postybirb/database'; import { AccountId, EntityId, IPostResponse, PostData, PostEventType, PostRecordState, SubmissionRating, SubmissionType, } from '@postybirb/types'; import 'reflect-metadata'; import { PostRecord, Submission } from '../../../drizzle/models'; import { NotificationsService } from '../../../notifications/notifications.service'; import { PostParsersService } from '../../../post-parsers/post-parsers.service'; import { ValidationService } from '../../../validation/validation.service'; import { MessageWebsite } from '../../../websites/models/website-modifiers/message-website'; import { UnknownWebsite } from '../../../websites/website'; import { WebsiteRegistryService } from '../../../websites/website-registry.service'; import { CancellableToken } from '../../models/cancellable-token'; import { PostEventRepository } from '../post-record-factory'; import { MessageSubmissionPostManager } from './message-submission-post-manager.service'; describe('MessageSubmissionPostManager', () => { let manager: MessageSubmissionPostManager; let postEventRepositoryMock: jest.Mocked; let websiteRegistryMock: jest.Mocked; let postParserServiceMock: jest.Mocked; let validationServiceMock: jest.Mocked; let notificationServiceMock: jest.Mocked; beforeEach(() => { clearDatabase(); postEventRepositoryMock = { insert: jest.fn().mockResolvedValue({}), findByPostRecordId: jest.fn().mockResolvedValue([]), getCompletedAccounts: jest.fn().mockResolvedValue([]), getFailedEvents: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; websiteRegistryMock = { findInstance: jest.fn(), } as unknown as jest.Mocked; postParserServiceMock = { parse: jest.fn(), } as unknown as jest.Mocked; validationServiceMock = { validateSubmission: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; notificationServiceMock = { create: jest.fn(), sendDesktopNotification: jest.fn(), } as unknown as jest.Mocked; manager = new MessageSubmissionPostManager( postEventRepositoryMock, websiteRegistryMock, postParserServiceMock, validationServiceMock, notificationServiceMock, ); }); function createSubmission(): Submission { return new Submission({ id: 'test-submission', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, type: SubmissionType.MESSAGE, files: [], options: [], isScheduled: false, schedule: {} as never, order: 1, posts: [] as never, isTemplate: false, isMultiSubmission: false, isArchived: false, }); } function createPostRecord(submission: Submission): PostRecord { return new PostRecord({ id: 'test-post-record' as EntityId, submission, state: PostRecordState.PENDING, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); } function createMockWebsite(accountId: AccountId): UnknownWebsite { return { id: 'test-website', account: { id: accountId, name: 'test-account', }, decoratedProps: { metadata: { name: 'Test Website', displayName: 'Test Website', }, }, onPostMessageSubmission: jest.fn(), } as unknown as UnknownWebsite; } describe('getSupportedType', () => { it('should return MESSAGE submission type', () => { expect(manager.getSupportedType()).toBe(SubmissionType.MESSAGE); }); }); describe('attemptToPost', () => { let mockWebsite: UnknownWebsite; let postRecord: PostRecord; let accountId: AccountId; let postData: PostData; let cancelToken: CancellableToken; beforeEach(() => { accountId = 'test-account-id' as AccountId; const submission = createSubmission(); postRecord = createPostRecord(submission); mockWebsite = createMockWebsite(accountId); cancelToken = new CancellableToken(); (manager as any).cancelToken = cancelToken; (manager as any).lastTimePostedToWebsite = {}; postData = { submission, options: { title: 'Test Title', description: 'Test Description', rating: SubmissionRating.GENERAL, tags: ['test'], }, } as PostData; }); it('should successfully post a message and emit MESSAGE_POSTED event', async () => { const sourceUrl = 'https://example.com/message/123'; const responseMessage = 'Message posted successfully'; (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', sourceUrl, message: responseMessage, }); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); // Verify website method was called expect( (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission, ).toHaveBeenCalledWith(postData, cancelToken); // Verify MESSAGE_POSTED event was emitted expect(postEventRepositoryMock.insert).toHaveBeenCalledWith( expect.objectContaining({ postRecordId: postRecord.id, accountId, eventType: PostEventType.MESSAGE_POSTED, sourceUrl, metadata: expect.objectContaining({ accountSnapshot: { name: 'test-account', website: 'Test Website', }, responseMessage, }), }), ); }); it('should emit MESSAGE_FAILED event and throw on error', async () => { const errorMessage = 'Failed to post message'; const exception = new Error('API Error'); const stage = 'upload'; const additionalInfo = { code: 'ERR_API' }; (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', message: errorMessage, exception, stage, additionalInfo, } as IPostResponse); await expect( (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ), ).rejects.toMatchObject({ exception, message: errorMessage, }); // Verify MESSAGE_FAILED event was emitted expect(postEventRepositoryMock.insert).toHaveBeenCalledWith( expect.objectContaining({ postRecordId: postRecord.id, accountId, eventType: PostEventType.MESSAGE_FAILED, error: expect.objectContaining({ message: errorMessage, stage, additionalInfo, }), }), ); }); it('should emit MESSAGE_FAILED event with unknown error when no message provided', async () => { const exception = new Error('Unknown API Error'); (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', exception, } as IPostResponse); await expect( (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ), ).rejects.toMatchObject({ exception, }); // Verify MESSAGE_FAILED event was emitted with default message expect(postEventRepositoryMock.insert).toHaveBeenCalledWith( expect.objectContaining({ eventType: PostEventType.MESSAGE_FAILED, error: expect.objectContaining({ message: 'Unknown error', }), }), ); }); it('should not wait if no previous post to account', async () => { (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', sourceUrl: 'https://example.com/message/123', }); const startTime = Date.now(); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); const elapsed = Date.now() - startTime; // Should not wait expect(elapsed).toBeLessThan(1000); }); it('should not wait if enough time has passed since last post', async () => { (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', sourceUrl: 'https://example.com/message/123', }); // Set last time posted to 10 seconds ago (beyond min interval) const now = new Date(); const lastTime = new Date(now.getTime() - 10000); (manager as any).lastTimePostedToWebsite[accountId] = lastTime; const startTime = Date.now(); await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); const elapsed = Date.now() - startTime; // Should not wait expect(elapsed).toBeLessThan(1000); }); it('should check cancel token during posting', async () => { // Cancel immediately cancelToken.cancel(); await expect( (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ), ).rejects.toThrow('Task was cancelled'); // Website method should not have been called expect( (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission, ).not.toHaveBeenCalled(); }); }); describe('event metadata structure', () => { it('should create events with proper metadata structure', async () => { const accountId = 'metadata-account' as AccountId; const submission = createSubmission(); const postRecord = createPostRecord(submission); const mockWebsite = createMockWebsite(accountId); (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest .fn() .mockResolvedValue({ instanceId: 'test-website', sourceUrl: 'https://example.com/message/456', message: 'Successfully posted message', }); const cancelToken = new CancellableToken(); (manager as any).cancelToken = cancelToken; (manager as any).lastTimePostedToWebsite = {}; const postData = { submission, options: { title: 'Test', description: 'Test', rating: SubmissionRating.GENERAL, tags: [], }, } as PostData; await (manager as any).attemptToPost( postRecord, accountId, mockWebsite, postData, ); expect(postEventRepositoryMock.insert).toHaveBeenCalledWith({ postRecordId: postRecord.id, accountId, eventType: PostEventType.MESSAGE_POSTED, sourceUrl: 'https://example.com/message/456', metadata: { accountSnapshot: { name: 'test-account', website: 'Test Website', }, responseMessage: 'Successfully posted message', }, }); }); }); }); ================================================ FILE: apps/client-server/src/app/post/services/post-manager-v2/message-submission-post-manager.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { AccountId, PostData, PostEventType, SubmissionType, } from '@postybirb/types'; import { PostRecord } from '../../../drizzle/models'; import { NotificationsService } from '../../../notifications/notifications.service'; import { PostParsersService } from '../../../post-parsers/post-parsers.service'; import { ValidationService } from '../../../validation/validation.service'; import { MessageWebsite } from '../../../websites/models/website-modifiers/message-website'; import { UnknownWebsite } from '../../../websites/website'; import { WebsiteRegistryService } from '../../../websites/website-registry.service'; import { PostEventRepository } from '../post-record-factory'; import { BasePostManager } from './base-post-manager.service'; /** * PostManager for message submissions. * Handles message-only posting. * @class MessageSubmissionPostManager */ @Injectable() export class MessageSubmissionPostManager extends BasePostManager { protected readonly logger = Logger(this.constructor.name); // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor( postEventRepository: PostEventRepository, websiteRegistry: WebsiteRegistryService, postParserService: PostParsersService, validationService: ValidationService, notificationService: NotificationsService, ) { super( postEventRepository, websiteRegistry, postParserService, validationService, notificationService, ); } getSupportedType(): SubmissionType { return SubmissionType.MESSAGE; } protected async attemptToPost( entity: PostRecord, accountId: AccountId, instance: UnknownWebsite, data: PostData, ): Promise { this.logger.info(`Posting message to ${instance.id}`); await this.waitForPostingWaitInterval(accountId, instance); this.cancelToken.throwIfCancelled(); const result = await ( instance as unknown as MessageWebsite ).onPostMessageSubmission(data, this.cancelToken); if (result.exception) { // Emit MESSAGE_FAILED event await this.postEventRepository.insert({ postRecordId: entity.id, accountId, eventType: PostEventType.MESSAGE_FAILED, error: { message: result.message || 'Unknown error', stack: result.exception?.stack, stage: result.stage, additionalInfo: result.additionalInfo, }, metadata: { accountSnapshot: { name: instance.account.name, website: instance.decoratedProps.metadata.name, }, responseMessage: result.message, }, }); // eslint-disable-next-line @typescript-eslint/no-throw-literal throw result; } // Emit MESSAGE_POSTED event await this.postEventRepository.insert({ postRecordId: entity.id, accountId, eventType: PostEventType.MESSAGE_POSTED, sourceUrl: result.sourceUrl, metadata: { accountSnapshot: { name: instance.account.name, website: instance.decoratedProps.metadata.name, }, responseMessage: result.message, }, }); this.logger.withMetadata(result).info(`Message posted to ${instance.id}`); } } ================================================ FILE: apps/client-server/src/app/post/services/post-manager-v2/post-manager-registry.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { EntityId, SubmissionType } from '@postybirb/types'; import { PostRecord } from '../../../drizzle/models'; import { PostRecordFactory } from '../post-record-factory'; import { BasePostManager } from './base-post-manager.service'; import { FileSubmissionPostManager } from './file-submission-post-manager.service'; import { MessageSubmissionPostManager } from './message-submission-post-manager.service'; /** * Registry for PostManager instances. * Maps submission types to appropriate PostManager implementations. * Allows multiple submissions to be posted concurrently. * @class PostManagerRegistry */ @Injectable() export class PostManagerRegistry { private readonly logger = Logger(this.constructor.name); private readonly managers: Map; constructor( private readonly fileSubmissionPostManager: FileSubmissionPostManager, private readonly messageSubmissionPostManager: MessageSubmissionPostManager, private readonly postRecordFactory: PostRecordFactory, ) { this.managers = new Map(); this.managers.set(SubmissionType.FILE, fileSubmissionPostManager); this.managers.set(SubmissionType.MESSAGE, messageSubmissionPostManager); } /** * Get a PostManager for a submission type. * @param {SubmissionType} type - The submission type * @returns {BasePostManager | undefined} The PostManager instance */ public getManager(type: SubmissionType): BasePostManager | undefined { return this.managers.get(type); } /** * Start posting for a post record. * Determines the appropriate PostManager and starts posting. * @param {PostRecord} postRecord - The post record to start */ public async startPost(postRecord: PostRecord): Promise { const submissionType = postRecord.submission.type; const manager = this.getManager(submissionType); if (!manager) { this.logger.error(`No PostManager found for type: ${submissionType}`); throw new Error(`No PostManager found for type: ${submissionType}`); } if (manager.isPosting()) { this.logger.warn( `PostManager for ${submissionType} is already posting, cannot start new post`, ); return; } // Build resume context for this post record const resumeContext = await this.postRecordFactory.buildResumeContext( postRecord.submissionId, postRecord.id, postRecord.resumeMode, ); await manager.startPost(postRecord, resumeContext); } /** * Cancel posting for a submission if it's currently running. * Checks all managers to find the one handling this submission. * @param {EntityId} submissionId - The submission ID to cancel * @returns {Promise} True if cancelled, false if not found */ public async cancelIfRunning(submissionId: EntityId): Promise { for (const manager of this.managers.values()) { if (await manager.cancelIfRunning(submissionId)) { return true; } } return false; } /** * Check if a specific submission type's manager is posting. * @param {SubmissionType} type - The submission type * @returns {boolean} True if posting */ public isPostingType(type: SubmissionType): boolean { const manager = this.getManager(type); return manager?.isPosting() ?? false; } } ================================================ FILE: apps/client-server/src/app/post/services/post-queue/post-queue.controller.ts ================================================ import { Body, Controller, Get, Post } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { PostyBirbController } from '../../../common/controller/postybirb-controller'; import { PostQueueActionDto } from '../../dtos/post-queue-action.dto'; import { PostQueueService } from './post-queue.service'; /** * Queue operations for Post data. * @class PostController */ @ApiTags('post-queue') @Controller('post-queue') export class PostQueueController extends PostyBirbController<'PostQueueRecordSchema'> { constructor(readonly service: PostQueueService) { super(service); } @Post('enqueue') @ApiOkResponse({ description: 'Post(s) queued.' }) async enqueue(@Body() request: PostQueueActionDto) { return this.service.enqueue(request.submissionIds, request.resumeMode); } @Post('dequeue') @ApiOkResponse({ description: 'Post(s) dequeued.' }) async dequeue(@Body() request: PostQueueActionDto) { this.service.dequeue(request.submissionIds); } @Get('is-paused') @ApiOkResponse({ description: 'Get if queue is paused.' }) async isPaused() { return { paused: await this.service.isPaused() }; } @Post('pause') @ApiOkResponse({ description: 'Queue paused.' }) async pause() { await this.service.pause(); return { paused: true }; } @Post('resume') @ApiOkResponse({ description: 'Queue resumed.' }) async resume() { await this.service.resume(); return { paused: false }; } } ================================================ FILE: apps/client-server/src/app/post/services/post-queue/post-queue.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { AccountId, DefaultDescription, PostRecordState, SubmissionId, SubmissionRating, SubmissionType, } from '@postybirb/types'; import { AccountModule } from '../../../account/account.module'; import { AccountService } from '../../../account/account.service'; import { CreateAccountDto } from '../../../account/dtos/create-account.dto'; import { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database'; import { SettingsService } from '../../../settings/settings.service'; import { CreateSubmissionDto } from '../../../submission/dtos/create-submission.dto'; import { SubmissionService } from '../../../submission/services/submission.service'; import { SubmissionModule } from '../../../submission/submission.module'; import { CreateWebsiteOptionsDto } from '../../../website-options/dtos/create-website-options.dto'; import { WebsiteOptionsModule } from '../../../website-options/website-options.module'; import { WebsiteOptionsService } from '../../../website-options/website-options.service'; import { WebsiteRegistryService } from '../../../websites/website-registry.service'; import { WebsitesModule } from '../../../websites/websites.module'; import { PostModule } from '../../post.module'; import { PostService } from '../../post.service'; import { PostManagerRegistry } from '../post-manager-v2'; import { PostQueueService } from './post-queue.service'; describe('PostQueueService', () => { let service: PostQueueService; let module: TestingModule; let submissionService: SubmissionService; let accountService: AccountService; let websiteOptionsService: WebsiteOptionsService; let registryService: WebsiteRegistryService; let postService: PostService; let mockPostManagerRegistry: jest.Mocked; beforeEach(async () => { clearDatabase(); // Create mock PostManagerRegistry mockPostManagerRegistry = { startPost: jest.fn().mockResolvedValue(undefined), cancelIfRunning: jest.fn().mockResolvedValue(true), isPostingType: jest.fn().mockReturnValue(false), getManager: jest.fn(), } as any; try { module = await Test.createTestingModule({ imports: [ SubmissionModule, AccountModule, WebsiteOptionsModule, WebsitesModule, PostModule, ], }) .overrideProvider(PostManagerRegistry) .useValue(mockPostManagerRegistry) .compile(); service = module.get(PostQueueService); submissionService = module.get(SubmissionService); accountService = module.get(AccountService); const settingsService = module.get(SettingsService); websiteOptionsService = module.get( WebsiteOptionsService, ); registryService = module.get( WebsiteRegistryService, ); postService = module.get(PostService); await accountService.onModuleInit(); await settingsService.onModuleInit(); } catch (err) { console.log(err); } }); function createSubmissionDto(): CreateSubmissionDto { const dto = new CreateSubmissionDto(); dto.name = 'Test'; dto.type = SubmissionType.MESSAGE; return dto; } function createAccountDto(): CreateAccountDto { const dto = new CreateAccountDto(); dto.name = 'Test'; dto.website = 'test'; return dto; } function createWebsiteOptionsDto( submissionId: SubmissionId, accountId: AccountId, ): CreateWebsiteOptionsDto { const dto = new CreateWebsiteOptionsDto(); dto.submissionId = submissionId; dto.accountId = accountId; dto.data = { title: 'Test Title', tags: { overrideDefault: true, tags: ['test'], }, description: { overrideDefault: true, description: DefaultDescription(), }, rating: SubmissionRating.GENERAL, }; return dto; } afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should handle pausing and resuming the queue', async () => { await service.pause(); expect(await service.isPaused()).toBe(true); await service.resume(); expect(await service.isPaused()).toBe(false); }); it('should handle enqueue and dequeue of submissions', async () => { await service.pause(); // Just to test the function const submission = await submissionService.create(createSubmissionDto()); const account = await accountService.create(createAccountDto()); expect(registryService.findInstance(account)).toBeDefined(); await websiteOptionsService.create( createWebsiteOptionsDto(submission.id, account.id), ); await service.enqueue([submission.id, submission.id]); expect((await service.findAll()).length).toBe(1); const top = await service.peek(); expect(top).toBeDefined(); expect(top.submission.id).toBe(submission.id); await service.dequeue([submission.id]); expect((await service.findAll()).length).toBe(0); expect(await service.peek()).toBeNull(); }); it('should insert posts into the post manager', async () => { const submission = await submissionService.create(createSubmissionDto()); const account = await accountService.create(createAccountDto()); expect(registryService.findInstance(account)).toBeDefined(); await websiteOptionsService.create( createWebsiteOptionsDto(submission.id, account.id), ); // Enqueue now creates the PostRecord immediately await service.enqueue([submission.id]); expect((await service.findAll()).length).toBe(1); // PostRecord should already exist after enqueue let postRecord = (await postService.findAll())[0]; expect(postRecord).toBeDefined(); expect(postRecord.submissionId).toBe(submission.id); expect(postRecord.state).toBe(PostRecordState.PENDING); // Initially, no manager is posting (so execute will start the post) mockPostManagerRegistry.isPostingType.mockReturnValue(false); // Execute should start the post manager await service.execute(); let queueRecord = await service.peek(); expect(mockPostManagerRegistry.startPost).toHaveBeenCalledWith( expect.objectContaining({ id: postRecord.id, submissionId: submission.id, }), ); expect(queueRecord).toBeDefined(); expect(queueRecord.postRecord).toBeDefined(); // Now simulate that the manager is posting mockPostManagerRegistry.isPostingType.mockReturnValue(true); // Simulate cancellation await mockPostManagerRegistry.cancelIfRunning(submission.id); expect(mockPostManagerRegistry.cancelIfRunning).toHaveBeenCalledWith( submission.id, ); // Simulate the post completing (with failure) - manually update the record const database = new PostyBirbDatabase('PostRecordSchema'); await database.update(postRecord.id, { state: PostRecordState.FAILED, completedAt: new Date().toISOString(), }); // Simulate posting finished mockPostManagerRegistry.isPostingType.mockReturnValue(false); queueRecord = await service.peek(); expect(queueRecord).toBeDefined(); expect(queueRecord.postRecord).toBeDefined(); // We expect the post to be in a terminal state and cleanup of the record. // The post record should remain after the queue record is deleted. await service.execute(); expect((await service.findAll()).length).toBe(0); postRecord = await postService.findById(postRecord.id); expect(postRecord.state).toBe(PostRecordState.FAILED); expect(postRecord.completedAt).toBeDefined(); }); }); ================================================ FILE: apps/client-server/src/app/post/services/post-queue/post-queue.service.ts ================================================ import { Injectable, InternalServerErrorException, OnModuleInit, Optional, } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { EntityId, PostRecordResumeMode, PostRecordState, ScheduleType, SubmissionId, } from '@postybirb/types'; import { IsTestEnvironment } from '@postybirb/utils/electron'; import { Mutex } from 'async-mutex'; import { Cron as CronGenerator } from 'croner'; import { PostyBirbService } from '../../../common/service/postybirb-service'; import { PostQueueRecord, PostRecord } from '../../../drizzle/models'; import { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database'; import { NotificationsService } from '../../../notifications/notifications.service'; import { SettingsService } from '../../../settings/settings.service'; import { SubmissionService } from '../../../submission/services/submission.service'; import { WSGateway } from '../../../web-socket/web-socket-gateway'; import { WebsiteRegistryService } from '../../../websites/website-registry.service'; import { PostManagerRegistry } from '../post-manager-v2'; import { PostRecordFactory } from '../post-record-factory'; /** * Handles the queue of posts to be posted. * This service is responsible for managing the queue of posts to be posted. * It will create post records and start the post manager when a post is ready to be posted. * @class PostQueueService */ @Injectable() export class PostQueueService extends PostyBirbService<'PostQueueRecordSchema'> implements OnModuleInit { private readonly queueModificationMutex = new Mutex(); private readonly queueMutex = new Mutex(); private initTime = Date.now(); private readonly postRecordRepository = new PostyBirbDatabase( 'PostRecordSchema', ); private readonly submissionRepository = new PostyBirbDatabase( 'SubmissionSchema', ); /** * Maximum time (in ms) a post can be RUNNING without any activity before being considered stuck. */ private readonly maxPostIdleTime = 30 * 60 * 1000; // 30 minutes constructor( private readonly postManagerRegistry: PostManagerRegistry, private readonly postRecordFactory: PostRecordFactory, private readonly settingsService: SettingsService, private readonly notificationService: NotificationsService, private readonly submissionService: SubmissionService, private readonly websiteRegistryService: WebsiteRegistryService, @Optional() webSocket?: WSGateway, ) { super('PostQueueRecordSchema', webSocket); } /** * Crash recovery: Resume any PostRecords that were left in RUNNING state. * This handles cases where the application was forcefully shut down or crashed * while a post was in progress. * * Also handles orphaned records - PENDING/RUNNING records that have no queue record * (can happen if queue record was deleted but post record state wasn't updated). */ async onModuleInit() { if (IsTestEnvironment()) { return; } try { // First, handle orphaned post records (PENDING/RUNNING without a queue record) await this.failOrphanedPostRecords(); // Find all RUNNING post records const runningRecords = await this.postRecordRepository.find({ where: (record, { eq }) => eq(record.state, PostRecordState.RUNNING), with: { submission: { with: { files: true, options: { with: { account: true, }, }, }, }, }, }); if (runningRecords.length > 0) { this.logger .withMetadata({ count: runningRecords.length }) .info( 'Detected interrupted PostRecords from crash/shutdown, scheduling resume', ); // Resume in the background so onModuleInit does not block server startup this.resumeInterruptedPosts(runningRecords); } } catch (error) { this.logger .withMetadata({ error }) .error('Failed to recover interrupted posts on startup'); // Don't throw - allow the app to start even if crash recovery fails } } /** * Resume interrupted posts in the background. * This is intentionally not awaited so it does not block server startup. */ private async resumeInterruptedPosts( runningRecords: PostRecord[], ): Promise { try { this.logger.info( 'Waiting for website registry initialization before crash recovery...', ); await this.websiteRegistryService.waitForInitialization(60_000); this.logger.info( 'Website registry initialized, proceeding with crash recovery', ); for (const record of runningRecords) { this.logger .withMetadata({ recordId: record.id, resumeMode: record.resumeMode, }) .info('Resuming interrupted PostRecord'); await this.postManagerRegistry.startPost(record); } } catch (error) { this.logger .withMetadata({ error }) .error('Failed to resume interrupted posts'); } } /** * Find and fail orphaned post records. * An orphaned record is one in PENDING or RUNNING state that has no corresponding queue record. * This can happen if the queue record was deleted (dequeue/cancel) but the post record * state wasn't properly updated to FAILED. */ private async failOrphanedPostRecords(): Promise { // Find all PENDING or RUNNING post records const inProgressRecords = await this.postRecordRepository.find({ where: (record, { or, eq }) => or( eq(record.state, PostRecordState.PENDING), eq(record.state, PostRecordState.RUNNING), ), }); if (inProgressRecords.length === 0) { return; } // Get all queue records to check which post records have a corresponding queue entry const allQueueRecords = await this.repository.findAll(); const queuedPostRecordIds = new Set( allQueueRecords .map((qr) => qr.postRecordId) .filter((id): id is string => id != null), ); // Find orphaned records (in-progress but not in queue) const orphanedRecords = inProgressRecords.filter( (record) => !queuedPostRecordIds.has(record.id), ); if (orphanedRecords.length > 0) { this.logger .withMetadata({ count: orphanedRecords.length }) .warn('Found orphaned PostRecords (PENDING/RUNNING with no queue record), marking as FAILED'); for (const record of orphanedRecords) { this.logger .withMetadata({ recordId: record.id, submissionId: record.submissionId, state: record.state, }) .warn('Marking orphaned PostRecord as FAILED'); await this.postRecordRepository.update(record.id, { state: PostRecordState.FAILED, completedAt: new Date().toISOString(), }); } } } public async isPaused(): Promise { return (await this.settingsService.getDefaultSettings()).settings .queuePaused; } public async pause() { this.logger.info('Queue paused'); const settings = await this.settingsService.getDefaultSettings(); await this.settingsService.update(settings.id, { settings: { ...settings.settings, queuePaused: true }, }); } public async resume() { this.logger.info('Queue resumed'); const settings = await this.settingsService.getDefaultSettings(); await this.settingsService.update(settings.id, { settings: { ...settings.settings, queuePaused: false }, }); } public override remove(id: EntityId) { return this.dequeue([id]); } /** * Get the most recent terminal PostRecord for a submission. * @param {SubmissionId} submissionId - The submission ID * @returns {Promise} The most recent terminal record, or null if none */ private async getMostRecentTerminalPostRecord( submissionId: SubmissionId, ): Promise { const records = await this.postRecordRepository.find({ where: (record, { eq, and, inArray }) => and( eq(record.submissionId, submissionId), inArray(record.state, [PostRecordState.DONE, PostRecordState.FAILED]), ), orderBy: (record, { desc }) => desc(record.createdAt), limit: 1, }); return records.length > 0 ? records[0] : null; } /** * Handle terminal state for a completed post record. * Archives the submission if successful and non-recurring. * Emits appropriate notifications. * @param {PostRecord} record - The completed post record */ private async handleTerminalState(record: PostRecord): Promise { const { submission } = record; const submissionName = submission.getSubmissionName() ?? 'Submission'; if (record.state === PostRecordState.DONE) { this.logger .withMetadata({ submissionId: record.submissionId }) .info('Post completed successfully'); // Archive submission if non-recurring schedule if (submission.schedule.scheduleType !== ScheduleType.RECURRING) { await this.submissionService.archive(record.submissionId); this.logger .withMetadata({ submissionId: record.submissionId }) .info('Submission archived after successful post'); } // Emit success notification await this.notificationService.create({ type: 'success', title: 'Post Completed', message: `Successfully posted "${submissionName}" to all websites`, tags: ['post-success'], data: { submissionId: record.submissionId, type: submission.type, }, }); } else if (record.state === PostRecordState.FAILED) { this.logger .withMetadata({ submissionId: record.submissionId }) .error('Post failed'); // Clear isScheduled for non-recurring submissions so they don't retry indefinitely if ( submission.isScheduled && submission.schedule.scheduleType !== ScheduleType.RECURRING ) { await this.submissionService.update(record.submissionId, { isScheduled: false, }); this.logger .withMetadata({ submissionId: record.submissionId }) .info('Cleared isScheduled flag after post failure'); } // Count failed events for the message const failedEventCount = record.events?.filter((e) => e.eventType === 'POST_ATTEMPT_FAILED') .length ?? 0; // Emit failure notification (summary - individual failures already notified) await this.notificationService.create({ type: 'warning', title: 'Post Incomplete', message: failedEventCount > 0 ? `"${submissionName}" failed to post to ${failedEventCount} website(s)` : `"${submissionName}" failed to post`, tags: ['post-incomplete'], data: { submissionId: record.submissionId, type: submission.type, failedCount: failedEventCount, }, }); } } /** * Enqueue submissions for posting. * If resumeMode is provided and the submission has a terminal PostRecord, * a new PostRecord will be created using the specified resume mode. * * Smart handling: If the most recent PostRecord is DONE (successful completion), * we always create a fresh record (restart) regardless of the provided resumeMode, * since the user is starting a new posting session. * * @param {SubmissionId[]} submissionIds - The submissions to enqueue * @param {PostRecordResumeMode} resumeMode - Optional resume mode for re-queuing terminal records */ public async enqueue( submissionIds: SubmissionId[], resumeMode?: PostRecordResumeMode, ) { if (!submissionIds.length) { return; } const release = await this.queueModificationMutex.acquire(); this.logger .withMetadata({ submissionIds, resumeMode }) .info('Enqueueing posts'); try { for (const submissionId of submissionIds) { // Check if submission exists and is not archived before doing anything const submission = await this.submissionRepository.findById(submissionId); if (!submission) { this.logger .withMetadata({ submissionId }) .warn('Submission not found, skipping enqueue'); continue; } if (submission.isArchived) { this.logger .withMetadata({ submissionId }) .info('Submission is archived, skipping enqueue'); continue; } const existing = await this.repository.findOne({ where: (queueRecord, { eq }) => eq(queueRecord.submissionId, submissionId), with: { postRecord: true, }, }); if (!existing) { // No queue entry exists - determine resume mode based on most recent PostRecord const mostRecentRecord = await this.getMostRecentTerminalPostRecord(submissionId); let effectiveResumeMode: PostRecordResumeMode; if (!mostRecentRecord) { // No prior post record - fresh start this.logger .withMetadata({ submissionId }) .info('No prior PostRecord - creating fresh'); effectiveResumeMode = PostRecordResumeMode.NEW; } else if (mostRecentRecord.state === PostRecordState.DONE) { // Prior was successful - always restart fresh regardless of provided mode this.logger .withMetadata({ submissionId }) .info('Prior PostRecord was DONE - creating fresh (restart)'); effectiveResumeMode = PostRecordResumeMode.NEW; } else { // Prior was FAILED - use provided mode or default to CONTINUE effectiveResumeMode = resumeMode ?? PostRecordResumeMode.CONTINUE; this.logger .withMetadata({ submissionId, priorRecordId: mostRecentRecord.id, resumeMode: effectiveResumeMode, }) .info('Prior PostRecord was FAILED - using resume mode'); } const newRecord = await this.postRecordFactory.create( submissionId, effectiveResumeMode, ); await this.repository.insert({ submissionId, postRecordId: newRecord.id, }); } // If existing, do nothing (first-in-wins) } } catch (error) { this.logger.withMetadata({ error }).error('Failed to enqueue posts'); throw new InternalServerErrorException(error.message); } finally { release(); this.initTime -= 61_000; // Ensure queue processing starts after next cycle } } public async dequeue(submissionIds: SubmissionId[]) { const release = await this.queueModificationMutex.acquire(); this.logger.withMetadata({ submissionIds }).info('Dequeueing posts'); try { const records = await this.repository.find({ where: (queueRecord, { inArray }) => inArray(queueRecord.submissionId, submissionIds), }); submissionIds.forEach((id) => this.postManagerRegistry.cancelIfRunning(id), ); return await this.repository.deleteById(records.map((r) => r.id)); } catch (error) { this.logger.withMetadata({ error }).error('Failed to dequeue posts'); throw new InternalServerErrorException(error.message); } finally { release(); } } /** * CRON based enqueueing of scheduled submissions. */ @Cron(CronExpression.EVERY_30_SECONDS) public async checkForScheduledSubmissions() { if (IsTestEnvironment()) { return; } const entities = await this.submissionRepository.find({ where: (queueRecord, { eq, and }) => and( eq(queueRecord.isScheduled, true), eq(queueRecord.isArchived, false), ), }); const now = Date.now(); const sorted = entities .filter((e) => new Date(e.schedule.scheduledFor).getTime() <= now) // Only those that are ready to be posted. .sort( (a, b) => new Date(a.schedule.scheduledFor).getTime() - new Date(b.schedule.scheduledFor).getTime(), ); // Sort by oldest first. await this.enqueue(sorted.map((s) => s.id)); sorted .filter((s) => s.schedule.cron) .forEach((s) => { const next = CronGenerator(s.schedule.cron).nextRun()?.toISOString(); if (next) { // eslint-disable-next-line no-param-reassign s.schedule.scheduledFor = next; this.submissionRepository.update(s.id, { schedule: s.schedule, }); } }); } /** * This runs a check every second on the state of queue items. * This aims to have simple logic. Each run will either create a post record and start the post manager, * or remove a submission from the queue if it is in a terminal state. * Nothing happens if the queue is empty. */ @Cron(CronExpression.EVERY_SECOND) public async run() { if (!(this.initTime + 60_000 <= Date.now())) { // Only run after 1 minute to allow the application to start up. return; } if (IsTestEnvironment()) { return; } await this.execute(); } /** * Check if a RUNNING post record appears to be stuck (no activity for too long). * Uses both record update time and last event time to determine activity. * * @param {PostRecord} record - The post record to check * @returns {boolean} True if the record appears to be stuck */ private isStuck(record: PostRecord): boolean { if (record.state !== PostRecordState.RUNNING) { return false; } // Find the most recent activity: either record update or last event const recordUpdatedAt = new Date(record.updatedAt).getTime(); const lastEventAt = record.events?.length ? Math.max(...record.events.map((e) => new Date(e.createdAt).getTime())) : 0; const lastActivityAt = Math.max(recordUpdatedAt, lastEventAt); const idleTime = Date.now() - lastActivityAt; return idleTime > this.maxPostIdleTime; } /** * Manages the queue by peeking at the top of the queue and deciding what to do based on the * state of the queue. * * Made public for testing purposes. */ public async execute() { if (this.queueMutex.isLocked()) { return; } const release = await this.queueMutex.acquire(); try { const isPaused = await this.isPaused(); if (isPaused) { this.logger.info('Queue is paused, skipping execution cycle'); return; } const top = await this.peek(); // Queue Empty if (!top) { return; } const { postRecord: record, submissionId, submission } = top; if (submission.isArchived) { // Submission is archived, remove from queue this.logger .withMetadata({ submissionId }) .info('Submission is archived, removing from queue'); await this.dequeue([submissionId]); return; } if (!record) { // PostRecord should always exist since enqueue() creates it // If missing, something is wrong - log error and remove from queue this.logger .withMetadata({ submissionId }) .error('Queue entry has no PostRecord - removing invalid entry'); await this.dequeue([submissionId]); return; } if ( record.state === PostRecordState.DONE || record.state === PostRecordState.FAILED ) { // Post is in a terminal state - handle completion actions and remove from queue await this.handleTerminalState(record); await this.dequeue([submissionId]); } else if (!this.postManagerRegistry.isPostingType(submission.type)) { // Post is not in a terminal state, but the manager for this type is not posting, so restart it. this.logger .withMetadata({ record }) .info( 'PostManager is not posting, but record is not in terminal state. Resuming record.', ); // Start the post - the manager will build resume context from the record await this.postManagerRegistry.startPost(record); } else if (this.isStuck(record)) { // Manager is posting but record shows no activity - it's stuck const recordUpdatedAt = new Date(record.updatedAt).getTime(); const lastEventAt = record.events?.length ? Math.max( ...record.events.map((e) => new Date(e.createdAt).getTime()), ) : 0; const lastActivityAt = Math.max(recordUpdatedAt, lastEventAt); this.logger .withMetadata({ submissionId, recordId: record.id, idleTime: Date.now() - lastActivityAt, lastActivityAt: new Date(lastActivityAt).toISOString(), eventCount: record.events?.length ?? 0, }) .warn( 'PostRecord has been RUNNING without activity - marking as failed', ); await this.postRecordRepository.update(record.id, { state: PostRecordState.FAILED, completedAt: new Date().toISOString(), }); // Next cycle will handle terminal state } // else: manager is actively posting and making progress - do nothing } catch (error) { this.logger.withMetadata({ error }).error('Failed to run queue'); throw error; } finally { release(); } } /** * Peeks at the next item in the queue. * Based on the createdAt date. */ public async peek(): Promise { return this.repository.findOne({ orderBy: (queueRecord, { asc }) => asc(queueRecord.createdAt), with: { submission: true, postRecord: { with: { events: true, submission: { with: { files: true, options: { with: { account: true, }, }, }, }, }, }, }, }); } } ================================================ FILE: apps/client-server/src/app/post/services/post-record-factory/index.ts ================================================ export * from './post-event.repository'; export * from './post-record-factory.service'; ================================================ FILE: apps/client-server/src/app/post/services/post-record-factory/post-event.repository.ts ================================================ import { Injectable } from '@nestjs/common'; import { Insert } from '@postybirb/database'; import { AccountId, EntityId, PostEventType, } from '@postybirb/types'; import { PostEvent } from '../../../drizzle/models'; import { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database'; /** * Repository for querying PostEvent records. * Provides specialized query methods for resume mode logic. * @class PostEventRepository */ @Injectable() export class PostEventRepository { private readonly repository: PostyBirbDatabase<'PostEventSchema'>; constructor() { this.repository = new PostyBirbDatabase('PostEventSchema', { account: true, }); } /** * Get all source URLs from a post record, excluding a specific account. * Used for cross-website source URL propagation within the current post. * @param {EntityId} postRecordId - The post record ID * @param {AccountId} excludeAccountId - The account ID to exclude (to avoid self-referential URLs) * @returns {Promise} Array of source URLs from other accounts */ async getSourceUrlsFromPost( postRecordId: EntityId, excludeAccountId: AccountId, ): Promise { const events = await this.repository.find({ where: (event, { eq, and, inArray }) => and( eq(event.postRecordId, postRecordId), inArray(event.eventType, [ PostEventType.FILE_POSTED, PostEventType.MESSAGE_POSTED, ]), ), }); return events .filter( (event) => event.sourceUrl && event.accountId && event.accountId !== excludeAccountId, ) .map((event) => event.sourceUrl as string); } /** * Get all error events for a post record. * @param {EntityId} postRecordId - The post record ID * @returns {Promise} All failed events */ async getFailedEvents(postRecordId: EntityId): Promise { return this.repository.find({ where: (event, { eq, and, inArray }) => and( eq(event.postRecordId, postRecordId), inArray(event.eventType, [ PostEventType.POST_ATTEMPT_FAILED, PostEventType.FILE_FAILED, PostEventType.MESSAGE_FAILED, ]), ), }); } /** * Insert a new post event. * @param {Insert<'PostEventSchema'>} event - The event to insert * @returns {Promise} The inserted event */ async insert(event: Insert<'PostEventSchema'>): Promise { return this.repository.insert(event) as Promise; } } ================================================ FILE: apps/client-server/src/app/post/services/post-record-factory/post-record-factory.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { EntityId, PostEventType, PostRecordResumeMode, PostRecordState, SubmissionType, } from '@postybirb/types'; import { AccountModule } from '../../../account/account.module'; import { AccountService } from '../../../account/account.service'; import { PostEvent, PostRecord } from '../../../drizzle/models'; import { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database'; import { PostParsersModule } from '../../../post-parsers/post-parsers.module'; import { CreateSubmissionDto } from '../../../submission/dtos/create-submission.dto'; import { SubmissionService } from '../../../submission/services/submission.service'; import { SubmissionModule } from '../../../submission/submission.module'; import { UserSpecifiedWebsiteOptionsModule } from '../../../user-specified-website-options/user-specified-website-options.module'; import { WebsitesModule } from '../../../websites/websites.module'; import { InvalidPostChainError } from '../../errors'; import { PostEventRepository } from './post-event.repository'; import { PostRecordFactory, ResumeContext } from './post-record-factory.service'; describe('PostRecordFactory', () => { let module: TestingModule; let factory: PostRecordFactory; let submissionService: SubmissionService; let accountService: AccountService; let postRecordRepository: PostyBirbDatabase<'PostRecordSchema'>; let postEventRepository: PostEventRepository; beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ imports: [ SubmissionModule, AccountModule, WebsitesModule, UserSpecifiedWebsiteOptionsModule, PostParsersModule, ], providers: [PostRecordFactory, PostEventRepository], }).compile(); factory = module.get(PostRecordFactory); submissionService = module.get(SubmissionService); accountService = module.get(AccountService); postEventRepository = module.get(PostEventRepository); postRecordRepository = new PostyBirbDatabase('PostRecordSchema'); await accountService.onModuleInit(); }); afterAll(async () => { await module.close(); }); async function createSubmission(): Promise { const dto = new CreateSubmissionDto(); dto.name = 'Test Submission'; dto.type = SubmissionType.MESSAGE; const submission = await submissionService.create(dto); return submission.id; } async function createAccount(name: string): Promise { const dto = { name, website: 'test', groups: [], }; const account = await accountService.create(dto as any); return account.id; } describe('create', () => { it('should create a PostRecord with default NEW mode and null originPostRecordId', async () => { const submissionId = await createSubmission(); const record = await factory.create(submissionId); expect(record).toBeDefined(); expect(record.submissionId).toBe(submissionId); expect(record.state).toBe(PostRecordState.PENDING); expect(record.resumeMode).toBe(PostRecordResumeMode.NEW); expect(record.originPostRecordId).toBeNull(); expect(record.id).toBeDefined(); expect(record.createdAt).toBeDefined(); }); it('should create multiple records for different submissions', async () => { const submission1 = await createSubmission(); const submission2 = await createSubmission(); const record1 = await factory.create(submission1); const record2 = await factory.create(submission2); expect(record1.submissionId).toBe(submission1); expect(record2.submissionId).toBe(submission2); expect(record1.id).not.toBe(record2.id); expect(record1.originPostRecordId).toBeNull(); expect(record2.originPostRecordId).toBeNull(); }); it('should set originPostRecordId when creating CONTINUE record', async () => { const submissionId = await createSubmission(); // Create origin NEW record first const originRecord = await factory.create(submissionId, PostRecordResumeMode.NEW); // Mark it as FAILED so CONTINUE is valid await postRecordRepository.update(originRecord.id, { state: PostRecordState.FAILED }); const continueRecord = await factory.create(submissionId, PostRecordResumeMode.CONTINUE); expect(continueRecord.resumeMode).toBe(PostRecordResumeMode.CONTINUE); expect(continueRecord.originPostRecordId).toBe(originRecord.id); }); it('should set originPostRecordId when creating CONTINUE_RETRY record', async () => { const submissionId = await createSubmission(); // Create origin NEW record first const originRecord = await factory.create(submissionId, PostRecordResumeMode.NEW); // Mark it as FAILED so CONTINUE_RETRY is valid await postRecordRepository.update(originRecord.id, { state: PostRecordState.FAILED }); const retryRecord = await factory.create(submissionId, PostRecordResumeMode.CONTINUE_RETRY); expect(retryRecord.resumeMode).toBe(PostRecordResumeMode.CONTINUE_RETRY); expect(retryRecord.originPostRecordId).toBe(originRecord.id); }); it('should throw InvalidPostChainError for CONTINUE without any origin', async () => { const submissionId = await createSubmission(); await expect( factory.create(submissionId, PostRecordResumeMode.CONTINUE), ).rejects.toThrow(InvalidPostChainError); try { await factory.create(submissionId, PostRecordResumeMode.CONTINUE); } catch (error) { expect(error).toBeInstanceOf(InvalidPostChainError); expect((error as InvalidPostChainError).reason).toBe('no_origin'); } }); it('should throw InvalidPostChainError for CONTINUE_RETRY without any origin', async () => { const submissionId = await createSubmission(); await expect( factory.create(submissionId, PostRecordResumeMode.CONTINUE_RETRY), ).rejects.toThrow(InvalidPostChainError); try { await factory.create(submissionId, PostRecordResumeMode.CONTINUE_RETRY); } catch (error) { expect(error).toBeInstanceOf(InvalidPostChainError); expect((error as InvalidPostChainError).reason).toBe('no_origin'); } }); it('should throw InvalidPostChainError when origin is DONE', async () => { const submissionId = await createSubmission(); // Create origin NEW record and mark it DONE (closed chain) const originRecord = await factory.create(submissionId, PostRecordResumeMode.NEW); await postRecordRepository.update(originRecord.id, { state: PostRecordState.DONE }); await expect( factory.create(submissionId, PostRecordResumeMode.CONTINUE), ).rejects.toThrow(InvalidPostChainError); try { await factory.create(submissionId, PostRecordResumeMode.CONTINUE); } catch (error) { expect(error).toBeInstanceOf(InvalidPostChainError); expect((error as InvalidPostChainError).reason).toBe('origin_done'); } }); it('should chain to most recent NEW record, not older ones', async () => { const submissionId = await createSubmission(); // Create first NEW record and mark it DONE const oldOrigin = await factory.create(submissionId, PostRecordResumeMode.NEW); await postRecordRepository.update(oldOrigin.id, { state: PostRecordState.DONE }); // Create second NEW record (new chain) const newOrigin = await factory.create(submissionId, PostRecordResumeMode.NEW); await postRecordRepository.update(newOrigin.id, { state: PostRecordState.FAILED }); // CONTINUE should chain to the newer origin const continueRecord = await factory.create(submissionId, PostRecordResumeMode.CONTINUE); expect(continueRecord.originPostRecordId).toBe(newOrigin.id); expect(continueRecord.originPostRecordId).not.toBe(oldOrigin.id); }); it('should throw InvalidPostChainError when PENDING record exists for submission', async () => { const submissionId = await createSubmission(); // Create a PENDING record (simulating one already in the queue) await postRecordRepository.insert({ submissionId, state: PostRecordState.PENDING, resumeMode: PostRecordResumeMode.NEW, originPostRecordId: null, }); // Attempting to create another should fail await expect( factory.create(submissionId, PostRecordResumeMode.NEW), ).rejects.toThrow(InvalidPostChainError); try { await factory.create(submissionId, PostRecordResumeMode.NEW); } catch (error) { expect(error).toBeInstanceOf(InvalidPostChainError); expect((error as InvalidPostChainError).reason).toBe('in_progress'); } }); it('should throw InvalidPostChainError when RUNNING record exists for submission', async () => { const submissionId = await createSubmission(); // Create a RUNNING record (simulating one currently being processed) await postRecordRepository.insert({ submissionId, state: PostRecordState.RUNNING, resumeMode: PostRecordResumeMode.NEW, originPostRecordId: null, }); // Attempting to create another should fail await expect( factory.create(submissionId, PostRecordResumeMode.NEW), ).rejects.toThrow(InvalidPostChainError); try { await factory.create(submissionId, PostRecordResumeMode.NEW); } catch (error) { expect(error).toBeInstanceOf(InvalidPostChainError); expect((error as InvalidPostChainError).reason).toBe('in_progress'); } }); it('should allow creating NEW record when prior records are FAILED or DONE', async () => { const submissionId = await createSubmission(); // Create a FAILED record await postRecordRepository.insert({ submissionId, state: PostRecordState.FAILED, resumeMode: PostRecordResumeMode.NEW, originPostRecordId: null, }); // Create a DONE record await postRecordRepository.insert({ submissionId, state: PostRecordState.DONE, resumeMode: PostRecordResumeMode.NEW, originPostRecordId: null, }); // Creating a NEW record should succeed (no PENDING/RUNNING blocking) const record = await factory.create(submissionId, PostRecordResumeMode.NEW); expect(record).toBeDefined(); expect(record.state).toBe(PostRecordState.PENDING); }); }); describe('buildResumeContext', () => { async function createPostRecordWithState( submissionId: EntityId, state: PostRecordState, resumeMode: PostRecordResumeMode, originPostRecordId?: EntityId, ): Promise { const record = await postRecordRepository.insert({ submissionId, state, resumeMode, originPostRecordId: originPostRecordId ?? null, }); return record; } async function addEvent( postRecordId: EntityId, eventType: PostEventType, accountId?: string, additionalData?: Partial, ): Promise { const eventData: any = { postRecordId, eventType, ...additionalData, }; // Only add accountId if provided if (accountId) { eventData.accountId = accountId as EntityId; } return postEventRepository.insert(eventData); } it('should return empty context for NEW mode', async () => { const submissionId = await createSubmission(); // Create an origin NEW record first const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); // Create a CONTINUE record chained to it const priorRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.CONTINUE, originRecord.id, ); const context = await factory.buildResumeContext( submissionId, priorRecord.id, PostRecordResumeMode.NEW, ); expect(context.completedAccountIds.size).toBe(0); expect(context.postedFilesByAccount.size).toBe(0); expect(context.sourceUrlsByAccount.size).toBe(0); }); it('should return empty context when no prior records exist', async () => { const submissionId = await createSubmission(); // Create a NEW record with no chain const priorRecord = await createPostRecordWithState( submissionId, PostRecordState.PENDING, PostRecordResumeMode.NEW, ); const context = await factory.buildResumeContext( submissionId, priorRecord.id, PostRecordResumeMode.CONTINUE, ); expect(context.completedAccountIds.size).toBe(0); expect(context.postedFilesByAccount.size).toBe(0); }); it('should aggregate completed accounts', async () => { const submissionId = await createSubmission(); const account1 = await createAccount('account-1'); const account2 = await createAccount('account-2'); // Create origin NEW record const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); await addEvent( originRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1, ); await addEvent( originRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account2, ); const context = await factory.buildResumeContext( submissionId, originRecord.id, PostRecordResumeMode.CONTINUE_RETRY, ); expect(context.completedAccountIds.size).toBe(2); expect(context.completedAccountIds.has(account1)).toBe(true); expect(context.completedAccountIds.has(account2)).toBe(true); }); it('should aggregate posted files in CONTINUE mode', async () => { const submissionId = await createSubmission(); const account1 = await createAccount('account-1'); // Create origin NEW record const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); await addEvent(originRecord.id, PostEventType.FILE_POSTED, account1, { fileId: 'file-1' as EntityId, sourceUrl: 'https://example.com/1', }); await addEvent(originRecord.id, PostEventType.FILE_POSTED, account1, { fileId: 'file-2' as EntityId, sourceUrl: 'https://example.com/2', }); const context = await factory.buildResumeContext( submissionId, originRecord.id, PostRecordResumeMode.CONTINUE, ); expect(context.postedFilesByAccount.size).toBe(1); const postedFiles = context.postedFilesByAccount.get(account1); expect(postedFiles?.size).toBe(2); expect(postedFiles?.has('file-1' as EntityId)).toBe(true); expect(postedFiles?.has('file-2' as EntityId)).toBe(true); }); it('should NOT aggregate posted files in CONTINUE_RETRY mode', async () => { const submissionId = await createSubmission(); const account1 = await createAccount('account-1'); // Create origin NEW record const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); await addEvent( originRecord.id, PostEventType.FILE_POSTED, account1, { fileId: 'file-1' as EntityId, }, ); const context = await factory.buildResumeContext( submissionId, originRecord.id, PostRecordResumeMode.CONTINUE_RETRY, ); expect(context.postedFilesByAccount.size).toBe(0); }); it('should aggregate source URLs from FILE_POSTED events', async () => { const submissionId = await createSubmission(); const account1 = await createAccount('account-1'); // Create origin NEW record const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); await addEvent( originRecord.id, PostEventType.FILE_POSTED, account1, { sourceUrl: 'https://example.com/post1', }, ); const context = await factory.buildResumeContext( submissionId, originRecord.id, PostRecordResumeMode.CONTINUE, ); const sourceUrls = context.sourceUrlsByAccount.get(account1); expect(sourceUrls).toHaveLength(1); expect(sourceUrls?.[0]).toBe('https://example.com/post1'); }); it('should aggregate source URLs from MESSAGE_POSTED events', async () => { const submissionId = await createSubmission(); const account1 = await createAccount('account-1'); // Create origin NEW record const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); await addEvent( originRecord.id, PostEventType.MESSAGE_POSTED, account1, { sourceUrl: 'https://example.com/message1', }, ); const context = await factory.buildResumeContext( submissionId, originRecord.id, PostRecordResumeMode.CONTINUE, ); const sourceUrls = context.sourceUrlsByAccount.get(account1); expect(sourceUrls).toHaveLength(1); expect(sourceUrls?.[0]).toBe('https://example.com/message1'); }); it('should only aggregate events from current chain (not prior DONE chain)', async () => { const submissionId = await createSubmission(); const accountOld = await createAccount('account-old'); const accountNew = await createAccount('account-new'); // Create older chain that completed successfully (DONE) const olderOrigin = await createPostRecordWithState( submissionId, PostRecordState.DONE, PostRecordResumeMode.NEW, ); await addEvent( olderOrigin.id, PostEventType.POST_ATTEMPT_COMPLETED, accountOld, ); // Create new chain origin const newOrigin = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); await addEvent( newOrigin.id, PostEventType.POST_ATTEMPT_COMPLETED, accountNew, ); const context = await factory.buildResumeContext( submissionId, newOrigin.id, PostRecordResumeMode.CONTINUE_RETRY, ); // Should only have account-new from the current chain expect(context.completedAccountIds.size).toBe(1); expect(context.completedAccountIds.has(accountNew)).toBe(true); expect(context.completedAccountIds.has(accountOld)).toBe(false); }); it('should aggregate events from origin and all chained records', async () => { const submissionId = await createSubmission(); const accountRestart = await createAccount('account-restart'); const accountNew = await createAccount('account-new'); // Create NEW record (origin) const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); await addEvent( originRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, accountRestart, ); // Create CONTINUE record chained to origin const continueRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.CONTINUE, originRecord.id, ); await addEvent( continueRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, accountNew, ); const context = await factory.buildResumeContext( submissionId, continueRecord.id, PostRecordResumeMode.CONTINUE_RETRY, ); // Should have both accounts from the chain expect(context.completedAccountIds.size).toBe(2); expect(context.completedAccountIds.has(accountNew)).toBe(true); expect(context.completedAccountIds.has(accountRestart)).toBe(true); }); it('should aggregate events from multiple chained FAILED records', async () => { const submissionId = await createSubmission(); const account1 = await createAccount('account-1'); const account2 = await createAccount('account-2'); const account3 = await createAccount('account-3'); // Create origin NEW record const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); await addEvent( originRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1, ); // Create first CONTINUE chained to origin const continue1 = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.CONTINUE, originRecord.id, ); await addEvent( continue1.id, PostEventType.POST_ATTEMPT_COMPLETED, account2, ); // Create second CONTINUE chained to origin const continue2 = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.CONTINUE, originRecord.id, ); await addEvent( continue2.id, PostEventType.POST_ATTEMPT_COMPLETED, account3, ); const context = await factory.buildResumeContext( submissionId, continue2.id, PostRecordResumeMode.CONTINUE_RETRY, ); expect(context.completedAccountIds.size).toBe(3); expect(context.completedAccountIds.has(account1)).toBe(true); expect(context.completedAccountIds.has(account2)).toBe(true); expect(context.completedAccountIds.has(account3)).toBe(true); }); }); describe('shouldSkipAccount', () => { it('should return true for completed accounts', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.CONTINUE_RETRY, completedAccountIds: new Set(['account-1' as EntityId]), postedFilesByAccount: new Map(), sourceUrlsByAccount: new Map(), }; expect(factory.shouldSkipAccount(context, 'account-1' as EntityId)).toBe( true, ); }); it('should return false for non-completed accounts', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.CONTINUE_RETRY, completedAccountIds: new Set(['account-1' as EntityId]), postedFilesByAccount: new Map(), sourceUrlsByAccount: new Map(), }; expect(factory.shouldSkipAccount(context, 'account-2' as EntityId)).toBe( false, ); }); }); describe('shouldSkipFile', () => { it('should return false in NEW mode', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.NEW, completedAccountIds: new Set(), postedFilesByAccount: new Map(), sourceUrlsByAccount: new Map(), }; expect( factory.shouldSkipFile( context, 'account-1' as EntityId, 'file-1' as EntityId, ), ).toBe(false); }); it('should return false in CONTINUE_RETRY mode', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.CONTINUE_RETRY, completedAccountIds: new Set(), postedFilesByAccount: new Map([ ['account-1' as EntityId, new Set(['file-1' as EntityId])], ]), sourceUrlsByAccount: new Map(), }; expect( factory.shouldSkipFile( context, 'account-1' as EntityId, 'file-1' as EntityId, ), ).toBe(false); }); it('should return true for posted files in CONTINUE mode', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.CONTINUE, completedAccountIds: new Set(), postedFilesByAccount: new Map([ ['account-1' as EntityId, new Set(['file-1' as EntityId])], ]), sourceUrlsByAccount: new Map(), }; expect( factory.shouldSkipFile( context, 'account-1' as EntityId, 'file-1' as EntityId, ), ).toBe(true); }); it('should return false for non-posted files in CONTINUE mode', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.CONTINUE, completedAccountIds: new Set(), postedFilesByAccount: new Map([ ['account-1' as EntityId, new Set(['file-1' as EntityId])], ]), sourceUrlsByAccount: new Map(), }; expect( factory.shouldSkipFile( context, 'account-1' as EntityId, 'file-2' as EntityId, ), ).toBe(false); }); }); describe('getSourceUrlsForAccount', () => { it('should return source URLs for an account', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.CONTINUE, completedAccountIds: new Set(), postedFilesByAccount: new Map(), sourceUrlsByAccount: new Map([ [ 'account-1' as EntityId, ['https://example.com/1', 'https://example.com/2'], ], ]), }; const urls = factory.getSourceUrlsForAccount( context, 'account-1' as EntityId, ); expect(urls).toHaveLength(2); expect(urls[0]).toBe('https://example.com/1'); expect(urls[1]).toBe('https://example.com/2'); }); it('should return empty array for account with no URLs', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.CONTINUE, completedAccountIds: new Set(), postedFilesByAccount: new Map(), sourceUrlsByAccount: new Map(), }; const urls = factory.getSourceUrlsForAccount( context, 'account-1' as EntityId, ); expect(urls).toHaveLength(0); }); }); describe('getAllSourceUrls', () => { it('should return all source URLs from all accounts', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.CONTINUE, completedAccountIds: new Set(), postedFilesByAccount: new Map(), sourceUrlsByAccount: new Map([ ['account-1' as EntityId, ['https://example.com/1']], ['account-2' as EntityId, ['https://example.com/2']], ]), }; const urls = factory.getAllSourceUrls(context); expect(urls).toHaveLength(2); expect(urls).toContain('https://example.com/1'); expect(urls).toContain('https://example.com/2'); }); it('should return empty array when no URLs exist', () => { const context: ResumeContext = { resumeMode: PostRecordResumeMode.CONTINUE, completedAccountIds: new Set(), postedFilesByAccount: new Map(), sourceUrlsByAccount: new Map(), }; const urls = factory.getAllSourceUrls(context); expect(urls).toHaveLength(0); }); }); describe('crash recovery', () => { /** * These tests verify the crash recovery behavior: * When PostyBirb crashes mid-post, a PostRecord is left in RUNNING state. * On restart, onModuleInit() finds these RUNNING records and resumes them. * The key behavior is that events from the RUNNING record must be preserved, * EVEN if the user originally requested NEW mode (fresh start). */ async function createPostRecordWithState( submissionId: EntityId, state: PostRecordState, resumeMode: PostRecordResumeMode, originPostRecordId?: EntityId, ): Promise { const record = await postRecordRepository.insert({ submissionId, state, resumeMode, originPostRecordId: originPostRecordId ?? null, }); return record; } async function addEvent( postRecordId: EntityId, eventType: PostEventType, accountId?: string, additionalData?: Partial, ): Promise { const eventData: any = { postRecordId, eventType, ...additionalData, }; if (accountId) { eventData.accountId = accountId as EntityId; } return postEventRepository.insert(eventData); } it('should aggregate events from RUNNING record even with NEW mode (crash recovery)', async () => { // Scenario: User started a NEW post, posted to 2 accounts, then crashed. // On restart, we must preserve those 2 completed accounts. const submissionId = await createSubmission(); const account1 = await createAccount('crash-account-1'); const account2 = await createAccount('crash-account-2'); // Create a RUNNING record with NEW mode (simulates mid-post crash) // NEW mode records are their own origin (originPostRecordId = null) const runningRecord = await createPostRecordWithState( submissionId, PostRecordState.RUNNING, PostRecordResumeMode.NEW, ); // Add events that occurred before crash await addEvent(runningRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1); await addEvent(runningRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account2); await addEvent(runningRecord.id, PostEventType.FILE_POSTED, account1, { fileId: 'crash-file-1' as EntityId, sourceUrl: 'https://example.com/crash-1', }); // Simulate crash recovery: buildResumeContext is called with NEW mode // but the record is still RUNNING (not terminal) const context = await factory.buildResumeContext( submissionId, runningRecord.id, PostRecordResumeMode.NEW, ); // Despite NEW mode, crash recovery should preserve completed accounts expect(context.completedAccountIds.size).toBe(2); expect(context.completedAccountIds.has(account1)).toBe(true); expect(context.completedAccountIds.has(account2)).toBe(true); // Crash recovery with NEW mode should include posted files to avoid re-uploading expect(context.postedFilesByAccount.size).toBe(1); const postedFiles = context.postedFilesByAccount.get(account1); expect(postedFiles?.has('crash-file-1' as EntityId)).toBe(true); }); it('should return empty context for NEW mode with terminal (FAILED/DONE) record', async () => { // Normal NEW behavior: if the prior record is terminal, start fresh const submissionId = await createSubmission(); const account1 = await createAccount('terminal-account-1'); // Create origin NEW record that failed const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); // Create a CONTINUE record chained to it const failedRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.CONTINUE, originRecord.id, ); await addEvent(failedRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1); // NEW mode with a FAILED record should return empty context const context = await factory.buildResumeContext( submissionId, failedRecord.id, PostRecordResumeMode.NEW, ); expect(context.completedAccountIds.size).toBe(0); expect(context.postedFilesByAccount.size).toBe(0); }); it('should aggregate events from RUNNING record with CONTINUE mode (crash recovery)', async () => { // CONTINUE mode crash recovery should also work const submissionId = await createSubmission(); const account1 = await createAccount('continue-crash-account-1'); // Create origin NEW record const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); // Create RUNNING CONTINUE record chained to origin const runningRecord = await createPostRecordWithState( submissionId, PostRecordState.RUNNING, PostRecordResumeMode.CONTINUE, originRecord.id, ); await addEvent(runningRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1); await addEvent(runningRecord.id, PostEventType.FILE_POSTED, account1, { fileId: 'continue-crash-file-1' as EntityId, }); const context = await factory.buildResumeContext( submissionId, runningRecord.id, PostRecordResumeMode.CONTINUE, ); // CONTINUE mode crash recovery should include both completed accounts and posted files expect(context.completedAccountIds.size).toBe(1); expect(context.completedAccountIds.has(account1)).toBe(true); expect(context.postedFilesByAccount.size).toBe(1); const postedFiles = context.postedFilesByAccount.get(account1); expect(postedFiles?.has('continue-crash-file-1' as EntityId)).toBe(true); }); it('should combine RUNNING record with prior FAILED records in CONTINUE mode', async () => { // Scenario: Previous attempt failed, user chose CONTINUE, then crashed mid-post. // We need to aggregate from both the RUNNING record AND the prior FAILED record. const submissionId = await createSubmission(); const account1 = await createAccount('prior-account-1'); const account2 = await createAccount('current-account-2'); // Create origin NEW record const originRecord = await createPostRecordWithState( submissionId, PostRecordState.FAILED, PostRecordResumeMode.NEW, ); await addEvent(originRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1); await addEvent(originRecord.id, PostEventType.FILE_POSTED, account1, { fileId: 'prior-file-1' as EntityId, }); // Second attempt: CONTINUE from prior, posted to account2, then crashed const runningRecord = await createPostRecordWithState( submissionId, PostRecordState.RUNNING, PostRecordResumeMode.CONTINUE, originRecord.id, ); await addEvent(runningRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account2); await addEvent(runningRecord.id, PostEventType.FILE_POSTED, account2, { fileId: 'current-file-1' as EntityId, }); const context = await factory.buildResumeContext( submissionId, runningRecord.id, PostRecordResumeMode.CONTINUE, ); // Should include completed accounts from both records expect(context.completedAccountIds.size).toBe(2); expect(context.completedAccountIds.has(account1)).toBe(true); expect(context.completedAccountIds.has(account2)).toBe(true); // Should include posted files from both records expect(context.postedFilesByAccount.size).toBe(2); expect(context.postedFilesByAccount.get(account1)?.has('prior-file-1' as EntityId)).toBe(true); expect(context.postedFilesByAccount.get(account2)?.has('current-file-1' as EntityId)).toBe(true); }); it('should preserve source URLs from RUNNING record in crash recovery', async () => { const submissionId = await createSubmission(); const account1 = await createAccount('url-crash-account-1'); // Create RUNNING NEW record (origin with crash) const runningRecord = await createPostRecordWithState( submissionId, PostRecordState.RUNNING, PostRecordResumeMode.NEW, ); await addEvent(runningRecord.id, PostEventType.FILE_POSTED, account1, { fileId: 'url-file-1' as EntityId, sourceUrl: 'https://example.com/posted-before-crash', }); const context = await factory.buildResumeContext( submissionId, runningRecord.id, PostRecordResumeMode.NEW, ); // Source URLs should be preserved for NEW mode crash recovery expect(context.sourceUrlsByAccount.size).toBe(1); const urls = context.sourceUrlsByAccount.get(account1); expect(urls).toContain('https://example.com/posted-before-crash'); }); }); }); ================================================ FILE: apps/client-server/src/app/post/services/post-record-factory/post-record-factory.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { AccountId, EntityId, PostEventType, PostRecordResumeMode, PostRecordState, } from '@postybirb/types'; import { PostEvent, PostRecord } from '../../../drizzle/models'; import { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database'; import { InvalidPostChainError } from '../../errors'; import { PostEventRepository } from './post-event.repository'; /** * Resume context containing information from a prior post attempt. * Used to determine what to skip or retry when resuming. * @interface ResumeContext */ export interface ResumeContext { /** * The resume mode used to create this context. * @type {PostRecordResumeMode} */ resumeMode: PostRecordResumeMode; /** * Account IDs that have already completed successfully. * For CONTINUE_RETRY mode, these accounts should be skipped entirely. * @type {Set} */ completedAccountIds: Set; /** * Map of account ID to file IDs that have been successfully posted. * For CONTINUE mode, these files should be skipped for each account. * @type {Map>} */ postedFilesByAccount: Map>; /** * Map of account ID to source URLs from prior successful posts. * Used for cross-website source URL propagation. * @type {Map} */ sourceUrlsByAccount: Map; } /** * Factory service for creating PostRecord entities. * Handles resume mode logic by querying the event ledger. * @class PostRecordFactory */ @Injectable() export class PostRecordFactory { private readonly logger = Logger(this.constructor.name); private readonly postRecordRepository: PostyBirbDatabase<'PostRecordSchema'>; constructor(private readonly postEventRepository: PostEventRepository) { this.postRecordRepository = new PostyBirbDatabase('PostRecordSchema'); } /** * Create a new PostRecord for a submission. * * For NEW mode: Creates a fresh record with originPostRecordId = null (it IS the origin). * For CONTINUE/RETRY: Finds the most recent NEW record for this submission and chains to it. * * @param {EntityId} submissionId - The submission ID * @param {PostRecordResumeMode} resumeMode - The resume mode (defaults to NEW) * @returns {Promise} The created post record * @throws {InvalidPostChainError} If CONTINUE/RETRY is requested but no valid origin exists, * or if a PostRecord is already PENDING or RUNNING for this submission */ async create( submissionId: EntityId, resumeMode: PostRecordResumeMode = PostRecordResumeMode.NEW, ): Promise { this.logger .withMetadata({ submissionId, resumeMode }) .info('Creating post record'); // Guard: Prevent creating a new record if one is already in progress const inProgressRecord = await this.findInProgressRecord(submissionId); if (inProgressRecord) { this.logger .withMetadata({ existingRecordId: inProgressRecord.id, existingState: inProgressRecord.state }) .warn('Cannot create PostRecord: submission already has an in-progress record'); throw new InvalidPostChainError(submissionId, resumeMode, 'in_progress'); } let originPostRecordId: EntityId | null = null; if (resumeMode !== PostRecordResumeMode.NEW) { // Find the most recent NEW record for this submission (no state filter) const originRecord = await this.findMostRecentOrigin(submissionId); if (!originRecord) { throw new InvalidPostChainError(submissionId, resumeMode, 'no_origin'); } if (originRecord.state === PostRecordState.DONE) { throw new InvalidPostChainError(submissionId, resumeMode, 'origin_done'); } originPostRecordId = originRecord.id; this.logger .withMetadata({ originPostRecordId }) .debug('Chaining to origin PostRecord'); } return this.postRecordRepository.insert({ submissionId, state: PostRecordState.PENDING, resumeMode, originPostRecordId, }); } /** * Find the most recent NEW PostRecord for a submission. * Used to determine the origin for CONTINUE/RETRY records. * * @param {EntityId} submissionId - The submission ID * @returns {Promise} The most recent NEW record, or null if none exists */ private async findMostRecentOrigin( submissionId: EntityId, ): Promise { const records = await this.postRecordRepository.find({ where: (record, { eq, and }) => and( eq(record.submissionId, submissionId), eq(record.resumeMode, PostRecordResumeMode.NEW), ), orderBy: (record, { desc }) => desc(record.createdAt), limit: 1, }); return records.length > 0 ? records[0] : null; } /** * Find any PENDING or RUNNING PostRecord for a submission. * Used to prevent creating a new record when one is already in progress. * * @param {EntityId} submissionId - The submission ID * @returns {Promise} An in-progress record, or null if none exists */ private async findInProgressRecord( submissionId: EntityId, ): Promise { const records = await this.postRecordRepository.find({ where: (record, { eq, and, or }) => and( eq(record.submissionId, submissionId), or( eq(record.state, PostRecordState.PENDING), eq(record.state, PostRecordState.RUNNING), ), ), limit: 1, }); return records.length > 0 ? records[0] : null; } // ======================================================================== // Resume Context Building // ======================================================================== /** * Build resume context from prior post records for the same submission. * * This method handles the chain of posting attempts correctly: * - DONE records represent complete "posting sessions" - they act as stop points * - FAILED records represent incomplete attempts that should be aggregated * - RUNNING records (crash recovery) should aggregate their own events * * Logic: * 1. Always include events from the current record being started (handles crash recovery) * 2. If the most recent terminal is DONE → return empty context (nothing to continue, start fresh) * 3. If the most recent terminal is FAILED → aggregate from FAILED records until we hit a DONE * * @param {EntityId} submissionId - The submission ID * @param {EntityId} currentRecordId - The ID of the record being started * @param {PostRecordResumeMode} resumeMode - The resume mode * @returns {Promise} The resume context */ async buildResumeContext( submissionId: EntityId, currentRecordId: EntityId, resumeMode: PostRecordResumeMode, ): Promise { const context = this.createEmptyContext(resumeMode); // First, always fetch the specific record we're starting. // This handles crash recovery where the record is RUNNING (not terminal). const currentRecord = await this.postRecordRepository.findById( currentRecordId, undefined, { events: true }, ); // For NEW mode on a fresh record, return empty context // But for crash recovery (RUNNING state), we still need to aggregate our own events if (resumeMode === PostRecordResumeMode.NEW) { if (currentRecord?.state === PostRecordState.RUNNING) { // Crash recovery: aggregate events from this record regardless of resumeMode this.logger.debug( 'NEW mode but RUNNING state (crash recovery) - aggregating own events', ); this.aggregateFromRecords([currentRecord], context, true); } else { this.logger.debug('NEW mode - returning empty resume context'); } return context; } // Get terminal records to aggregate based on the chain logic const terminalRecords = await this.getRecordsToAggregate(submissionId); // Combine: current record (if RUNNING) + terminal records (excluding duplicates) const recordsToAggregate = this.combineRecordsForAggregation( currentRecord, terminalRecords, ); if (recordsToAggregate.length === 0) { this.logger.debug( 'No records to aggregate (fresh start or most recent was DONE)', ); return context; } // Aggregate events based on resume mode const includePostedFiles = resumeMode === PostRecordResumeMode.CONTINUE; this.aggregateFromRecords(recordsToAggregate, context, includePostedFiles); this.logger .withMetadata({ completedAccountCount: context.completedAccountIds.size, accountsWithPostedFiles: context.postedFilesByAccount.size, aggregatedRecordCount: recordsToAggregate.length, resumeMode, }) .debug('Built resume context'); return context; } /** * Combine the current record (if RUNNING) with terminal records, avoiding duplicates. * This ensures crash recovery includes the RUNNING record's events. */ private combineRecordsForAggregation( currentRecord: PostRecord | null | undefined, terminalRecords: PostRecord[], ): PostRecord[] { const result: PostRecord[] = []; const seenIds = new Set(); // Add current record first if it's RUNNING (crash recovery case) if (currentRecord?.state === PostRecordState.RUNNING) { result.push(currentRecord); seenIds.add(currentRecord.id); } // Add terminal records that weren't already added for (const record of terminalRecords) { if (!seenIds.has(record.id)) { result.push(record); seenIds.add(record.id); } } return result; } /** * Aggregate events from a list of records into the context. */ private aggregateFromRecords( records: PostRecord[], context: ResumeContext, includePostedFiles: boolean, ): void { this.aggregateSourceUrls(records, context); this.aggregateCompletedAccounts(records, context); if (includePostedFiles) { this.aggregatePostedFiles(records, context); } } /** * Create an empty resume context with default values. */ private createEmptyContext(resumeMode: PostRecordResumeMode): ResumeContext { return { resumeMode, completedAccountIds: new Set(), postedFilesByAccount: new Map>(), sourceUrlsByAccount: new Map(), }; } /** * Get the list of PostRecords whose events should be aggregated. * * Uses the originPostRecordId field to find all records in the same chain. * A chain consists of: * - The origin NEW record (originPostRecordId = null, resumeMode = NEW) * - All CONTINUE/RETRY records that reference that origin * * @param {EntityId} submissionId - The submission ID * @param {EntityId} [originId] - Optional origin ID to query directly * @returns {Promise} Records to aggregate (may be empty) */ private async getRecordsToAggregate( submissionId: EntityId, originId?: EntityId, ): Promise { // If no origin provided, find the most recent origin for this submission let effectiveOriginId = originId; if (!effectiveOriginId) { const origin = await this.findMostRecentOrigin(submissionId); if (!origin) { this.logger.debug('No origin PostRecord found for submission'); return []; } effectiveOriginId = origin.id; } // Query all records in this chain: the origin + all records referencing it const chainRecords = await this.postRecordRepository.find({ where: (record, { eq, or }) => or( eq(record.id, effectiveOriginId), eq(record.originPostRecordId, effectiveOriginId), ), orderBy: (record, { asc }) => asc(record.createdAt), with: { events: true, }, }); this.logger .withMetadata({ submissionId, originId: effectiveOriginId, chainRecordCount: chainRecords.length, chainRecordIds: chainRecords.map((r) => r.id), }) .debug('Retrieved chain records for aggregation'); return chainRecords; } /** * Aggregate source URLs from events into the context. * Source URLs are used for cross-website propagation. */ private aggregateSourceUrls( records: PostRecord[], context: ResumeContext, ): void { for (const record of records) { if (!record.events) continue; for (const event of record.events) { if ( this.isSourceUrlEvent(event) && event.accountId && event.sourceUrl ) { this.addSourceUrl(context, event.accountId, event.sourceUrl); } } } } /** * Aggregate completed account IDs from events into the context. */ private aggregateCompletedAccounts( records: PostRecord[], context: ResumeContext, ): void { for (const record of records) { if (!record.events) continue; for (const event of record.events) { if ( event.eventType === PostEventType.POST_ATTEMPT_COMPLETED && event.accountId ) { context.completedAccountIds.add(event.accountId); } } } } /** * Aggregate posted file IDs (per account) from events into the context. */ private aggregatePostedFiles( records: PostRecord[], context: ResumeContext, ): void { for (const record of records) { if (!record.events) continue; for (const event of record.events) { if ( event.eventType === PostEventType.FILE_POSTED && event.accountId && event.fileId ) { this.addPostedFile(context, event.accountId, event.fileId); } } } } /** * Check if an event contains a source URL (FILE_POSTED or MESSAGE_POSTED). */ private isSourceUrlEvent(event: PostEvent): boolean { return ( event.eventType === PostEventType.FILE_POSTED || event.eventType === PostEventType.MESSAGE_POSTED ); } /** * Add a source URL to the context for an account. */ private addSourceUrl( context: ResumeContext, accountId: AccountId, sourceUrl: string, ): void { const existing = context.sourceUrlsByAccount.get(accountId); if (existing) { existing.push(sourceUrl); } else { context.sourceUrlsByAccount.set(accountId, [sourceUrl]); } } /** * Add a posted file to the context for an account. */ private addPostedFile( context: ResumeContext, accountId: AccountId, fileId: EntityId, ): void { const existing = context.postedFilesByAccount.get(accountId); if (existing) { existing.add(fileId); } else { context.postedFilesByAccount.set(accountId, new Set([fileId])); } } // ======================================================================== // Resume Context Helpers // ======================================================================== /** * Check if an account should be skipped based on resume context. * @param {ResumeContext} context - The resume context * @param {AccountId} accountId - The account to check * @returns {boolean} True if the account should be skipped */ shouldSkipAccount(context: ResumeContext, accountId: AccountId): boolean { return context.completedAccountIds.has(accountId); } /** * Check if a file should be skipped for a specific account based on resume context. * @param {ResumeContext} context - The resume context * @param {AccountId} accountId - The account ID * @param {EntityId} fileId - The file ID to check * @returns {boolean} True if the file should be skipped */ shouldSkipFile( context: ResumeContext, accountId: AccountId, fileId: EntityId, ): boolean { // If account is completed, all files should be skipped (handled by shouldSkipAccount) // This method is for checking individual files within a non-completed account if (context.resumeMode === PostRecordResumeMode.NEW) { return false; } if (context.resumeMode === PostRecordResumeMode.CONTINUE_RETRY) { // CONTINUE_RETRY retries all files for non-completed accounts return false; } // CONTINUE mode: skip files that were already posted const postedFiles = context.postedFilesByAccount.get(accountId); return postedFiles?.has(fileId) ?? false; } /** * Get source URLs from prior attempts for cross-website propagation. * @param {ResumeContext} context - The resume context * @param {AccountId} accountId - The account to get URLs for * @returns {string[]} Source URLs from the prior attempt */ getSourceUrlsForAccount( context: ResumeContext, accountId: AccountId, ): string[] { return context.sourceUrlsByAccount.get(accountId) ?? []; } /** * Get all source URLs from all accounts in the resume context. * @param {ResumeContext} context - The resume context * @returns {string[]} All source URLs */ getAllSourceUrls(context: ResumeContext): string[] { const allUrls: string[] = []; for (const urls of context.sourceUrlsByAccount.values()) { allUrls.push(...urls); } return allUrls; } /** * Get all source URLs from the resume context, excluding a specific account. * Used for cross-website source URL propagation to avoid self-referential URLs. * @param {ResumeContext} context - The resume context * @param {AccountId} excludeAccountId - The account ID to exclude * @returns {string[]} Source URLs from all accounts except the excluded one */ getSourceUrlsExcludingAccount( context: ResumeContext, excludeAccountId: AccountId, ): string[] { const allUrls: string[] = []; for (const [accountId, urls] of context.sourceUrlsByAccount.entries()) { if (accountId !== excludeAccountId) { allUrls.push(...urls); } } return allUrls; } } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/converters/base-converter.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { UsernameShortcut } from '@postybirb/types'; import { ConversionContext } from '../description-node.base'; import { InlineTypes, isTextNode, TipTapNode } from '../description-node.types'; /** * Base converter for TipTap JSON → output format. * * Converters process TipTap nodes directly (no wrapper classes). * Block-level nodes are dispatched to `convertBlockNode`, inline shortcut * atoms to `convertInlineNode`, and text nodes to `convertTextNode`. */ export abstract class BaseConverter { /** Current depth for nested block rendering */ protected currentDepth = 0; /** Used to prevent loop when default shortcut is insert into default section */ private processingDefaultDescription = false; abstract convertBlockNode( node: TipTapNode, context: ConversionContext, ): string; abstract convertInlineNode( node: TipTapNode, context: ConversionContext, ): string; abstract convertTextNode( node: TipTapNode, context: ConversionContext, ): string; /** * Converts an array of top-level TipTap nodes (block nodes). */ convertBlocks(nodes: TipTapNode[], context: ConversionContext): string { const results = nodes.map((node) => this.convertBlockNode(node, context)); return results.join(this.getBlockSeparator()); } /** * Converts raw TipTap block data. Handles default description recursion guard. */ convertRawBlocks(blocks: TipTapNode[], context: ConversionContext): string { const isDefaultDescription = blocks === context.defaultDescription; if (isDefaultDescription) { if (this.processingDefaultDescription) { return ''; } this.processingDefaultDescription = true; } try { return this.convertBlocks(blocks, context); } finally { if (isDefaultDescription) { this.processingDefaultDescription = false; } } } /** * Returns the separator to use between blocks. */ protected abstract getBlockSeparator(): string; /** * Converts the `content` array of a block node. * Dispatches each child to the appropriate handler based on type. */ protected convertContent( content: TipTapNode[] | undefined, context: ConversionContext, ): string { if (!content || content.length === 0) return ''; return content .map((child) => { if (isTextNode(child)) { return this.convertTextNode(child, context); } if (InlineTypes.includes(child.type)) { return this.convertInlineNode(child, context); } // Nested block nodes (e.g., listItem content) return this.convertBlockNode(child, context); }) .join(''); } /** * Converts children blocks with increased depth. */ protected convertChildren( children: TipTapNode[], context: ConversionContext, ): string { if (!children || children.length === 0) return ''; this.currentDepth += 1; try { const results = children.map((child) => this.convertBlockNode(child, context), ); return results.join(this.getBlockSeparator()); } finally { this.currentDepth -= 1; } } /** * Helper to check if a shortcut should be rendered for this website. */ protected shouldRenderShortcut( node: TipTapNode, context: ConversionContext, ): boolean { const attrs = node.attrs ?? {}; const onlyTo = (attrs.only?.split(',') ?? []) .map((s: string) => s.trim().toLowerCase()) .filter((s: string) => s.length > 0); if (onlyTo.length === 0) return true; return onlyTo.includes(context.website.toLowerCase()); } /** * Helper to resolve username shortcut link. */ protected getUsernameShortcutLink( node: TipTapNode, context: ConversionContext, ): | { url: string; username: string; } | undefined { const attrs = node.attrs ?? {}; const username = (attrs.username as string)?.trim() ?? ''; let convertedUsername = username; let effectiveShortcutId = attrs.shortcut; const converted = context.usernameConversions?.get(username); if (converted && converted !== username) { convertedUsername = converted; effectiveShortcutId = context.website; } const shortcut: UsernameShortcut | undefined = context.shortcuts[effectiveShortcutId]; const url = shortcut?.convert?.call(node, context.website, effectiveShortcutId) ?? shortcut?.url; return convertedUsername && url ? { url: url.replace('$1', convertedUsername), username: convertedUsername, } : undefined; } } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/converters/bbcode-converter.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ConversionContext } from '../description-node.base'; import { TipTapNode } from '../description-node.types'; import { BaseConverter } from './base-converter'; export class BBCodeConverter extends BaseConverter { protected getBlockSeparator(): string { return '\n'; } convertBlockNode( node: TipTapNode, context: ConversionContext, ): string { const attrs = node.attrs ?? {}; if (node.type === 'defaultShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return this.convertRawBlocks(context.defaultDescription, context); } // For FA: More than 5 dashes in a line are replaced with a horizontal divider. if (node.type === 'horizontalRule') return '--------'; if (node.type === 'image') return ''; if (node.type === 'hardBreak') return '\n'; // List containers if (node.type === 'bulletList') { return (node.content ?? []) .map((child) => this.convertBlockNode(child, context)) .join('\n'); } if (node.type === 'orderedList') { return (node.content ?? []) .map((child) => this.convertBlockNode(child, context)) .join('\n'); } if (node.type === 'listItem') { const inner = (node.content ?? []) .map((child) => { if (child.type === 'paragraph') { return this.convertContent(child.content, context); } return this.convertBlockNode(child, context); }) .join(''); return `• ${inner}`; } if (node.type === 'blockquote') { const inner = (node.content ?? []) .map((child) => this.convertBlockNode(child, context)) .join('\n'); return `[quote]${inner}[/quote]`; } if (node.type === 'paragraph') { let text = this.convertContent(node.content, context); // Apply text alignment if not default if (attrs.textAlign && attrs.textAlign !== 'left') { text = `[${attrs.textAlign}]${text}[/${attrs.textAlign}]`; } // Apply indentation if (attrs.indent && attrs.indent > 0) { const spaces = '\u00A0\u00A0\u00A0\u00A0'.repeat(attrs.indent); text = `${spaces}${text}`; } return text; } if (node.type === 'heading') { const level = attrs.level ?? 1; let text = `[h${level}]${this.convertContent(node.content, context)}[/h${level}]`; if (attrs.textAlign && attrs.textAlign !== 'left') { text = `[${attrs.textAlign}]${text}[/${attrs.textAlign}]`; } if (attrs.indent && attrs.indent > 0) { const spaces = '\u00A0\u00A0\u00A0\u00A0'.repeat(attrs.indent); text = `${spaces}${text}`; } return text; } // Fallback return this.convertContent(node.content, context); } convertInlineNode( node: TipTapNode, context: ConversionContext, ): string { const attrs = node.attrs ?? {}; if (node.type === 'username') { if (!this.shouldRenderShortcut(node, context)) return ''; const sc = this.getUsernameShortcutLink(node, context); if (sc?.url.startsWith('http')) { return `[url=${sc.url}]${sc.username}[/url]`; } return sc ? `${sc.url ?? sc.username}` : ''; } if (node.type === 'customShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; const shortcutBlocks = context.customShortcuts.get(attrs.id); if (shortcutBlocks) { return this.convertRawBlocks(shortcutBlocks, context); } return ''; } if (node.type === 'titleShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return context.title ?? ''; } if (node.type === 'tagsShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return context.tags?.map((e) => `#${e}`).join(' ') ?? ''; } if (node.type === 'contentWarningShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return context.contentWarningText ?? ''; } if (node.type === 'hardBreak') return '\n'; return this.convertContent(node.content, context); } convertTextNode( node: TipTapNode, context: ConversionContext, ): string { const textNode = node as any; if (!textNode.text) return ''; if (textNode.text === '\n' || textNode.text === '\r\n') { return '\n'; } const marks = textNode.marks ?? []; // Check for link mark const linkMark = marks.find((m: any) => m.type === 'link'); if (linkMark) { const href = linkMark.attrs?.href ?? ''; const innerText = this.renderTextWithMarks(textNode.text, marks.filter((m: any) => m.type !== 'link')); return `[url=${href}]${innerText}[/url]`; } return this.renderTextWithMarks(textNode.text, marks); } /** * Renders text with BBCode formatting marks applied. */ private renderTextWithMarks(text: string, marks: any[]): string { const segments: string[] = []; for (const mark of marks) { switch (mark.type) { case 'bold': segments.push('b'); break; case 'italic': segments.push('i'); break; case 'underline': segments.push('u'); break; case 'strike': segments.push('s'); break; default: break; } } if (!segments.length) { return text; } let segmentedText = `${segments.map((e) => `[${e}]`).join('')}${text}${segments .reverse() .map((e) => `[/${e}]`) .join('')}`; // Check for textStyle mark with color const textStyleMark = marks.find((m: any) => m.type === 'textStyle'); if (textStyleMark?.attrs?.color) { segmentedText = `[color=${textStyleMark.attrs.color}]${segmentedText}[/color]`; } return segmentedText; } } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/converters/custom-converter.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ConversionContext } from '../description-node.base'; import { TipTapNode } from '../description-node.types'; import { BaseConverter } from './base-converter'; export type CustomNodeHandler = ( node: TipTapNode, context: ConversionContext, ) => string; /** * Converter that uses custom handlers for each node type. * This allows websites like Tumblr to inject their own conversion logic. */ export class CustomConverter extends BaseConverter { constructor( private readonly blockHandler: CustomNodeHandler, private readonly inlineHandler?: CustomNodeHandler, private readonly textHandler?: CustomNodeHandler, ) { super(); } protected getBlockSeparator(): string { return '\n'; } convertBlockNode( node: TipTapNode, context: ConversionContext, ): string { return this.blockHandler(node, context); } convertInlineNode( node: TipTapNode, context: ConversionContext, ): string { if (this.inlineHandler) { return this.inlineHandler(node, context); } return this.convertContent(node.content, context); } convertTextNode( node: TipTapNode, context: ConversionContext, ): string { if (this.textHandler) { return this.textHandler(node, context); } return (node as any).text ?? ''; } } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/converters/html-converter.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { encode } from 'html-entities'; import { ConversionContext } from '../description-node.base'; import { TipTapNode } from '../description-node.types'; import { BaseConverter } from './base-converter'; export class HtmlConverter extends BaseConverter { protected getBlockSeparator(): string { return ''; } convertBlockNode( node: TipTapNode, context: ConversionContext, ): string { const attrs = node.attrs ?? {}; // Handle special block types if (node.type === 'defaultShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return this.convertRawBlocks(context.defaultDescription, context); } if (node.type === 'horizontalRule') return '
'; if (node.type === 'image') return this.convertImage(node); if (node.type === 'hardBreak') return '
'; // List containers: render as
    /
      wrapping children if (node.type === 'bulletList') { const items = (node.content ?? []) .map((child) => this.convertBlockNode(child, context)) .join(''); return `
        ${items}
      `; } if (node.type === 'orderedList') { const items = (node.content ?? []) .map((child) => this.convertBlockNode(child, context)) .join(''); return `
        ${items}
      `; } if (node.type === 'listItem') { // listItem content is typically [paragraph, ...]. Render inline content of inner paragraphs. const inner = (node.content ?? []) .map((child) => { if (child.type === 'paragraph') { return this.convertContent(child.content, context); } return this.convertBlockNode(child, context); }) .join(''); return `
    1. ${inner}
    2. `; } if (node.type === 'blockquote') { const inner = (node.content ?? []) .map((child) => this.convertBlockNode(child, context)) .join(''); return `
      ${inner}
      `; } // Regular blocks: paragraph, heading, etc. const tag = this.getBlockTag(node); const styles = this.getBlockStyles(node); const content = this.convertContent(node.content, context); return `<${tag}${styles ? ` style="${styles}"` : ''}>${content}`; } convertInlineNode( node: TipTapNode, context: ConversionContext, ): string { const attrs = node.attrs ?? {}; if (node.type === 'username') { if (!this.shouldRenderShortcut(node, context)) return ''; const sc = this.getUsernameShortcutLink(node, context); if (!sc) return ''; if (!sc.url.startsWith('http')) return `${sc.url}`; return `${sc.username}`; } if (node.type === 'customShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; const shortcutBlocks = context.customShortcuts.get(attrs.id); if (shortcutBlocks) { return this.convertRawBlocks(shortcutBlocks, context); } return ''; } if (node.type === 'titleShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return context.title ? `${encode(context.title, { level: 'html5' })}` : ''; } if (node.type === 'tagsShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return context.tags?.length ? `${context.tags.map((t) => encode(`#${t}`, { level: 'html5' })).join(' ')}` : ''; } if (node.type === 'contentWarningShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return context.contentWarningText ? `${encode(context.contentWarningText, { level: 'html5' })}` : ''; } if (node.type === 'hardBreak') return '
      '; // Fallback: render content const content = this.convertContent(node.content, context); return content ? `${content}` : ''; } convertTextNode( node: TipTapNode, context: ConversionContext, ): string { const textNode = node as any; if (!textNode.text) return ''; // Handle line breaks from merged blocks if (textNode.text === '\n' || textNode.text === '\r\n') { return '
      '; } const marks = textNode.marks ?? []; const segments: string[] = []; const styles: string[] = []; // Check for link mark — wrap entire text in const linkMark = marks.find((m: any) => m.type === 'link'); if (linkMark) { const href = linkMark.attrs?.href ?? ''; const innerHtml = this.renderTextWithMarks(textNode.text, marks.filter((m: any) => m.type !== 'link')); return `${innerHtml}`; } return this.renderTextWithMarks(textNode.text, marks); } /** * Renders text with formatting marks (bold, italic, etc.) applied. */ private renderTextWithMarks(text: string, marks: any[]): string { const segments: string[] = []; const styles: string[] = []; for (const mark of marks) { switch (mark.type) { case 'bold': segments.push('b'); break; case 'italic': segments.push('i'); break; case 'underline': segments.push('u'); break; case 'strike': segments.push('s'); break; default: break; } } // Check for textStyle mark with color const textStyleMark = marks.find((m: any) => m.type === 'textStyle'); if (textStyleMark?.attrs?.color) { styles.push(`color: ${textStyleMark.attrs.color}`); } const encodedText = encode(text, { level: 'html5' }).replace(/\n/g, '
      '); if (!segments.length && !styles.length) { return encodedText; } const stylesString = styles.join(';'); return `${segments.map((s) => `<${s}>`).join('')}${encodedText}${segments .reverse() .map((s) => ``) .join('')}`; } private getBlockTag(node: TipTapNode): string { const attrs = node.attrs ?? {}; if (node.type === 'paragraph') return 'div'; if (node.type === 'heading') return `h${attrs.level ?? 1}`; return 'div'; } private getBlockStyles(node: TipTapNode): string { const attrs = node.attrs ?? {}; const styles: string[] = []; if ( attrs.textAlign && attrs.textAlign !== 'left' ) { styles.push(`text-align: ${attrs.textAlign}`); } if (attrs.indent && attrs.indent > 0) { styles.push(`margin-left: ${attrs.indent * 2}em`); } return styles.join(';'); } private convertImage(node: TipTapNode): string { const attrs = node.attrs ?? {}; const src = attrs.src || ''; const alt = attrs.alt || ''; const width = attrs.width || ''; const height = attrs.height || ''; let imgTag = `${alt}${imgTag}`; } } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/converters/npf-converter.spec.ts ================================================ import { NPFTextBlock, TipTapNode } from '@postybirb/types'; import { ConversionContext } from '../description-node.base'; import { NpfConverter } from './npf-converter'; describe('NpfConverter', () => { let converter: NpfConverter; let context: ConversionContext; beforeEach(() => { converter = new NpfConverter(); context = { website: 'tumblr', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; }); describe('convertBlockNode', () => { it('should convert a simple paragraph to NPF text block', () => { const node: TipTapNode = { type: 'paragraph', content: [ { type: 'text', text: 'Hello, World!', }, ], }; const resultJson = converter.convertBlockNode(node, context); const result = JSON.parse(resultJson); expect(result).toEqual({ type: 'text', text: 'Hello, World!', }); }); it('should convert a paragraph with bold text to NPF with formatting', () => { const node: TipTapNode = { type: 'paragraph', content: [ { type: 'text', text: 'This is ', }, { type: 'text', text: 'bold', marks: [{ type: 'bold' }], }, { type: 'text', text: ' text', }, ], }; const resultJson = converter.convertBlockNode(node, context); const result = JSON.parse(resultJson) as NPFTextBlock; expect(result.type).toBe('text'); expect(result.text).toBe('This is bold text'); expect(result.formatting).toEqual([ { start: 8, end: 12, type: 'bold', }, ]); }); it('should convert a paragraph with link mark to NPF with link formatting', () => { const node: TipTapNode = { type: 'paragraph', content: [ { type: 'text', text: 'Visit ', }, { type: 'text', text: 'PostyBirb', marks: [ { type: 'link', attrs: { href: 'https://postybirb.com' }, }, ], }, ], }; const resultJson = converter.convertBlockNode(node, context); const result = JSON.parse(resultJson) as NPFTextBlock; expect(result.type).toBe('text'); expect(result.text).toBe('Visit PostyBirb'); expect(result.formatting).toEqual([ { start: 6, end: 15, type: 'link', url: 'https://postybirb.com', }, ]); }); it('should convert heading to NPF text block with subtype', () => { const node: TipTapNode = { type: 'heading', attrs: { level: 1 }, content: [ { type: 'text', text: 'My Heading', }, ], }; const resultJson = converter.convertBlockNode(node, context); const result = JSON.parse(resultJson); expect(result).toEqual({ type: 'text', text: 'My Heading', subtype: 'heading1', }); }); it('should convert image to NPF image block', () => { const node: TipTapNode = { type: 'image', attrs: { src: 'https://example.com/image.jpg', alt: 'My Image', width: 800, height: 600, }, }; const resultJson = converter.convertBlockNode(node, context); const result = JSON.parse(resultJson); expect(result).toEqual({ type: 'image', media: [ { url: 'https://example.com/image.jpg', type: 'image/jpeg', width: 800, height: 600, }, ], alt_text: 'My Image', }); }); it('should handle multiple formatting types on same text', () => { const node: TipTapNode = { type: 'paragraph', content: [ { type: 'text', text: 'Bold and Italic', marks: [{ type: 'bold' }, { type: 'italic' }], }, ], }; const resultJson = converter.convertBlockNode(node, context); const result = JSON.parse(resultJson) as NPFTextBlock; expect(result.type).toBe('text'); expect(result.text).toBe('Bold and Italic'); expect(result.formatting).toEqual([ { start: 0, end: 15, type: 'bold', }, { start: 0, end: 15, type: 'italic', }, ]); }); it('should handle custom shortcuts', () => { const shortcutContent: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Commission Info', marks: [{ type: 'bold' }], }, ], }, ]; context.customShortcuts.set('cs-1', shortcutContent); const node: TipTapNode = { type: 'paragraph', content: [ { type: 'text', text: 'Check out my ', }, { type: 'customShortcut', attrs: { id: 'cs-1' }, }, ], }; const resultJson = converter.convertBlockNode(node, context); const result = JSON.parse(resultJson) as NPFTextBlock; expect(result.type).toBe('text'); expect(result.text).toBe('Check out my Commission Info'); expect(result.formatting).toEqual([ { start: 13, end: 28, type: 'bold', }, ]); }); it('should handle double spaces', () => { const shortcutContent: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Commission Info', marks: [{ type: 'bold' }], }, ], }, ]; context.customShortcuts.set('cs-1', shortcutContent); const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'aaa' }], }, { type: 'defaultShortcut', }, { type: 'paragraph', content: [], }, { type: 'paragraph', content: [{ type: 'text', text: 'aaa' }], }, ]; const resultJson = converter.convertBlocks(nodes, context); const result = JSON.parse(resultJson) as NPFTextBlock; expect(result).toMatchInlineSnapshot(` [ { "text": "aaa", "type": "text", }, { "text": "", "type": "text", }, { "text": "aaa", "type": "text", }, ] `); }); }); }); ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/converters/npf-converter.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { NPFContentBlock, NPFImageBlock, NPFInlineFormatting, NPFMediaObject, NPFTextBlock, } from '@postybirb/types'; import { ConversionContext } from '../description-node.base'; import { isTextNode, TipTapMark, TipTapNode } from '../description-node.types'; import { BaseConverter } from './base-converter'; /** * Converter that outputs Tumblr NPF (Nuevo Post Format) blocks. */ export class NpfConverter extends BaseConverter { private currentFormatting: NPFInlineFormatting[] = []; private currentPosition = 0; private blocks: NPFContentBlock[] = []; protected getBlockSeparator(): string { return ''; } /** * Override convertBlocks to accumulate NPF blocks and return JSON. */ convertBlocks(nodes: TipTapNode[], context: ConversionContext): string { this.blocks = []; for (const node of nodes) { this.convertBlockNodeRecursive(node, context, 0); } return JSON.stringify(this.blocks); } /** * Recursively converts a block node and its children to NPF blocks. */ private convertBlockNodeRecursive( node: TipTapNode, context: ConversionContext, indentLevel: number, ): void { if (node.type === 'defaultShortcut') { if (!this.shouldRenderShortcut(node, context)) return; if (context.defaultDescription && context.defaultDescription.length > 0) { for (const defaultBlock of context.defaultDescription) { this.convertBlockNodeRecursive(defaultBlock, context, indentLevel); } } return; } // Handle list containers — recurse into items if (node.type === 'bulletList') { for (const item of node.content ?? []) { this.convertListItemToNpf( item, context, 'unordered-list-item', indentLevel, ); } return; } if (node.type === 'orderedList') { for (const item of node.content ?? []) { this.convertListItemToNpf( item, context, 'ordered-list-item', indentLevel, ); } return; } if (node.type === 'blockquote') { for (const child of node.content ?? []) { const block = this.convertBlockNodeToNpf(child, context); if (block.type === 'text') { block.subtype = 'quote'; } this.blocks.push(block); } return; } const block = this.convertBlockNodeToNpf(node, context); // Apply indent_level for nested blocks (NPF supports 0-7) if (indentLevel > 0 && block.type === 'text' && indentLevel <= 7) { block.indent_level = indentLevel; if (!block.subtype) { block.subtype = 'indented'; } } this.blocks.push(block); } /** * Convert a listItem node to NPF text block with appropriate subtype. */ private convertListItemToNpf( node: TipTapNode, context: ConversionContext, subtype: 'ordered-list-item' | 'unordered-list-item', indentLevel: number, ): void { // listItem typically contains [paragraph, ...nested lists] for (const child of node.content ?? []) { if (child.type === 'paragraph') { this.currentFormatting = []; this.currentPosition = 0; const text = this.extractText(child.content ?? [], context); const npfBlock: NPFTextBlock = { type: 'text', text, subtype, formatting: this.currentFormatting.length > 0 ? this.currentFormatting : undefined, }; if (indentLevel > 0 && indentLevel <= 7) { npfBlock.indent_level = indentLevel; } this.blocks.push(npfBlock); } else if (child.type === 'bulletList') { for (const item of child.content ?? []) { this.convertListItemToNpf( item, context, 'unordered-list-item', indentLevel + 1, ); } } else if (child.type === 'orderedList') { for (const item of child.content ?? []) { this.convertListItemToNpf( item, context, 'ordered-list-item', indentLevel + 1, ); } } else { this.convertBlockNodeRecursive(child, context, indentLevel); } } } /** * Stub method required by BaseConverter interface. */ convertBlockNode(node: TipTapNode, context: ConversionContext): string { const block = this.convertBlockNodeToNpf(node, context); return JSON.stringify(block); } /** * Internal method that returns a single NPF block. */ private convertBlockNodeToNpf( node: TipTapNode, context: ConversionContext, ): NPFContentBlock { this.currentFormatting = []; this.currentPosition = 0; switch (node.type) { case 'paragraph': return this.convertParagraph(node, context); case 'heading': return this.convertHeading(node, context); case 'image': return this.convertImage(node); case 'horizontalRule': return { type: 'text', text: '' }; case 'defaultShortcut': return { type: 'text', text: '' }; default: return this.convertParagraph(node, context); } } private convertParagraph( node: TipTapNode, context: ConversionContext, ): NPFTextBlock { const text = this.extractText(node.content ?? [], context); const formatting = this.currentFormatting.length > 0 ? this.currentFormatting : undefined; return { type: 'text', text, formatting }; } private convertHeading( node: TipTapNode, context: ConversionContext, ): NPFTextBlock { const text = this.extractText(node.content ?? [], context); const level = parseInt(node.attrs?.level || '1', 10); const subtype = level === 1 ? 'heading1' : 'heading2'; return { type: 'text', text, subtype, formatting: this.currentFormatting.length > 0 ? this.currentFormatting : undefined, }; } private convertImage(node: TipTapNode): NPFImageBlock { const attrs = node.attrs ?? {}; const url = attrs.src || ''; const alt = attrs.alt || ''; const width = attrs.width ? parseInt(String(attrs.width), 10) : undefined; const height = attrs.height ? parseInt(String(attrs.height), 10) : undefined; const media: NPFMediaObject[] = [ { url, type: this.getMimeType(url), width: width || undefined, height: height || undefined, }, ]; const imageBlock: NPFImageBlock = { type: 'image', media, alt_text: alt || undefined, }; return imageBlock; } convertInlineNode(node: TipTapNode, context: ConversionContext): string { const attrs = node.attrs ?? {}; const startPos = this.currentPosition; let text = ''; if (node.type === 'customShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; const shortcutBlocks = context.customShortcuts.get(attrs.id); if (shortcutBlocks) { for (const block of shortcutBlocks) { text += this.extractText(block.content ?? [], context); } } return text; } if (node.type === 'username') { if (!this.shouldRenderShortcut(node, context)) return ''; const sc = this.getUsernameShortcutLink(node, context); if (!sc) { text = attrs.username ?? ''; } else if (!sc.url.startsWith('http')) { text = sc.url; } else { text = sc.username; if (text.length > 0) { this.currentFormatting.push({ start: startPos, end: startPos + text.length, type: 'link', url: sc.url, }); } } this.currentPosition += text.length; return text; } if (node.type === 'titleShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; text = context.title ?? ''; this.currentPosition += text.length; this.addFormattingForMarks(node.marks, startPos, this.currentPosition); return text; } if (node.type === 'tagsShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; text = context.tags?.map((e) => `#${e}`).join(' ') ?? ''; this.currentPosition += text.length; this.addFormattingForMarks(node.marks, startPos, this.currentPosition); return text; } if (node.type === 'contentWarningShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; text = context.contentWarningText ?? ''; this.currentPosition += text.length; this.addFormattingForMarks(node.marks, startPos, this.currentPosition); return text; } if (node.type === 'hardBreak') { text = '\n'; this.currentPosition += text.length; return text; } // Generic inline: extract text from content text = this.extractText(node.content ?? [], context); return text; } convertTextNode(node: TipTapNode): string { const text = node.text ?? ''; const startPos = this.currentPosition; this.currentPosition += text.length; this.addFormattingForMarks(node.marks, startPos, this.currentPosition); return text; } /** * Extracts plain text from content array and builds formatting. */ private extractText( content: TipTapNode[], context: ConversionContext, ): string { let text = ''; for (const node of content) { if (isTextNode(node)) { text += this.convertTextNode(node); } else { text += this.convertInlineNode(node, context); } } return text; } /** * Adds formatting entries for marks on a text node. */ private addFormattingForMarks( marks: TipTapMark[], start: number, end: number, ): void { if (!marks || start === end) return; for (const mark of marks) { switch (mark.type) { case 'bold': this.currentFormatting.push({ start, end, type: 'bold' }); break; case 'italic': this.currentFormatting.push({ start, end, type: 'italic' }); break; case 'strike': this.currentFormatting.push({ start, end, type: 'strikethrough', }); break; case 'textStyle': if (mark.attrs?.color) { this.currentFormatting.push({ start, end, type: 'color', hex: mark.attrs.color, }); } break; case 'link': if (typeof mark.attrs?.href === 'string') this.currentFormatting.push({ start, end, type: 'link', url: mark.attrs.href, }); break; default: break; } } } /** * Gets MIME type from file URL. */ private getMimeType(url: string): string | undefined { const ext = url.split('.').pop()?.toLowerCase(); const mimeMap: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mp3', ogg: 'audio/ogg', wav: 'audio/wav', }; return ext ? mimeMap[ext] : undefined; } } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/converters/plaintext-converter.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ConversionContext } from '../description-node.base'; import { TipTapNode } from '../description-node.types'; import { BaseConverter } from './base-converter'; export class PlainTextConverter extends BaseConverter { protected getBlockSeparator(): string { return '\r\n'; } convertBlockNode(node: TipTapNode, context: ConversionContext): string { if (node.type === 'defaultShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return this.convertRawBlocks(context.defaultDescription, context); } if (node.type === 'horizontalRule') return '----------'; if (node.type === 'image') return ''; if (node.type === 'hardBreak') return '\r\n'; // List containers if (node.type === 'bulletList' || node.type === 'orderedList') { return (node.content ?? []) .map((child) => this.convertBlockNode(child, context)) .join('\r\n'); } if (node.type === 'listItem') { const inner = (node.content ?? []) .map((child) => { if (child.type === 'paragraph') { return this.convertContent(child.content, context); } return this.convertBlockNode(child, context); }) .join(''); return `- ${inner}`; } if (node.type === 'blockquote') { return (node.content ?? []) .map((child) => { const text = this.convertBlockNode(child, context); return `> ${text}`; }) .join('\r\n'); } // Indent paragraph/heading content if (node.type === 'paragraph' || node.type === 'heading') { const attrs = node.attrs ?? {}; let text = this.convertContent(node.content, context); if (attrs.indent && attrs.indent > 0) { const spaces = ' '.repeat(attrs.indent); text = `${spaces}${text}`; } return text; } return this.convertContent(node.content, context); } convertInlineNode(node: TipTapNode, context: ConversionContext): string { const attrs = node.attrs ?? {}; if (node.type === 'username') { if (!this.shouldRenderShortcut(node, context)) return ''; const sc = this.getUsernameShortcutLink(node, context); return sc ? sc.url : ''; } if (node.type === 'customShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; const shortcutBlocks = context.customShortcuts.get(attrs.id); if (shortcutBlocks) { return this.convertRawBlocks(shortcutBlocks, context); } return ''; } if (node.type === 'titleShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return context.title ?? ''; } if (node.type === 'tagsShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return context.tags?.map((e) => `#${e}`).join(' ') ?? ''; } if (node.type === 'contentWarningShortcut') { if (!this.shouldRenderShortcut(node, context)) return ''; return context.contentWarningText ?? ''; } if (node.type === 'hardBreak') return '\r\n'; return this.convertContent(node.content, context); } convertTextNode(node: TipTapNode, context: ConversionContext): string { const textNode = node as any; // Check for link mark — append URL const marks = textNode.marks ?? []; const linkMark = marks.find((m: any) => m.type === 'link'); if (linkMark) { return `${textNode.text}: ${linkMark.attrs?.href ?? ''}`; } return textNode.text ?? ''; } } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/description-node-tree.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import TurndownService from 'turndown'; import { BaseConverter } from './converters/base-converter'; import { BBCodeConverter } from './converters/bbcode-converter'; import { CustomConverter, CustomNodeHandler, } from './converters/custom-converter'; import { HtmlConverter } from './converters/html-converter'; import { PlainTextConverter } from './converters/plaintext-converter'; import { ConversionContext } from './description-node.base'; import { TipTapNode } from './description-node.types'; export type InsertionOptions = { insertTitle?: string; insertTags?: string[]; insertAd: boolean; }; export class DescriptionNodeTree { private readonly nodes: TipTapNode[]; private readonly insertionOptions: InsertionOptions; private context: ConversionContext; /** Empty paragraph used as spacing before the ad */ private readonly spacing: TipTapNode = { type: 'paragraph', content: [], }; /** PostyBirb ad in TipTap JSON format */ private readonly ad: TipTapNode = { type: 'paragraph', content: [ { type: 'text', text: 'Posted using PostyBirb', marks: [ { type: 'link', attrs: { href: 'https://postybirb.com', target: '_blank' }, }, ], }, ], }; constructor( context: ConversionContext, nodes: TipTapNode[], insertionOptions: InsertionOptions, ) { this.context = context; this.insertionOptions = insertionOptions; this.nodes = nodes ?? []; } toBBCode(): string { const converter = new BBCodeConverter(); return converter.convertBlocks(this.withInsertions(), this.context); } toPlainText(): string { const converter = new PlainTextConverter(); return converter.convertBlocks(this.withInsertions(), this.context); } toHtml(): string { const converter = new HtmlConverter(); return converter.convertBlocks(this.withInsertions(), this.context); } toMarkdown(turndownService?: TurndownService): string { const converter = turndownService ?? new TurndownService(); converter.addRule('nestedIndent', { filter: (node) => node.nodeName === 'DIV' && node.getAttribute('style')?.includes('margin-left'), replacement: (content) => `\n\n> ${content.trim().replace(/\n/g, '\n> ')}\n\n`, }); const html = this.toHtml(); return converter.turndown(html); } parseCustom(blockHandler: CustomNodeHandler): string { const converter = new CustomConverter(blockHandler); return converter.convertBlocks(this.withInsertions(), this.context); } parseWithConverter(converter: BaseConverter): string { return converter.convertBlocks(this.withInsertions(), this.context); } public updateContext(updates: Partial): void { this.context = { ...this.context, ...updates }; } /** * Finds all TipTap nodes of a specific type in the tree (recursively). */ public findNodesByType(type: string): TipTapNode[] { const found: TipTapNode[] = []; const traverse = (nodes: TipTapNode[]) => { for (const node of nodes) { if (node.type === type) { found.push(node); } if (node.content) { traverse(node.content); } } }; traverse(this.nodes); return found; } /** * Finds all custom shortcut IDs in the tree. */ public findCustomShortcutIds(): Set { const ids = new Set(); const shortcuts = this.findNodesByType('customShortcut'); for (const shortcut of shortcuts) { const id = shortcut.attrs?.id; if (id) { ids.add(id); } } return ids; } /** * Finds all usernames in the tree. */ public findUsernames(): Set { const usernames = new Set(); const usernameNodes = this.findNodesByType('username'); for (const node of usernameNodes) { const username = (node.attrs?.username as string)?.trim(); if (username) { usernames.add(username); } } return usernames; } /** * Checks if a TipTap node is structurally empty * (no content, or content is only whitespace text nodes). * Only considers paragraph/heading nodes as trimmable. */ private isEmptyNode(node: TipTapNode): boolean { if (node.type !== 'paragraph' && node.type !== 'heading') { return false; } if (!node.content || node.content.length === 0) { return true; } return node.content.every( (child) => child.type === 'text' && (!(child as any).text || (child as any).text.trim() === ''), ); } /** * Trims structurally empty nodes from the start and end of a block array, * preserving empty nodes in the middle (intentional blank lines). */ private trimEmptyEdgeNodes(nodes: TipTapNode[]): TipTapNode[] { let start = 0; let end = nodes.length - 1; while (start < nodes.length && this.isEmptyNode(nodes[start])) { start++; } while (end >= start && this.isEmptyNode(nodes[end])) { end--; } if (start > end) return []; return nodes.slice(start, end + 1); } private withInsertions(): TipTapNode[] { // Trim empty edge nodes before insertions so converters receive clean input const nodes = this.trimEmptyEdgeNodes([...this.nodes]); const { insertAd, insertTags, insertTitle } = this.insertionOptions; if (insertTitle) { nodes.unshift({ type: 'heading', attrs: { level: 2 }, content: [ { type: 'text', text: insertTitle, }, ], }); } if (insertTags) { nodes.push({ type: 'paragraph', content: [ { type: 'text', text: insertTags.map((e) => `#${e}`).join(' '), }, ], }); } if (insertAd) { const lastNode = nodes[nodes.length - 1]; const isLastNodeSpacing = lastNode?.type === 'paragraph' && (!lastNode.content || lastNode.content.length === 0); // Avoid duplicated spacings if (!isLastNodeSpacing) { nodes.push(this.spacing); } nodes.push(this.ad); } return nodes; } } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/description-node.base.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { UsernameShortcut } from '@postybirb/types'; import { TipTapNode } from './description-node.types'; /** * Context provided to all converters during conversion. */ export interface ConversionContext { website: string; shortcuts: Record; customShortcuts: Map; defaultDescription: TipTapNode[]; title?: string; tags?: string[]; usernameConversions?: Map; contentWarningText?: string; } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node/description-node.types.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { TipTapMark, TipTapNode } from '@postybirb/types'; /** * Re-export TipTap types from the shared types library for use in the server parser. */ export type { TipTapMark, TipTapNode }; /** * A TipTap node used as a block-level element (paragraph, heading, list, etc.). * In TipTap, block nodes have `type`, optional `attrs`, and optional `content`. */ export interface ITipTapBlockNode extends TipTapNode { attrs?: Record; content?: TipTapNode[]; } /** * A TipTap text node. Has `type: 'text'`, `text`, and optional `marks`. */ export interface ITipTapTextNode extends TipTapNode { type: 'text'; text: string; marks?: TipTapMark[]; } /** * Known block-level node types in TipTap. */ export const BlockTypes: string[] = [ 'doc', 'paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'horizontalRule', 'hardBreak', 'image', 'defaultShortcut', ]; /** * Known inline/atom node types in TipTap (rendered inline, not block-level). */ export const InlineTypes: string[] = [ 'username', 'customShortcut', 'titleShortcut', 'tagsShortcut', 'contentWarningShortcut', ]; /** * Helper to check if a TipTap node is a text node. */ export function isTextNode(node: TipTapNode): node is ITipTapTextNode { return node.type === 'text' && typeof (node as any).text === 'string'; } /** * Helper to check if a TipTap node is an inline shortcut node. */ export function isInlineShortcut(node: TipTapNode): boolean { return InlineTypes.includes(node.type); } /** * Helper to check if a text node has a specific mark. */ export function hasMark(node: ITipTapTextNode, markType: string): boolean { return node.marks?.some((m) => m.type === markType) ?? false; } /** * Helper to get a mark's attrs from a text node. */ export function getMarkAttrs( node: ITipTapTextNode, markType: string, ): Record | undefined { return node.marks?.find((m) => m.type === markType)?.attrs; } ================================================ FILE: apps/client-server/src/app/post-parsers/models/description-node.spec.ts ================================================ import { TipTapNode } from '@postybirb/types'; import { DescriptionNodeTree } from './description-node/description-node-tree'; import { ConversionContext } from './description-node/description-node.base'; describe('DescriptionNode', () => { it('should support username shortcuts', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] }, { type: 'username', attrs: { shortcut: 'test', only: '', username: 'User', }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: { test: { id: 'test', url: 'https://test.postybirb.com/$1', }, }, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('Hello, https://test.postybirb.com/User'); expect(tree.toHtml()).toBe( '
      Hello, User
      ', ); expect(tree.toBBCode()).toBe( '[b]Hello, [/b][url=https://test.postybirb.com/User]User[/url]', ); }); it('should support username shortcut conversion', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] }, { type: 'username', attrs: { shortcut: 'test', only: '', username: 'User', }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: { test: { id: 'test', url: 'https://test.postybirb.com/$1', convert: (websiteName) => { if (websiteName === 'test') { return ''; } return undefined; }, }, }, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('Hello, '); expect(tree.toHtml()).toBe( '
      Hello,
      ', ); expect(tree.toBBCode()).toBe('[b]Hello, [/b]'); }); it('should handle multiple paragraphs', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'First paragraph.' }], }, { type: 'paragraph', content: [{ type: 'text', text: 'Second paragraph.' }], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('First paragraph.\r\nSecond paragraph.'); expect(tree.toHtml()).toBe( '
      First paragraph.
      Second paragraph.
      ', ); expect(tree.toBBCode()).toBe('First paragraph.\nSecond paragraph.'); }); describe('findUsernames', () => { it('should find all usernames in the tree', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Hello ' }, { type: 'username', attrs: { shortcut: 'test', only: '', username: 'User1', }, }, { type: 'text', text: ' and ' }, { type: 'username', attrs: { shortcut: 'test', only: '', username: 'User2', }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const usernames = tree.findUsernames(); expect(usernames.size).toBe(2); expect(usernames.has('User1')).toBe(true); expect(usernames.has('User2')).toBe(true); }); it('should find usernames across multiple paragraphs', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'username', attrs: { shortcut: 'test', only: '', username: 'Alice' }, }, ], }, { type: 'paragraph', content: [ { type: 'username', attrs: { shortcut: 'test', only: '', username: 'Bob' }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const usernames = tree.findUsernames(); expect(usernames.size).toBe(2); expect(usernames.has('Alice')).toBe(true); expect(usernames.has('Bob')).toBe(true); }); it('should return empty set when no usernames exist', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Just plain text' }], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const usernames = tree.findUsernames(); expect(usernames.size).toBe(0); }); it('should handle duplicate usernames', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'username', attrs: { shortcut: 'test', only: '', username: 'SameUser', }, }, { type: 'text', text: ' and ' }, { type: 'username', attrs: { shortcut: 'test', only: '', username: 'SameUser', }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const usernames = tree.findUsernames(); expect(usernames.size).toBe(1); expect(usernames.has('SameUser')).toBe(true); }); }); describe('findCustomShortcutIds', () => { it('should find all custom shortcut IDs in the tree', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Here are some shortcuts: ' }, { type: 'customShortcut', attrs: { id: 'shortcut-1' }, }, { type: 'text', text: ' and ' }, { type: 'customShortcut', attrs: { id: 'shortcut-2' }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const shortcutIds = tree.findCustomShortcutIds(); expect(shortcutIds.size).toBe(2); expect(shortcutIds.has('shortcut-1')).toBe(true); expect(shortcutIds.has('shortcut-2')).toBe(true); }); it('should find shortcuts across multiple paragraphs', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'customShortcut', attrs: { id: 'shortcut-a' }, }, ], }, { type: 'paragraph', content: [ { type: 'customShortcut', attrs: { id: 'shortcut-b' }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const shortcutIds = tree.findCustomShortcutIds(); expect(shortcutIds.size).toBe(2); expect(shortcutIds.has('shortcut-a')).toBe(true); expect(shortcutIds.has('shortcut-b')).toBe(true); }); it('should return empty set when no custom shortcuts exist', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Just plain text' }], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const shortcutIds = tree.findCustomShortcutIds(); expect(shortcutIds.size).toBe(0); }); it('should handle duplicate shortcut IDs', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'customShortcut', attrs: { id: 'same-id' }, }, { type: 'text', text: ' and ' }, { type: 'customShortcut', attrs: { id: 'same-id' }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const shortcutIds = tree.findCustomShortcutIds(); expect(shortcutIds.size).toBe(1); expect(shortcutIds.has('same-id')).toBe(true); }); it('should handle shortcuts without IDs gracefully', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'customShortcut', attrs: { id: '' }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const shortcutIds = tree.findCustomShortcutIds(); expect(shortcutIds.size).toBe(0); }); }); describe('updateContext', () => { it('should allow updating context after tree creation', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'username', attrs: { shortcut: 'test', only: '', username: 'TestUser', }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: { test: { id: 'test', url: 'https://test.postybirb.com/$1', }, }, customShortcuts: new Map(), defaultDescription: [], usernameConversions: new Map(), }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); // Before update - no conversion expect(tree.toPlainText()).toBe('https://test.postybirb.com/TestUser'); // Update context with username conversion tree.updateContext({ usernameConversions: new Map([['TestUser', 'ConvertedUser']]), }); // After update - should use converted username expect(tree.toPlainText()).toBe( 'https://test.postybirb.com/ConvertedUser', ); // Verify the tree still finds the original username const usernames = tree.findUsernames(); expect(usernames.has('TestUser')).toBe(true); }); it('should convert cross-platform username tags to target website', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'username', attrs: { shortcut: 'twitter', only: '', username: 'abcd', }, }, ], }, ]; const context: ConversionContext = { website: 'bluesky', shortcuts: { twitter: { id: 'twitter', url: 'https://x.com/$1', }, bluesky: { id: 'bluesky', url: 'https://bsky.app/profile/$1', }, }, customShortcuts: new Map(), defaultDescription: [], usernameConversions: new Map([['abcd', 'abcd.bsky.app']]), }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('https://bsky.app/profile/abcd.bsky.app'); expect(tree.toHtml()).toBe( '', ); }); it('should keep original username when no conversion exists', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'username', attrs: { shortcut: 'twitter', only: '', username: 'someuser', }, }, ], }, ]; const context: ConversionContext = { website: 'bluesky', shortcuts: { twitter: { id: 'twitter', url: 'https://x.com/$1', }, bluesky: { id: 'bluesky', url: 'https://bsky.app/profile/$1', }, }, customShortcuts: new Map(), defaultDescription: [], usernameConversions: new Map(), }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('https://x.com/someuser'); expect(tree.toHtml()).toBe( '', ); }); it('should convert usernames when shortcut ID matches target website', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'username', attrs: { shortcut: 'bluesky', only: '', username: 'x' }, }, ], }, ]; const context: ConversionContext = { website: 'bluesky', shortcuts: { twitter: { id: 'twitter', url: 'https://x.com/$1', }, bluesky: { id: 'bluesky', url: 'https://bsky.app/profile/$1', }, }, customShortcuts: new Map(), defaultDescription: [], usernameConversions: new Map([['x', 'bluesky_user']]), }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('https://bsky.app/profile/bluesky_user'); expect(tree.toHtml()).toBe( '', ); }); }); describe('blockquote nesting', () => { it('should render blockquotes in HTML', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1 nested' }], }, ], }, { type: 'paragraph', content: [{ type: 'text', text: 'Para 2' }], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toHtml()).toBe( '
      Para 1
      Para 1 nested
      Para 2
      ', ); }); it('should render blockquotes in plain text with > prefix', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1 nested' }], }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('Para 1\r\n> Para 1 nested'); }); it('should render blockquotes in BBCode with [quote] tags', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1 nested' }], }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toBBCode()).toBe('Para 1\n[quote]Para 1 nested[/quote]'); }); it('should handle deeply nested blockquotes (multi-level)', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Level 0' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Level 1' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Level 2' }], }, ], }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('Level 0\r\n> Level 1\r\n> > Level 2'); expect(tree.toBBCode()).toBe( 'Level 0\n[quote]Level 1\n[quote]Level 2[/quote][/quote]', ); }); it('should handle multiple paragraphs at same blockquote level', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1 nested' }], }, { type: 'paragraph', content: [{ type: 'text', text: 'Para 1 nested 2' }], }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe( 'Para 1\r\n> Para 1 nested\r\n> Para 1 nested 2', ); }); it('should find usernames in blockquotes', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1 ' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Nested ' }, { type: 'username', attrs: { shortcut: 'test', only: '', username: 'NestedUser', }, }, ], }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: { test: { id: 'test', url: 'https://test.postybirb.com/$1', }, }, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const usernames = tree.findUsernames(); expect(usernames.has('NestedUser')).toBe(true); }); it('should render blockquotes in Markdown', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Para 1 nested' }], }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toMarkdown()).toBe('Para 1\n\n> Para 1 nested'); }); it('should render deeply nested blockquotes in Markdown', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'text', text: 'Level 0' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Level 1' }], }, { type: 'blockquote', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Level 2' }], }, ], }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toMarkdown()).toBe('Level 0\n\n> Level 1\n> \n> > Level 2'); }); }); describe('system inline shortcuts', () => { it('should render titleShortcut with title from context', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Title: ' }, { type: 'titleShortcut', attrs: {} }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], title: 'My Amazing Artwork', tags: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('Title: My Amazing Artwork'); expect(tree.toHtml()).toBe( '
      Title: My Amazing Artwork
      ', ); expect(tree.toBBCode()).toBe('Title: My Amazing Artwork'); }); it('should render tagsShortcut with tags from context', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Tags: ' }, { type: 'tagsShortcut', attrs: {} }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], title: '', tags: ['art', 'digital', 'fantasy'], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('Tags: #art #digital #fantasy'); expect(tree.toHtml()).toBe( '
      Tags: #art #digital #fantasy
      ', ); expect(tree.toBBCode()).toBe('Tags: #art #digital #fantasy'); }); it('should render contentWarningShortcut with content warning from context', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'CW: ' }, { type: 'contentWarningShortcut', attrs: {} }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], title: '', tags: [], contentWarningText: 'Mild Violence', }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('CW: Mild Violence'); expect(tree.toHtml()).toBe('
      CW: Mild Violence
      '); expect(tree.toBBCode()).toBe('CW: Mild Violence'); }); it('should render empty string when title is not in context', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Title: ' }, { type: 'titleShortcut', attrs: {} }, { type: 'text', text: ' end' }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('Title: end'); }); it('should render empty string when tags array is empty', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Tags: ' }, { type: 'tagsShortcut', attrs: {} }, { type: 'text', text: ' end' }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], tags: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('Tags: end'); }); it('should render all system shortcuts together', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'titleShortcut', attrs: {} }, { type: 'text', text: ' - ' }, { type: 'contentWarningShortcut', attrs: {} }, ], }, { type: 'paragraph', content: [{ type: 'tagsShortcut', attrs: {} }], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], title: 'My Art', tags: ['tag1', 'tag2'], contentWarningText: 'NSFW', }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe('My Art - NSFW\r\n#tag1 #tag2'); expect(tree.toHtml()).toBe( '
      My Art - NSFW
      #tag1 #tag2
      ', ); }); it('should HTML encode special characters in title', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [{ type: 'titleShortcut', attrs: {} }], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], title: '', tags: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toHtml()).toBe( '
      <script>alert("xss")</script>
      ', ); }); }); describe('Username shortcuts', () => { it('should support username attrs format', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] }, { type: 'username', attrs: { shortcut: 'test', only: '', username: 'TestUser', }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: { test: { id: 'test', url: 'https://test.postybirb.com/$1', }, }, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe( 'Hello, https://test.postybirb.com/TestUser', ); expect(tree.toHtml()).toBe( '
      Hello, TestUser
      ', ); expect(tree.toBBCode()).toBe( '[b]Hello, [/b][url=https://test.postybirb.com/TestUser]TestUser[/url]', ); }); it('should find usernames from attrs format', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'username', attrs: { shortcut: 'twitter', only: '', username: 'alice', }, }, { type: 'text', text: ' and ' }, { type: 'username', attrs: { shortcut: 'twitter', only: '', username: 'bob', }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: {}, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); const usernames = tree.findUsernames(); expect(usernames.size).toBe(2); expect(usernames.has('alice')).toBe(true); expect(usernames.has('bob')).toBe(true); }); it('should support username conversion', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Follow me: ' }, { type: 'username', attrs: { shortcut: 'twitter', only: '', username: 'myusername', }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: { twitter: { id: 'twitter', url: 'https://twitter.com/$1', }, test: { id: 'test', url: 'https://test.postybirb.com/$1', }, }, customShortcuts: new Map(), defaultDescription: [], usernameConversions: new Map([['myusername', 'converted_username']]), }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe( 'Follow me: https://test.postybirb.com/converted_username', ); expect(tree.toHtml()).toBe( '', ); }); it('should handle empty username gracefully', () => { const nodes: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'username', attrs: { shortcut: 'test', only: '', username: '', }, }, ], }, ]; const context: ConversionContext = { website: 'test', shortcuts: { test: { id: 'test', url: 'https://test.postybirb.com/$1', }, }, customShortcuts: new Map(), defaultDescription: [], }; const tree = new DescriptionNodeTree(context, nodes, { insertAd: false, }); expect(tree.toPlainText()).toBe(''); expect(tree.findUsernames().size).toBe(0); }); }); }); ================================================ FILE: apps/client-server/src/app/post-parsers/parsers/content-warning-parser.ts ================================================ import { Injectable } from '@nestjs/common'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { DefaultWebsiteOptions } from '../../websites/models/default-website-options'; @Injectable() export class ContentWarningParser { public async parse( defaultOptions: DefaultWebsiteOptions, websiteOptions: BaseWebsiteOptions, ): Promise { const defaultWarningForm = defaultOptions.getFormFieldFor('contentWarning'); const websiteWarningForm = websiteOptions.getFormFieldFor('contentWarning'); const merged = websiteOptions.mergeDefaults(defaultOptions); const warning = merged.contentWarning ?? ''; const field = websiteWarningForm ?? defaultWarningForm; const maxLength = field?.maxLength ?? Infinity; return warning.trim().slice(0, maxLength); } } ================================================ FILE: apps/client-server/src/app/post-parsers/parsers/description-parser.service.spec.ts ================================================ /* eslint-disable max-classes-per-file */ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { DescriptionField } from '@postybirb/form-builder'; import { Description, DescriptionType, DescriptionValue, IWebsiteOptions, TipTapNode, } from '@postybirb/types'; import { WEBSITE_IMPLEMENTATIONS } from '../../constants'; import { CustomShortcutsService } from '../../custom-shortcuts/custom-shortcuts.service'; import { SettingsService } from '../../settings/settings.service'; import { UserConvertersService } from '../../user-converters/user-converters.service'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { DefaultWebsiteOptions } from '../../websites/models/default-website-options'; import { UnknownWebsite } from '../../websites/website'; import { DescriptionParserService } from './description-parser.service'; describe('DescriptionParserService', () => { let module: TestingModule; let service: DescriptionParserService; let settingsService: SettingsService; let customShortcutsService: CustomShortcutsService; let userConvertersService: UserConvertersService; const testDescription: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] }, { type: 'text', text: 'World!' }, ], }, { type: 'paragraph', content: [ { type: 'text', text: 'A link', marks: [ { type: 'link', attrs: { href: 'https://postybirb.com' }, }, ], }, ], }, ], }; beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ providers: [ DescriptionParserService, { provide: SettingsService, useValue: { getSettings: jest.fn(), getDefaultSettings: jest.fn(), }, }, { provide: CustomShortcutsService, useValue: { findById: jest.fn(), }, }, { provide: UserConvertersService, useValue: { convert: jest .fn() .mockImplementation((instance, username) => Promise.resolve(username), ), }, }, { provide: WEBSITE_IMPLEMENTATIONS, useValue: [], }, ], }).compile(); service = module.get(DescriptionParserService); settingsService = module.get(SettingsService); customShortcutsService = module.get(CustomShortcutsService); userConvertersService = module.get(UserConvertersService); settingsService.getDefaultSettings = jest.fn().mockResolvedValue({ settings: { hiddenWebsites: [], language: 'en', allowAd: false, }, }); }); afterAll(async () => { await module.close(); }); function createWebsiteOptions( description: Description | undefined, ): IWebsiteOptions { return { data: { description: { description, }, }, } as IWebsiteOptions; } it('should be defined', () => { expect(service).toBeDefined(); }); it('should parse plaintext description', async () => { const instance = { decoratedProps: { allowAd: true, metadata: { name: 'Test', }, }, }; class PlaintextBaseWebsiteOptions extends BaseWebsiteOptions { @DescriptionField({ descriptionType: DescriptionType.PLAINTEXT }) description: DescriptionValue; } const defaultOptions = createWebsiteOptions(testDescription); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new PlaintextBaseWebsiteOptions(websiteOptions.data), [], '', ); expect(description).toMatchInlineSnapshot(` "Hello, World! A link: https://postybirb.com" `); }); it('should parse html description', async () => { const instance = { decoratedProps: { allowAd: true, metadata: { name: 'Test', }, }, }; const defaultOptions = createWebsiteOptions(testDescription); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), [], '', ); expect(description).toMatchInlineSnapshot( `"
      Hello, World!
      "`, ); }); it('should parse markdown description', async () => { const instance = { decoratedProps: { allowAd: true, metadata: { name: 'Test', }, }, }; class MarkdownBaseWebsiteOptions extends BaseWebsiteOptions { @DescriptionField({ descriptionType: DescriptionType.MARKDOWN }) description: DescriptionValue; } const defaultOptions = createWebsiteOptions(testDescription); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new MarkdownBaseWebsiteOptions(websiteOptions.data), [], '', ); expect(description).toMatchInlineSnapshot(` "**Hello,** World! [A link](https://postybirb.com)" `); }); it('should return empty for description type NONE', async () => { const instance = { decoratedProps: { allowAd: true, }, }; class NoneBaseWebsiteOptions extends BaseWebsiteOptions { @DescriptionField({ descriptionType: DescriptionType.NONE }) description: DescriptionValue; } const defaultOptions = createWebsiteOptions(testDescription); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new NoneBaseWebsiteOptions(websiteOptions.data), [], '', ); expect(description).toEqual(undefined); }); it('should insert ad if allowed in settings and website', async () => { settingsService.getDefaultSettings = jest.fn().mockResolvedValue({ settings: { hiddenWebsites: [], language: 'en', allowAd: true, }, }); const instance = { decoratedProps: { allowAd: true, metadata: { name: 'Test', }, }, }; const defaultOptions = createWebsiteOptions(testDescription); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), [], '', ); expect(description).toMatchInlineSnapshot( `"
      Hello, World!
      "`, ); }); it('should not insert ad if allowed in settings and not website', async () => { settingsService.getDefaultSettings = jest.fn().mockResolvedValue({ settings: { hiddenWebsites: [], language: 'en', allowAd: true, }, }); const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; const defaultOptions = createWebsiteOptions(testDescription); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), [], '', ); expect(description).toMatchInlineSnapshot( `"
      Hello, World!
      "`, ); }); it('should pass blocks through without merging', () => { const blocks: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Test\nIn the same block!', }, ], }, { type: 'paragraph', content: [ { type: 'text', text: 'New block', }, ], }, { type: 'paragraph', attrs: { textAlign: 'center' }, content: [ { type: 'text', text: 'block', }, ], }, ]; const result = service.mergeBlocks(blocks); expect(result).toBe(blocks); }); it('should insert default when available', async () => { const instance = { decoratedProps: { allowAd: true, metadata: { name: 'Test', }, }, }; class PlaintextBaseWebsiteOptions extends BaseWebsiteOptions { @DescriptionField({ descriptionType: DescriptionType.PLAINTEXT }) description: DescriptionValue; } const defaultOptions = createWebsiteOptions(testDescription); const websiteDesc: Description = { type: 'doc', content: [ { type: 'defaultShortcut', }, { type: 'paragraph', content: [{ type: 'text', text: 'Hello, Basic' }], }, ], }; const websiteOptions = createWebsiteOptions(websiteDesc); websiteOptions.data.description.overrideDefault = true; const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new PlaintextBaseWebsiteOptions(websiteOptions.data), [], '', ); expect(description).toMatchInlineSnapshot(` "Hello, World! A link: https://postybirb.com Hello, Basic" `); }); describe('Custom Shortcuts', () => { it('should inject single custom shortcut', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; const shortcutContent: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Commission Info', marks: [{ type: 'bold' }], }, ], }, ], }; customShortcutsService.findById = jest.fn().mockResolvedValue({ id: 'cs-1', name: 'commission', shortcut: shortcutContent, }); const descriptionWithShortcut: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Check out my ' }, { type: 'customShortcut', attrs: { id: 'cs-1' } }, ], }, ], }; const defaultOptions = createWebsiteOptions(descriptionWithShortcut); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), [], '', ); expect(customShortcutsService.findById).toHaveBeenCalledWith('cs-1'); expect(description).toMatchInlineSnapshot( `"
      Check out my
      Commission Info
      "`, ); }); it('should inject multiple custom shortcuts', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; const commissionShortcut: Description = { type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Commissions Open!' }], }, ], }; const priceShortcut: Description = { type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: '$50 per hour' }], }, ], }; customShortcutsService.findById = jest .fn() .mockImplementation((id: string) => { if (id === 'cs-1') return Promise.resolve({ id: 'cs-1', name: 'commission', shortcut: commissionShortcut, }); if (id === 'cs-2') return Promise.resolve({ id: 'cs-2', name: 'price', shortcut: priceShortcut, }); return Promise.resolve(null); }); const descriptionWithShortcuts: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'customShortcut', attrs: { id: 'cs-1' } }, { type: 'text', text: ' - ' }, { type: 'customShortcut', attrs: { id: 'cs-2' } }, ], }, ], }; const defaultOptions = createWebsiteOptions(descriptionWithShortcuts); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), [], '', ); expect(customShortcutsService.findById).toHaveBeenCalledWith('cs-1'); expect(customShortcutsService.findById).toHaveBeenCalledWith('cs-2'); expect(description).toMatchInlineSnapshot( `"
      Commissions Open!
      -
      $50 per hour
      "`, ); }); it('should handle missing custom shortcut gracefully', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; customShortcutsService.findById = jest.fn().mockResolvedValue(null); const descriptionWithMissing: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Before ' }, { type: 'customShortcut', attrs: { id: 'cs-missing' }, }, { type: 'text', text: ' After' }, ], }, ], }; const defaultOptions = createWebsiteOptions(descriptionWithMissing); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), [], '', ); expect(customShortcutsService.findById).toHaveBeenCalledWith( 'cs-missing', ); expect(description).toMatchInlineSnapshot(`"
      Before After
      "`); }); it('should resolve custom shortcuts with different output formats', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; class PlaintextBaseWebsiteOptions extends BaseWebsiteOptions { @DescriptionField({ descriptionType: DescriptionType.PLAINTEXT }) description: DescriptionValue; } const shortcutContent: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Bold Text', marks: [{ type: 'bold' }], }, ], }, ], }; customShortcutsService.findById = jest.fn().mockResolvedValue({ id: 'cs-1', name: 'bold', shortcut: shortcutContent, }); const descriptionWithShortcut: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Text: ' }, { type: 'customShortcut', attrs: { id: 'cs-1' } }, ], }, ], }; const defaultOptions = createWebsiteOptions(descriptionWithShortcut); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new PlaintextBaseWebsiteOptions(websiteOptions.data), [], '', ); expect(description).toMatchInlineSnapshot(`"Text: Bold Text"`); }); it('should resolve custom shortcuts with links and styling', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; const shortcutWithLink: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Visit my ' }, { type: 'text', text: 'portfolio', marks: [ { type: 'bold' }, { type: 'link', attrs: { href: 'https://portfolio.example.com' }, }, ], }, ], }, ], }; customShortcutsService.findById = jest.fn().mockResolvedValue({ id: 'cs-link', name: 'portfolio', shortcut: shortcutWithLink, }); const descriptionWithShortcut: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'customShortcut', attrs: { id: 'cs-link' } }, ], }, ], }; const defaultOptions = createWebsiteOptions(descriptionWithShortcut); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), [], '', ); expect(description).toMatchInlineSnapshot( `"
      Visit my portfolio
      "`, ); }); }); describe('System Inline Shortcuts', () => { it('should render titleShortcut with submission title', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; const descriptionWithTitle: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Artwork: ' }, { type: 'titleShortcut', attrs: {} }, ], }, ], }; const defaultOptions = createWebsiteOptions(descriptionWithTitle); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), [], 'My Amazing Art', ); expect(description).toMatchInlineSnapshot( `"
      Artwork: My Amazing Art
      "`, ); }); it('should render tagsShortcut with submission tags', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; const descriptionWithTags: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Tags: ' }, { type: 'tagsShortcut', attrs: {} }, ], }, ], }; const defaultOptions = createWebsiteOptions(descriptionWithTags); const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), ['art', 'digital', 'fantasy'], '', ); expect(description).toMatchInlineSnapshot( `"
      Tags: #art #digital #fantasy
      "`, ); }); it('should render contentWarningShortcut with content warning', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; const descriptionWithCW: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Content Warning: ' }, { type: 'contentWarningShortcut', attrs: {} }, ], }, ], }; const defaultOptions = new DefaultWebsiteOptions({ description: { description: descriptionWithCW, overrideDefault: false, }, contentWarning: 'Mild Violence', }); const websiteOptions = new BaseWebsiteOptions({}); const description = await service.parse( instance as unknown as UnknownWebsite, defaultOptions, websiteOptions, [], '', ); expect(description).toMatchInlineSnapshot( `"
      Content Warning: Mild Violence
      "`, ); }); it('should not double-insert title when titleShortcut is present and insertTitle is true', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; const descriptionWithTitle: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Title: ' }, { type: 'titleShortcut', attrs: {} }, ], }, ], }; const defaultOptions = createWebsiteOptions(descriptionWithTitle); defaultOptions.data.description.insertTitle = true; const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), [], 'My Title', ); expect(description).toMatchInlineSnapshot( `"
      Title: My Title
      "`, ); expect((description.match(/My Title/g) || []).length).toBe(1); }); it('should not double-insert tags when tagsShortcut is present and insertTags is true', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; const descriptionWithTags: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Tags: ' }, { type: 'tagsShortcut', attrs: {} }, ], }, ], }; const defaultOptions = createWebsiteOptions(descriptionWithTags); defaultOptions.data.description.insertTags = true; const websiteOptions = createWebsiteOptions(undefined); const description = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), ['tag1', 'tag2'], '', ); expect(description).toMatchInlineSnapshot( `"
      Tags: #tag1 #tag2
      "`, ); expect((description.match(/#tag1 #tag2/g) || []).length).toBe(1); }); it('should render all system shortcuts together in plaintext', async () => { const instance = { decoratedProps: { allowAd: false, metadata: { name: 'Test', }, }, }; class PlaintextBaseWebsiteOptions extends BaseWebsiteOptions { @DescriptionField({ descriptionType: DescriptionType.PLAINTEXT }) description: DescriptionValue; } const descriptionWithAll: Description = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'titleShortcut', attrs: {} }, { type: 'text', text: ' (' }, { type: 'contentWarningShortcut', attrs: {} }, { type: 'text', text: ')' }, ], }, { type: 'paragraph', content: [{ type: 'tagsShortcut', attrs: {} }], }, ], }; const defaultOptions = new DefaultWebsiteOptions({ description: { description: descriptionWithAll, overrideDefault: false, }, contentWarning: 'NSFW', }); const websiteOptions = new PlaintextBaseWebsiteOptions({}); const description = await service.parse( instance as unknown as UnknownWebsite, defaultOptions, websiteOptions, ['art', 'digital'], 'My Art', ); expect(description).toMatchInlineSnapshot(` "My Art (NSFW) #art #digital" `); }); }); // TODO: Add test for description type CUSTOM }); ================================================ FILE: apps/client-server/src/app/post-parsers/parsers/description-parser.service.ts ================================================ import { Inject, Injectable } from '@nestjs/common'; import { DescriptionType, TipTapNode, UsernameShortcut, } from '@postybirb/types'; import { Class } from 'type-fest'; import { WEBSITE_IMPLEMENTATIONS } from '../../constants'; import { CustomShortcutsService } from '../../custom-shortcuts/custom-shortcuts.service'; import { SettingsService } from '../../settings/settings.service'; import { UserConvertersService } from '../../user-converters/user-converters.service'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { DefaultWebsiteOptions } from '../../websites/models/default-website-options'; import { isWithCustomDescriptionParser } from '../../websites/models/website-modifiers/with-custom-description-parser'; import { isWithRuntimeDescriptionParser } from '../../websites/models/website-modifiers/with-runtime-description-parser'; import { UnknownWebsite, Website } from '../../websites/website'; import { DescriptionNodeTree, InsertionOptions, } from '../models/description-node/description-node-tree'; import { ConversionContext } from '../models/description-node/description-node.base'; @Injectable() export class DescriptionParserService { private readonly websiteShortcuts: Record = {}; constructor( private readonly settingsService: SettingsService, @Inject(WEBSITE_IMPLEMENTATIONS) private readonly websiteImplementations: Class[], private readonly customShortcutsService?: CustomShortcutsService, private readonly userConvertersService?: UserConvertersService, ) { this.websiteImplementations.forEach((website) => { const shortcut: UsernameShortcut | undefined = website.prototype.decoratedProps.usernameShortcut; if (shortcut) { this.websiteShortcuts[shortcut.id] = shortcut; } }); } public async parse( instance: Website, defaultOptions: DefaultWebsiteOptions, websiteOptions: BaseWebsiteOptions, tags: string[], title: string, ): Promise { const mergedOptions = websiteOptions.mergeDefaults(defaultOptions); const { descriptionType, hidden } = mergedOptions.getFormFieldFor('description'); if (descriptionType === DescriptionType.NONE || hidden) { return undefined; } const settings = await this.settingsService.getDefaultSettings(); let { allowAd } = settings.settings; if (!instance.decoratedProps.allowAd) { allowAd = false; } const descriptionValue = mergedOptions.description; const descriptionBlocks: TipTapNode[] = descriptionValue.description?.content ?? []; const { contentWarning } = mergedOptions; // Detect presence of shortcut tags to prevent double insertion const hasTitleShortcut = this.hasInlineContentType( descriptionBlocks, 'titleShortcut', ); const hasTagsShortcut = this.hasInlineContentType( descriptionBlocks, 'tagsShortcut', ); const insertionOptions: InsertionOptions = { insertTitle: descriptionValue.insertTitle && !hasTitleShortcut ? title : undefined, insertTags: descriptionValue.insertTags && !hasTagsShortcut ? tags : undefined, insertAd: allowAd, }; /* * We choose to merge blocks here to avoid confusing user expectations. * Most editors want you to use Shift + Enter to insert a new line. But in * most cases this is not something the user cares about. They just want to * see the description on a line-by-line basis. So we choose to merge similar * blocks together to avoid confusion. */ const mergedDescriptionBlocks = this.mergeBlocks(descriptionBlocks); // Pre-resolve default description const defaultDescription = this.mergeBlocks( defaultOptions.description.description?.content ?? [], ); for (let i = defaultDescription.length - 1; i >= 0; i--) { const element = defaultDescription[i]; const isSpacing = element?.type === 'paragraph' && (!element.content || element.content.length === 0); if (isSpacing) { defaultDescription.splice(i); } else break; } // Build tree once with minimal context const context: ConversionContext = { website: instance.decoratedProps.metadata.name, shortcuts: this.websiteShortcuts, customShortcuts: new Map(), defaultDescription, title, tags, usernameConversions: new Map(), contentWarningText: contentWarning, }; const tree = new DescriptionNodeTree( context, mergedDescriptionBlocks, insertionOptions, ); // Resolve and inject into the same tree const customShortcuts = await this.resolveCustomShortcutsFromTree(tree); const usernameConversions = await this.resolveUsernamesFromTree( tree, instance, ); tree.updateContext({ customShortcuts, usernameConversions, }); return this.createDescription(instance, descriptionType, tree); } private createDescription( instance: Website, descriptionType: DescriptionType, tree: DescriptionNodeTree, ): string { switch (descriptionType) { case DescriptionType.MARKDOWN: return tree.toMarkdown(); case DescriptionType.HTML: return tree.toHtml(); case DescriptionType.PLAINTEXT: return tree.toPlainText(); case DescriptionType.BBCODE: return tree.toBBCode(); case DescriptionType.CUSTOM: if (isWithCustomDescriptionParser(instance)) { const converter = instance.getDescriptionConverter(); return tree.parseWithConverter(converter); } throw new Error( `Website does not implement custom description parser: ${instance.constructor.name}`, ); case DescriptionType.RUNTIME: if (isWithRuntimeDescriptionParser(instance)) { return this.createDescription( instance, instance.getRuntimeParser(), tree, ); } throw new Error( `Website does not implement runtime description mapping: ${instance.constructor.name}`, ); default: throw new Error(`Unsupported description type: ${descriptionType}`); } } /** * Pre-resolves all custom shortcuts found in the description tree. * Note: Does not handle nested shortcuts - users should not create circular references. */ private async resolveCustomShortcutsFromTree( tree: DescriptionNodeTree, ): Promise> { const customShortcuts = new Map(); const shortcutIds = tree.findCustomShortcutIds(); for (const id of shortcutIds) { const shortcut = await this.customShortcutsService?.findById(id); if (shortcut) { const shortcutBlocks = this.mergeBlocks( shortcut.shortcut?.content ?? [], ); customShortcuts.set(id, shortcutBlocks); } } return customShortcuts; } /** * Pre-resolves all usernames found in the description tree. */ private async resolveUsernamesFromTree( tree: DescriptionNodeTree, instance: Website, ): Promise> { const usernameConversions = new Map(); const usernames = tree.findUsernames(); for (const username of usernames) { const converted = (await this.userConvertersService?.convert(instance, username)) ?? username; usernameConversions.set(username, converted); } return usernameConversions; } public mergeBlocks(blocks: TipTapNode[]): TipTapNode[] { return blocks; } /** * Recursively checks if TipTap nodes contain a specific inline content type. */ private hasInlineContentType(blocks: TipTapNode[], type: string): boolean { for (const block of blocks) { if (block?.type === type) return true; if (Array.isArray(block?.content)) { if (this.hasInlineContentType(block.content, type)) { return true; } } } return false; } } ================================================ FILE: apps/client-server/src/app/post-parsers/parsers/rating-parser.spec.ts ================================================ import { IWebsiteOptions, SubmissionRating } from '@postybirb/types'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { DefaultWebsiteOptions } from '../../websites/models/default-website-options'; import { RatingParser } from './rating-parser'; describe('RatingParser', () => { let parser: RatingParser; beforeEach(() => { parser = new RatingParser(); }); it('should parse rating', () => { const options: IWebsiteOptions = { data: { rating: SubmissionRating.ADULT, }, } as IWebsiteOptions; const defaultOptions: IWebsiteOptions = { data: { rating: SubmissionRating.GENERAL, }, } as IWebsiteOptions; expect( parser.parse( new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(options.data), ), ).toEqual(SubmissionRating.ADULT); }); it('should parse default rating', () => { const options: IWebsiteOptions = { data: {}, } as IWebsiteOptions; const defaultOptions: IWebsiteOptions = { data: { rating: SubmissionRating.GENERAL, }, } as IWebsiteOptions; expect( parser.parse( new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(options.data), ), ).toEqual(SubmissionRating.GENERAL); }); it('should throw on parsing no rating', () => { expect(() => parser.parse( { rating: null } as unknown as DefaultWebsiteOptions, { rating: null } as unknown as BaseWebsiteOptions, ), ).toThrow(Error); }); }); ================================================ FILE: apps/client-server/src/app/post-parsers/parsers/rating-parser.ts ================================================ import { SubmissionRating } from '@postybirb/types'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { DefaultWebsiteOptions } from '../../websites/models/default-website-options'; export class RatingParser { public parse( defaultOptions: DefaultWebsiteOptions, websiteOptions: BaseWebsiteOptions, ): SubmissionRating { if (websiteOptions.rating) { return websiteOptions.rating; } if (defaultOptions.rating) { return defaultOptions.rating; } throw new Error('No rating found'); } } ================================================ FILE: apps/client-server/src/app/post-parsers/parsers/tag-parser.service.spec.ts ================================================ /* eslint-disable max-classes-per-file */ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { TagField } from '@postybirb/form-builder'; import { IWebsiteOptions, TagValue } from '@postybirb/types'; import { TagConvertersService } from '../../tag-converters/tag-converters.service'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { DefaultWebsiteOptions } from '../../websites/models/default-website-options'; import { UnknownWebsite } from '../../websites/website'; import { TagParserService } from './tag-parser.service'; describe('TagParserService', () => { let module: TestingModule; let service: TagParserService; beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ providers: [ TagParserService, { provide: TagConvertersService, useValue: { convert: (_, tags: string[]) => tags, }, }, ], }).compile(); service = module.get(TagParserService); }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should parse tags', async () => { const instance = {}; const defaultOptions: IWebsiteOptions = { data: { tags: { tags: ['default'], }, }, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { data: { tags: { tags: ['website'], }, }, } as IWebsiteOptions; const tags = [ ...websiteOptions.data.tags.tags, ...defaultOptions.data.tags.tags, ]; const result = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), ); expect(result).toEqual(tags); }); it('should parse tags with default tags override', async () => { const instance = {}; const defaultOptions: IWebsiteOptions = { data: { tags: { tags: ['default'], }, }, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { data: { tags: { tags: ['website'], overrideDefault: true, }, }, } as IWebsiteOptions; const { tags } = websiteOptions.data.tags; const result = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), ); expect(result).toEqual(tags); }); it('should parse tags with no website options', async () => { const instance = {}; const defaultOptions: IWebsiteOptions = { data: { tags: { tags: ['default'], }, }, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { data: {}, } as IWebsiteOptions; const { tags } = defaultOptions.data.tags; const result = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), ); expect(result).toEqual(tags); }); it('should parse tags with no website options and no default tags', async () => { const instance = {}; const defaultOptions: IWebsiteOptions = { data: {}, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { data: {}, } as IWebsiteOptions; const tags = []; const result = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), ); expect(result).toEqual(tags); }); it('should parse tags with no tag support and return empty', async () => { const instance = {}; const defaultOptions: IWebsiteOptions = { data: { tags: { tags: ['default'], }, }, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { data: { tags: { tags: ['website'], }, }, } as IWebsiteOptions; const tags = []; class TagOptions extends BaseWebsiteOptions { @TagField({ hidden: true }) tags: TagValue; } const result = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new TagOptions(websiteOptions.data), ); expect(result).toEqual(tags); }); it('should parse tags with custom instance tag parser', async () => { const instance = {}; class TagOptions extends BaseWebsiteOptions { @TagField({}) tags: TagValue; protected processTag(tag: string): string { return tag.toUpperCase(); } } const defaultOptions: IWebsiteOptions = { data: { tags: { tags: ['default'], }, }, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { data: { tags: { tags: ['website'], }, }, } as IWebsiteOptions; const tags = [ ...websiteOptions.data.tags.tags, ...defaultOptions.data.tags.tags, ].map((tag) => tag.toUpperCase()); const result = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new TagOptions(websiteOptions.data), ); expect(result).toEqual(tags); }); it('should truncate tags to maxTags', async () => { const instance = {}; const defaultOptions: IWebsiteOptions = { data: { tags: { tags: ['default', 'default2'], }, }, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { data: { tags: { tags: ['website', 'website2'], }, }, } as IWebsiteOptions; const tags = [websiteOptions.data.tags.tags[0]]; class TagOptions extends BaseWebsiteOptions { @TagField({ maxTags: 1 }) tags: TagValue; } const result = await service.parse( instance as unknown as UnknownWebsite, new DefaultWebsiteOptions(defaultOptions.data), new TagOptions(websiteOptions.data), ); expect(result).toEqual(tags); }); }); ================================================ FILE: apps/client-server/src/app/post-parsers/parsers/tag-parser.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { TagConvertersService } from '../../tag-converters/tag-converters.service'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { DefaultWebsiteOptions } from '../../websites/models/default-website-options'; import { Website } from '../../websites/website'; @Injectable() export class TagParserService { constructor(private readonly tagConvertersService: TagConvertersService) {} public async parse( instance: Website, defaultOptions: DefaultWebsiteOptions, websiteOptions: BaseWebsiteOptions, ): Promise { const mergedOptions = websiteOptions.mergeDefaults(defaultOptions); return mergedOptions.getProcessedTags((tag) => this.tagConvertersService.convert(instance, tag), ); } } ================================================ FILE: apps/client-server/src/app/post-parsers/parsers/title-parser.spec.ts ================================================ /* eslint-disable max-classes-per-file */ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { TitleField } from '@postybirb/form-builder'; import { IWebsiteOptions } from '@postybirb/types'; import { FormGeneratorService } from '../../form-generator/form-generator.service'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { DefaultWebsiteOptions } from '../../websites/models/default-website-options'; import { TitleParser } from './title-parser'; describe('TitleParserService', () => { let module: TestingModule; let service: TitleParser; beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ providers: [ TitleParser, { provide: FormGeneratorService, useValue: { getDefaultForm: jest.fn(), generateForm: jest.fn(), }, }, ], }).compile(); service = module.get(TitleParser); }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should parse title', async () => { const defaultOptions: IWebsiteOptions = { id: 'default', data: { title: 'default', }, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { id: 'website', data: { title: 'website', }, } as IWebsiteOptions; const title = await service.parse( new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), ); expect(title).toBe('website'); }); it('should parse title with no website options', async () => { class TestWebsiteOptions extends BaseWebsiteOptions { @TitleField({ maxLength: 5 }) public title: string; } class TestDefaultWebsiteOptions extends DefaultWebsiteOptions { @TitleField({ maxLength: 10 }) public title: string; } const defaultOptions: IWebsiteOptions = { id: 'default', data: { title: 'default', }, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { id: 'website', data: {}, } as IWebsiteOptions; const title = await service.parse( new TestDefaultWebsiteOptions(defaultOptions.data), new TestWebsiteOptions(websiteOptions.data), ); // Title should be truncated expect(title).toBe('defau'); }); it('should parse title and use default form if website form is not available', async () => { const defaultOptions: IWebsiteOptions = { id: 'default', data: { title: 'default', }, } as IWebsiteOptions; const websiteOptions: IWebsiteOptions = { id: 'website', data: {}, } as IWebsiteOptions; const title = await service.parse( new DefaultWebsiteOptions(defaultOptions.data), new BaseWebsiteOptions(websiteOptions.data), ); expect(title).toBe('default'); }); }); ================================================ FILE: apps/client-server/src/app/post-parsers/parsers/title-parser.ts ================================================ import { Injectable } from '@nestjs/common'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { DefaultWebsiteOptions } from '../../websites/models/default-website-options'; @Injectable() export class TitleParser { public async parse( defaultOptions: DefaultWebsiteOptions, websiteOptions: BaseWebsiteOptions, ): Promise { const defaultTitleForm = defaultOptions.getFormFieldFor('title'); const websiteTitleForm = websiteOptions.getFormFieldFor('title'); const merged = websiteOptions.mergeDefaults(defaultOptions); const title = merged.title ?? ''; const field = websiteTitleForm ?? defaultTitleForm; const maxLength = field?.maxLength ?? Infinity; return title.trim().slice(0, maxLength); } } ================================================ FILE: apps/client-server/src/app/post-parsers/post-parsers.module.ts ================================================ import { Module, forwardRef } from '@nestjs/common'; import { CustomShortcutsModule } from '../custom-shortcuts/custom-shortcuts.module'; import { FormGeneratorModule } from '../form-generator/form-generator.module'; import { SettingsModule } from '../settings/settings.module'; import { TagConvertersModule } from '../tag-converters/tag-converters.module'; import { UserConvertersModule } from '../user-converters/user-converters.module'; import { WebsiteImplProvider } from '../websites/implementations/provider'; import { DescriptionParserService } from './parsers/description-parser.service'; import { TagParserService } from './parsers/tag-parser.service'; import { PostParsersService } from './post-parsers.service'; @Module({ imports: [ TagConvertersModule, UserConvertersModule, FormGeneratorModule, SettingsModule, forwardRef(() => CustomShortcutsModule), ], providers: [ PostParsersService, TagParserService, WebsiteImplProvider, DescriptionParserService, ], exports: [PostParsersService, TagParserService, DescriptionParserService], }) export class PostParsersModule {} ================================================ FILE: apps/client-server/src/app/post-parsers/post-parsers.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { ISubmission, IWebsiteFormFields, IWebsiteOptions, PostData, } from '@postybirb/types'; import { DefaultWebsiteOptions } from '../websites/models/default-website-options'; import { UnknownWebsite } from '../websites/website'; import { ContentWarningParser } from './parsers/content-warning-parser'; import { DescriptionParserService } from './parsers/description-parser.service'; import { RatingParser } from './parsers/rating-parser'; import { TagParserService } from './parsers/tag-parser.service'; import { TitleParser } from './parsers/title-parser'; @Injectable() export class PostParsersService { private readonly ratingParser: RatingParser = new RatingParser(); private readonly titleParser: TitleParser = new TitleParser(); private readonly contentWarningParser: ContentWarningParser = new ContentWarningParser(); constructor( private readonly tagParser: TagParserService, private readonly descriptionParser: DescriptionParserService, ) {} public async parse( submission: ISubmission, instance: UnknownWebsite, websiteOptions: IWebsiteOptions, ): Promise> { const defaultOptions: IWebsiteOptions = submission.options.find( (o) => o.isDefault, ); const defaultOpts = Object.assign(new DefaultWebsiteOptions(), { ...defaultOptions.data, }); const websiteOpts = Object.assign(instance.getModelFor(submission.type), { ...websiteOptions.data, }); const tags = await this.tagParser.parse(instance, defaultOpts, websiteOpts); const title = await this.titleParser.parse(defaultOpts, websiteOpts); const contentWarning = await this.contentWarningParser.parse( defaultOpts, websiteOpts, ); return { submission, options: { ...defaultOptions.data, ...websiteOptions.data, tags, description: await this.descriptionParser.parse( instance, defaultOpts, websiteOpts, tags, title, ), title, contentWarning, rating: this.ratingParser.parse(defaultOpts, websiteOpts), }, }; } } ================================================ FILE: apps/client-server/src/app/remote/models/update-cookies-remote.dto.ts ================================================ import { UpdateCookiesRemote } from '@postybirb/types'; import { IsString } from 'class-validator'; export class UpdateCookiesRemoteDto implements UpdateCookiesRemote { @IsString() accountId: string; @IsString() cookies: string; } ================================================ FILE: apps/client-server/src/app/remote/remote.controller.ts ================================================ import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { UpdateCookiesRemoteDto } from './models/update-cookies-remote.dto'; import { RemoteService } from './remote.service'; @Controller('remote') export class RemoteController { constructor(private readonly remoteService: RemoteService) {} @Get('ping/:password') ping(@Param('password') password: string) { return this.remoteService.validate(password); } @Post('set-cookies') setCookies(@Body() updateCookies: UpdateCookiesRemoteDto) { return this.remoteService.setCookies(updateCookies); } } ================================================ FILE: apps/client-server/src/app/remote/remote.middleware.ts ================================================ import { Injectable, NestMiddleware, UnauthorizedException, } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { RemoteService } from './remote.service'; @Injectable() export class RemotePasswordMiddleware implements NestMiddleware { constructor(private readonly remoteService: RemoteService) {} async use(req: Request, res: Response, next: NextFunction) { try { if (req.baseUrl.startsWith('/api/file/')) { // Skip authentication for file API routes // This is mostly just to avoid nuisance password injection into query params next(); return; } if (req.baseUrl.startsWith('/api/remote/ping')) { // Skip authentication for ping API routes to let user check the password next(); return; } const remotePassword = req.headers['x-remote-password'] as string; if (!remotePassword) { throw new UnauthorizedException('No remote password provided'); } const isValid = await this.remoteService.validate(remotePassword); if (!isValid) { throw new UnauthorizedException('Invalid remote password'); } next(); // Proceed to the next middleware or route handler } catch (error) { next(error); // Pass the error to the global error handler } } } ================================================ FILE: apps/client-server/src/app/remote/remote.module.ts ================================================ import { Module } from '@nestjs/common'; import { SettingsModule } from '../settings/settings.module'; import { RemoteController } from './remote.controller'; import { RemoteService } from './remote.service'; @Module({ imports: [SettingsModule], controllers: [RemoteController], providers: [RemoteService], exports: [RemoteService], }) export class RemoteModule {} ================================================ FILE: apps/client-server/src/app/remote/remote.service.ts ================================================ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { getRemoteConfig } from '@postybirb/utils/electron'; import { session } from 'electron'; import { UpdateCookiesRemoteDto } from './models/update-cookies-remote.dto'; @Injectable() export class RemoteService { protected readonly logger = Logger(this.constructor.name); async validate(password: string): Promise { const remoteConfig = await getRemoteConfig(); // if (!remoteConfig.enabled) { // this.logger.error('Remote access is not enabled'); // throw new UnauthorizedException('Remote access is not enabled'); // } if (remoteConfig.password !== password) { this.logger.error('Invalid remote access password'); throw new UnauthorizedException('Invalid remote access password'); } return true; } /** * Set cookies for the specified account ID. * To share cookies with the remote host, this method should be called. * * @param {UpdateCookiesRemoteDto} updateCookies */ async setCookies(updateCookies: UpdateCookiesRemoteDto) { this.logger .withMetadata({ accountId: updateCookies.accountId }) .info('Updating cookies from remote client'); const clientCookies = Buffer.from(updateCookies.cookies, 'base64').toString( 'utf-8', ); const cookies = JSON.parse(clientCookies); if (!Array.isArray(cookies)) { this.logger.error('Invalid cookies format received from remote client'); throw new Error('Invalid cookies format received from remote client'); } if (cookies.length === 0) { this.logger.warn('No cookies provided for account, skipping update'); return; } const accountSession = session.fromPartition( `persist:${updateCookies.accountId}`, ); await accountSession.clearStorageData(); await Promise.all( cookies.map((cookie) => accountSession.cookies.set(this.convertCookie(cookie)), ), ); } private convertCookie(cookie: Electron.Cookie): Electron.CookiesSetDetails { const url = `${cookie.secure ? 'https' : 'http'}://${cookie.domain}${cookie.path || ''}`; const details: Electron.CookiesSetDetails = { domain: `.${cookie.domain}`.replace('..', '.'), httpOnly: cookie.httpOnly || false, name: cookie.name, secure: cookie.secure || false, url: url.replace('://.', '://'), value: cookie.value, }; if (cookie.expirationDate) { details.expirationDate = cookie.expirationDate; } return details; } } ================================================ FILE: apps/client-server/src/app/security-and-authentication/ssl.ts ================================================ import { Logger } from '@postybirb/logger'; import { app } from 'electron'; import { mkdir, readFile, stat, writeFile } from 'fs/promises'; import forge from 'node-forge'; import { join } from 'path'; export class SSL { private static cachedCerts?: { key: string; cert: string }; static async getOrCreateSSL(): Promise<{ key: string; cert: string }> { // Return cached certs if available if (this.cachedCerts) { return this.cachedCerts; } const logger = Logger().withContext({ name: 'SSL' }); const path = join(app.getPath('userData'), 'auth'); const keyPath = join(path, 'key.pem'); const certPath = join(path, 'cert.pem'); // Check if certificates already exist let exists = false; try { await stat(certPath); exists = true; } catch { try { await mkdir(path, { recursive: true }); } catch (err) { if (err.code !== 'EEXIST') { logger.error(err); } } } if (exists) { const certs = { key: (await readFile(keyPath)).toString(), cert: (await readFile(certPath)).toString(), }; this.cachedCerts = certs; return certs; } logger.trace('Creating SSL certs...'); const { pki } = forge; // Generate RSA key pair - will use native crypto if available const keys = pki.rsa.generateKeyPair(2048); const cert = pki.createCertificate(); cert.publicKey = keys.publicKey; cert.serialNumber = '01'; cert.validity.notBefore = new Date(); cert.validity.notAfter = new Date(); cert.validity.notAfter.setFullYear( cert.validity.notBefore.getFullYear() + 99, ); const attrs = [ { name: 'commonName', value: 'postybirb.com' }, { name: 'countryName', value: 'US' }, { shortName: 'ST', value: 'Virginia' }, { name: 'localityName', value: 'Arlington' }, { name: 'organizationName', value: 'PostyBirb' }, { shortName: 'OU', value: 'PostyBirb' }, ]; cert.setSubject(attrs); cert.setIssuer(attrs); cert.sign(keys.privateKey); const pkey = pki.privateKeyToPem(keys.privateKey); const pcert = pki.certificateToPem(cert); await Promise.all([writeFile(keyPath, pkey), writeFile(certPath, pcert)]); logger.info('SSL Certs created'); const certs = { cert: pcert, key: pkey }; this.cachedCerts = certs; return certs; } } ================================================ FILE: apps/client-server/src/app/settings/dtos/update-settings.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ISettingsOptions, IUpdateSettingsDto } from '@postybirb/types'; import { IsObject } from 'class-validator'; /** * Settings update request object. */ export class UpdateSettingsDto implements IUpdateSettingsDto { @ApiProperty() @IsObject() settings: ISettingsOptions; } ================================================ FILE: apps/client-server/src/app/settings/dtos/update-startup-settings.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { StartupOptions } from '@postybirb/utils/electron'; import { IsBoolean, IsOptional, IsString } from 'class-validator'; export class UpdateStartupSettingsDto implements StartupOptions { @ApiProperty() @IsOptional() @IsString() appDataPath: string; @ApiProperty() @IsOptional() @IsString() port: string; @ApiProperty() @IsOptional() @IsBoolean() startAppOnSystemStartup: boolean; @ApiProperty() @IsOptional() @IsBoolean() spellchecker: boolean; } ================================================ FILE: apps/client-server/src/app/settings/settings.controller.ts ================================================ import { Body, Controller, Get, Param, Patch } from '@nestjs/common'; import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { EntityId } from '@postybirb/types'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { UpdateSettingsDto } from './dtos/update-settings.dto'; import { UpdateStartupSettingsDto } from './dtos/update-startup-settings.dto'; import { SettingsService } from './settings.service'; /** * CRUD operations for settings. * @class SettingsController */ @ApiTags('settings') @Controller('settings') export class SettingsController extends PostyBirbController<'SettingsSchema'> { constructor(readonly service: SettingsService) { super(service); } @Patch(':id') @ApiOkResponse({ description: 'Update successful.' }) @ApiNotFoundResponse({ description: 'Settings profile not found.' }) update( @Body() updateSettingsDto: UpdateSettingsDto, @Param('id') id: EntityId, ) { return this.service .update(id, updateSettingsDto) .then((entity) => entity.toDTO()); } @Get('startup') getStartupSettings() { return this.service.getStartupSettings(); } @Patch('startup/system-startup') updateStartupSettings(@Body() startupOptions: UpdateStartupSettingsDto) { return this.service.updateStartupSettings(startupOptions); } } ================================================ FILE: apps/client-server/src/app/settings/settings.events.ts ================================================ import { SETTINGS_UPDATES } from '@postybirb/socket-events'; import { SettingsDto } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type SettingsEventTypes = SettingsUpdateEvent; class SettingsUpdateEvent implements WebsocketEvent { event: string = SETTINGS_UPDATES; data: SettingsDto[]; } ================================================ FILE: apps/client-server/src/app/settings/settings.module.ts ================================================ import { Module } from '@nestjs/common'; import { SettingsController } from './settings.controller'; import { SettingsService } from './settings.service'; @Module({ providers: [SettingsService], controllers: [SettingsController], exports: [SettingsService], }) export class SettingsModule {} ================================================ FILE: apps/client-server/src/app/settings/settings.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { UpdateSettingsDto } from './dtos/update-settings.dto'; import { SettingsService } from './settings.service'; describe('SettingsService', () => { let service: SettingsService; let module: TestingModule; beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ providers: [SettingsService], }).compile(); service = module.get(SettingsService); await service.onModuleInit(); }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should update entities', async () => { const groups = await service.findAll(); expect(groups).toHaveLength(1); const record = groups[0]; const updateDto = new UpdateSettingsDto(); updateDto.settings = { desktopNotifications: { enabled: true, showOnDirectoryWatcherError: true, showOnDirectoryWatcherSuccess: true, showOnPostError: true, showOnPostSuccess: true, }, tagSearchProvider: { id: undefined, showWikiInHelpOnHover: false, }, hiddenWebsites: ['test'], language: 'en', allowAd: true, queuePaused: false, }; await service.update(record.id, updateDto); const updatedRec = await service.findById(record.id); expect(updatedRec.settings).toEqual(updateDto.settings); }); }); ================================================ FILE: apps/client-server/src/app/settings/settings.service.ts ================================================ import { BadRequestException, Injectable, OnModuleInit, Optional, } from '@nestjs/common'; import { SETTINGS_UPDATES } from '@postybirb/socket-events'; import { EntityId, SettingsConstants } from '@postybirb/types'; import { StartupOptions, getStartupOptions, setStartupOptions, } from '@postybirb/utils/electron'; import { eq } from 'drizzle-orm'; import { PostyBirbService } from '../common/service/postybirb-service'; import { Settings } from '../drizzle/models'; import { WSGateway } from '../web-socket/web-socket-gateway'; import { UpdateSettingsDto } from './dtos/update-settings.dto'; @Injectable() export class SettingsService extends PostyBirbService<'SettingsSchema'> implements OnModuleInit { constructor(@Optional() webSocket: WSGateway) { super('SettingsSchema', webSocket); this.repository.subscribe('SettingsSchema', () => this.emit()); } /** * Initializes default settings if required. * Also updates existing settings with any new default fields that might be missing. * Heavy merge operations are deferred to avoid blocking application startup. */ async onModuleInit() { const defaultSettingsCount = await this.repository.count( eq(this.schema.profile, SettingsConstants.DEFAULT_PROFILE_NAME), ); if (!defaultSettingsCount) { this.createDefaultSettings(); } else { // Defer the settings merge check to avoid blocking startup setImmediate(async () => { // Get existing default settings const existingSettings = await this.getDefaultSettings(); if (existingSettings) { // Check if there are any missing fields compared to the current default settings const currentDefaults = SettingsConstants.DEFAULT_SETTINGS; let hasChanges = false; const updatedSettings = { ...existingSettings.settings }; // Recursively merge missing fields // eslint-disable-next-line @typescript-eslint/no-explicit-any const mergeObjects = (target: any, source: any, path = ''): boolean => { let changed = false; Object.keys(source).forEach((key) => { const fullPath = path ? `${path}.${key}` : key; // If key doesn't exist in target, add it if (!(key in target)) { // eslint-disable-next-line no-param-reassign target[key] = source[key]; this.logger.debug(`Added missing setting: ${fullPath}`); changed = true; } // If both are objects, recursively merge else if ( typeof source[key] === 'object' && source[key] !== null && typeof target[key] === 'object' && target[key] !== null && !Array.isArray(source[key]) && !Array.isArray(target[key]) ) { const nestedChanged = mergeObjects( target[key], source[key], fullPath, ); if (nestedChanged) changed = true; } }); return changed; }; hasChanges = mergeObjects(updatedSettings, currentDefaults); // Update database if there were changes if (hasChanges) { this.logger.debug('Updating default settings with missing fields'); await this.repository.update(existingSettings.id, { settings: updatedSettings, }); } } }); } } // Not sure if we'll ever need this // eslint-disable-next-line @typescript-eslint/no-unused-vars create(createDto: unknown): Promise { throw new Error('Method not implemented.'); } /** * Creates the default settings record. */ private createDefaultSettings() { this.repository .insert({ profile: SettingsConstants.DEFAULT_PROFILE_NAME, settings: SettingsConstants.DEFAULT_SETTINGS, }) .then((entity) => { this.logger.withMetadata(entity).debug('Default settings created'); }) .catch((err: Error) => { this.logger.withError(err).error('Unable to create default settings'); }); } /** * Emits settings. */ async emit() { super.emit({ event: SETTINGS_UPDATES, data: (await this.findAll()).map((entity) => entity.toDTO()), }); } /** * Gets the startup settings. */ public getStartupSettings() { return getStartupOptions(); } /** * Gets the default settings. */ public getDefaultSettings() { return this.repository.findOne({ where: (setting, { eq: equals }) => equals(setting.profile, SettingsConstants.DEFAULT_PROFILE_NAME), }); } /** * Updates app startup settings. */ public updateStartupSettings(startUpOptions: Partial) { if (startUpOptions.appDataPath) { // eslint-disable-next-line no-param-reassign startUpOptions.appDataPath = startUpOptions.appDataPath.trim(); } if (startUpOptions.port) { // eslint-disable-next-line no-param-reassign startUpOptions.port = startUpOptions.port.trim(); const port = parseInt(startUpOptions.port, 10); if (Number.isNaN(port) || port < 1024 || port > 65535) { throw new BadRequestException('Invalid port'); } } setStartupOptions({ ...startUpOptions }); } /** * Updates settings. * * @param {string} id * @param {UpdateSettingsDto} updateSettingsDto * @return {*} */ async update(id: EntityId, updateSettingsDto: UpdateSettingsDto) { this.logger .withMetadata(updateSettingsDto) .info(`Updating Settings '${id}'`); return this.repository.update(id, updateSettingsDto); } /** * Tests remote connection to a PostyBirb host. * * @param {string} hostUrl * @param {string} password * @return {Promise<{ success: boolean; message: string }> */ async testRemoteConnection( hostUrl: string, password: string, ): Promise<{ success: boolean; message: string }> { try { if (!hostUrl || !password) { return { success: false, message: 'Host URL and password are required', }; } // Clean up the URL const cleanUrl = hostUrl.trim().replace(/\/$/, ''); const testUrl = `${cleanUrl}/api/remote/ping/${encodeURIComponent(password)}`; this.logger.debug(`Testing remote connection to: ${cleanUrl}`); const response = await fetch(testUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', }, // Set a reasonable timeout signal: AbortSignal.timeout(10000), // 10 seconds }); if (response.ok) { const result = await response.json(); if (result === true) { return { success: true, message: 'Connection successful! Host is reachable and password is correct.', }; } } // Handle different HTTP status codes switch (response.status) { case 401: return { success: false, message: 'Authentication failed. Please check your password.', }; case 404: return { success: false, message: 'Host not found. Please check the URL.', }; case 500: return { success: false, message: 'Host server error. The remote host may not be configured properly.', }; default: return { success: false, message: `Connection failed with status ${response.status}`, }; } } catch (error) { this.logger.withError(error).error('Remote connection test failed'); if (error instanceof TypeError && error.message.includes('fetch')) { return { success: false, message: 'Network error. Please check the host URL and ensure the host is running.', }; } if (error.name === 'AbortError') { return { success: false, message: 'Connection timeout. The host may be unreachable.', }; } return { success: false, message: `Connection test failed: ${error.message}`, }; } } } ================================================ FILE: apps/client-server/src/app/submission/dtos/apply-multi-submission.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IApplyMultiSubmissionDto, SubmissionId } from '@postybirb/types'; import { IsArray, IsBoolean, IsNotEmpty, IsString } from 'class-validator'; export class ApplyMultiSubmissionDto implements IApplyMultiSubmissionDto { @ApiProperty() @IsString() @IsNotEmpty() submissionToApply: SubmissionId; @ApiProperty() @IsArray() @IsString({ each: true }) @IsNotEmpty() submissionIds: SubmissionId[]; @ApiProperty() @IsBoolean() merge: boolean; } ================================================ FILE: apps/client-server/src/app/submission/dtos/apply-template-options.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { SubmissionId } from '@postybirb/types'; import { Type } from 'class-transformer'; import { IsArray, IsBoolean, IsNotEmpty, IsString, ValidateNested, } from 'class-validator'; import { TemplateOptionDto } from './template-option.dto'; /** * DTO for applying selected template options to multiple submissions. */ export class ApplyTemplateOptionsDto { @ApiProperty({ description: 'Submission IDs to apply template options to' }) @IsArray() @IsString({ each: true }) @IsNotEmpty() targetSubmissionIds: SubmissionId[]; @ApiProperty({ description: 'Template options to apply', type: [TemplateOptionDto], }) @IsArray() @ValidateNested({ each: true }) @Type(() => TemplateOptionDto) options: TemplateOptionDto[]; @ApiProperty({ description: 'Whether to replace title with template title' }) @IsBoolean() overrideTitle: boolean; @ApiProperty({ description: 'Whether to replace description with template description', }) @IsBoolean() overrideDescription: boolean; } ================================================ FILE: apps/client-server/src/app/submission/dtos/create-submission.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ICreateSubmissionDefaultOptions, ICreateSubmissionDto, IFileMetadata, SubmissionType, } from '@postybirb/types'; import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, IsEnum, IsObject, IsOptional, IsString, } from 'class-validator'; /** * Helper to parse JSON strings from FormData. * Returns the parsed object or the original value if already an object. */ function parseJsonField(value: unknown): T | undefined { if (value === undefined || value === null || value === '') { return undefined; } if (typeof value === 'string') { try { return JSON.parse(value) as T; } catch { return undefined; } } return value as T; } export class CreateSubmissionDto implements ICreateSubmissionDto { @ApiProperty() @IsOptional() @IsString() name: string; @ApiProperty({ enum: SubmissionType }) @IsOptional() @IsEnum(SubmissionType) type: SubmissionType; @ApiProperty() @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isTemplate?: boolean; @ApiProperty() @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isMultiSubmission?: boolean; @ApiProperty({ description: 'Default options to apply to all created submissions' }) @IsOptional() @IsObject() @Transform(({ value }) => parseJsonField(value)) defaultOptions?: ICreateSubmissionDefaultOptions; @ApiProperty({ description: 'Per-file metadata for batch uploads' }) @IsOptional() @IsArray() @Transform(({ value }) => parseJsonField(value)) fileMetadata?: IFileMetadata[]; } ================================================ FILE: apps/client-server/src/app/submission/dtos/reorder-submission-files.dto.ts ================================================ import { IReorderSubmissionFilesDto } from '@postybirb/types'; import { IsObject } from 'class-validator'; export class ReorderSubmissionFilesDto implements IReorderSubmissionFilesDto { @IsObject() order: Record; } ================================================ FILE: apps/client-server/src/app/submission/dtos/reorder-submission.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { SubmissionId } from '@postybirb/types'; import { IsIn, IsString } from 'class-validator'; export class ReorderSubmissionDto { @ApiProperty({ description: 'The ID of the submission to move' }) @IsString() id: SubmissionId; @ApiProperty({ description: 'The ID of the target submission to position relative to', }) @IsString() targetId: SubmissionId; @ApiProperty({ description: 'Whether to place before or after the target', enum: ['before', 'after'], }) @IsIn(['before', 'after']) position: 'before' | 'after'; } ================================================ FILE: apps/client-server/src/app/submission/dtos/template-option.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { AccountId, IWebsiteFormFields } from '@postybirb/types'; import { IsNotEmpty, IsObject, IsString } from 'class-validator'; /** * Single template option to apply. */ export class TemplateOptionDto { @ApiProperty() @IsString() @IsNotEmpty() accountId: AccountId; @ApiProperty({ type: Object }) @IsObject() data: IWebsiteFormFields; } ================================================ FILE: apps/client-server/src/app/submission/dtos/update-alt-file.dto.ts ================================================ import { IUpdateAltFileDto } from '@postybirb/types'; import { IsString } from 'class-validator'; export class UpdateAltFileDto implements IUpdateAltFileDto { @IsString() text: string; } ================================================ FILE: apps/client-server/src/app/submission/dtos/update-submission-template-name.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IUpdateSubmissionTemplateNameDto } from '@postybirb/types'; import { IsString } from 'class-validator'; export class UpdateSubmissionTemplateNameDto implements IUpdateSubmissionTemplateNameDto { @ApiProperty() @IsString() name: string; } ================================================ FILE: apps/client-server/src/app/submission/dtos/update-submission.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ISubmissionMetadata, IUpdateSubmissionDto, IWebsiteFormFields, ScheduleType, WebsiteOptionsDto } from '@postybirb/types'; import { IsArray, IsBoolean, IsEnum, IsISO8601, IsObject, IsOptional, IsString, } from 'class-validator'; export class UpdateSubmissionDto implements IUpdateSubmissionDto { @ApiProperty() @IsOptional() @IsBoolean() isArchived?: boolean; @ApiProperty() @IsOptional() @IsBoolean() isScheduled?: boolean; @ApiProperty() @IsOptional() @IsString() @IsISO8601() scheduledFor?: string | null | undefined; @ApiProperty({ enum: ScheduleType }) @IsOptional() @IsEnum(ScheduleType) scheduleType?: ScheduleType; @ApiProperty() @IsOptional() @IsString() cron?: string | null | undefined; @ApiProperty() @IsOptional() @IsArray() deletedWebsiteOptions?: string[]; @ApiProperty() @IsOptional() @IsArray() newOrUpdatedOptions?: WebsiteOptionsDto[]; @ApiProperty() @IsOptional() @IsObject() metadata?: ISubmissionMetadata; } ================================================ FILE: apps/client-server/src/app/submission/file-submission.controller.ts ================================================ import { BadRequestException, Body, Controller, Delete, Get, Param, Patch, Post, UploadedFile, UploadedFiles, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { ApiBadRequestResponse, ApiConsumes, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { EntityId, SubmissionFileMetadata, SubmissionId, } from '@postybirb/types'; import { MulterFileInfo } from '../file/models/multer-file-info'; import { ReorderSubmissionFilesDto } from './dtos/reorder-submission-files.dto'; import { UpdateAltFileDto } from './dtos/update-alt-file.dto'; import { FileSubmissionService } from './services/file-submission.service'; import { SubmissionService } from './services/submission.service'; type Target = 'file' | 'thumbnail'; /** * Specific REST operations for File Submissions. * i.e. as thumbnail changes. * @class FileSubmissionController */ @ApiTags('file-submission') @Controller('file-submission') export class FileSubmissionController { constructor( private service: FileSubmissionService, private submissionService: SubmissionService, ) {} private findOne(id: SubmissionId) { return this.submissionService.findById(id).then((record) => record.toDTO()); } @Post('add/:target/:id') @ApiConsumes('multipart/form-data') @ApiOkResponse({ description: 'File appended.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) @UseInterceptors(FilesInterceptor('files', undefined, { preservePath: true })) async appendFile( @Param('target') target: Target, @Param('id') id: SubmissionId, @UploadedFiles() files: MulterFileInfo[], ) { switch (target) { case 'file': await Promise.all( files.map((file) => this.service.appendFile(id, file)), ); break; case 'thumbnail': default: throw new BadRequestException(`Unsupported add target '${target}'`); } return this.findOne(id); } @Post('replace/:target/:id/:fileId') @ApiConsumes('multipart/form-data') @ApiOkResponse({ description: 'File replaced.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) @UseInterceptors(FileInterceptor('file', { preservePath: true })) async replaceFile( @Param('target') target: Target, @Param('id') id: SubmissionId, @Param('fileId') fileId: EntityId, @UploadedFile() file: MulterFileInfo, ) { switch (target) { case 'file': await this.service.replaceFile(id, fileId, file); break; case 'thumbnail': await this.service.replaceThumbnail(id, fileId, file); break; default: throw new BadRequestException(`Unsupported replace target '${target}'`); } return this.findOne(id); } @Delete('remove/:target/:id/:fileId') @ApiOkResponse({ description: 'File removed.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) async removeFile( @Param('target') target: Target, @Param('id') id: SubmissionId, @Param('fileId') fileId: EntityId, ) { switch (target) { case 'file': await this.service.removeFile(id, fileId); break; case 'thumbnail': default: throw new BadRequestException(`Unsupported remove target '${target}'`); } return this.findOne(id); } @Get('alt/:id') @ApiOkResponse({ description: 'Alt File Text.' }) async getAltFileText(@Param('id') id: EntityId) { return this.service.getAltFileText(id); } @Patch('alt/:id') @ApiOkResponse({ description: 'Updated Alt File Text.' }) async updateAltFileText( @Param('id') id: EntityId, @Body() update: UpdateAltFileDto, ) { return this.service.updateAltFileText(id, update); } @Patch('metadata/:id') @ApiOkResponse({ description: 'Updated Metadata.' }) async updateMetadata( @Param('id') id: EntityId, @Body() update: SubmissionFileMetadata, ) { return this.service.updateMetadata(id, update); } @Patch('reorder') @ApiOkResponse({ description: 'Files reordered.' }) async reorderFiles(@Body() update: ReorderSubmissionFilesDto) { return this.service.reorderFiles(update); } } ================================================ FILE: apps/client-server/src/app/submission/services/file-submission.service.ts ================================================ import { BadRequestException, forwardRef, Inject, Injectable, } from '@nestjs/common'; import { EntityId, FileSubmission, FileType, isFileSubmission, ISubmission, SubmissionFileMetadata, SubmissionId, SubmissionType, } from '@postybirb/types'; import { getFileType } from '@postybirb/utils/file-type'; import { PostyBirbService } from '../../common/service/postybirb-service'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { FileService } from '../../file/file.service'; import { MulterFileInfo } from '../../file/models/multer-file-info'; import { CreateSubmissionDto } from '../dtos/create-submission.dto'; import { ReorderSubmissionFilesDto } from '../dtos/reorder-submission-files.dto'; import { UpdateAltFileDto } from '../dtos/update-alt-file.dto'; import { ISubmissionService } from './submission-service.interface'; import { SubmissionService } from './submission.service'; /** * Service that implements logic for manipulating a FileSubmission. * All actions perform mutations on the original object. * * @class FileSubmissionService * @implements {ISubmissionService} */ @Injectable() export class FileSubmissionService extends PostyBirbService<'SubmissionSchema'> implements ISubmissionService { constructor( private readonly fileService: FileService, @Inject(forwardRef(() => SubmissionService)) private readonly submissionService: SubmissionService, ) { super( new PostyBirbDatabase('SubmissionSchema', { files: true, }), ); } async populate( submission: FileSubmission, createSubmissionDto: CreateSubmissionDto, file: MulterFileInfo, ): Promise { // eslint-disable-next-line no-param-reassign submission.metadata = { ...submission.metadata, }; await this.appendFile(submission, file); } private guardIsFileSubmission(submission: ISubmission) { if (!isFileSubmission(submission)) { throw new BadRequestException( `Submission '${(submission as ISubmission).id}' is not a ${SubmissionType.FILE} submission.`, ); } if (submission.metadata.template) { throw new BadRequestException( `Submission '${submission.id}' is a template and cannot have files.`, ); } } /** * Guards against mixing different file types in the same submission. * For example, prevents adding an IMAGE file to a submission that already contains a TEXT (PDF) file. * * @param {FileSubmission} submission - The submission to check * @param {MulterFileInfo} file - The new file being added * @throws {BadRequestException} if file types are incompatible */ private guardFileTypeCompatibility( submission: FileSubmission, file: MulterFileInfo, ) { if (!submission.files || submission.files.length === 0) { return; // No existing files, any type is allowed } const newFileType = getFileType(file.originalname); const existingFileType = getFileType(submission.files[0].fileName); if (newFileType !== existingFileType) { const fileTypeLabels: Record = { [FileType.IMAGE]: 'IMAGE', [FileType.VIDEO]: 'VIDEO', [FileType.AUDIO]: 'AUDIO', [FileType.TEXT]: 'TEXT', [FileType.UNKNOWN]: 'UNKNOWN', }; throw new BadRequestException( `Cannot add ${fileTypeLabels[newFileType]} file to a submission containing ${fileTypeLabels[existingFileType]} files. All files in a submission must be of the same type.`, ); } } /** * Adds a file to a submission. * * @param {string} id * @param {MulterFileInfo} file */ async appendFile(id: EntityId | FileSubmission, file: MulterFileInfo) { const submission = ( typeof id === 'string' ? await this.repository.findById(id, { failOnMissing: true, }) : id ) as FileSubmission; this.guardIsFileSubmission(submission); this.guardFileTypeCompatibility(submission, file); const createdFile = await this.fileService.create(file, submission); this.logger .withMetadata(submission) .info(`Created file ${createdFile.id} = ${submission.id}`); await this.repository.update(submission.id, { metadata: submission.metadata, }); return submission; } async replaceFile(id: EntityId, fileId: EntityId, file: MulterFileInfo) { const submission = (await this.repository.findById( id, )) as unknown as FileSubmission; this.guardIsFileSubmission(submission); await this.fileService.update(file, fileId, false); } /** * Replaces a thumbnail file. * * @param {SubmissionId} id * @param {EntityId} fileId * @param {MulterFileInfo} file */ async replaceThumbnail( id: SubmissionId, fileId: EntityId, file: MulterFileInfo, ) { const submission = (await this.repository.findById( id, )) as unknown as FileSubmission; this.guardIsFileSubmission(submission); await this.fileService.update(file, fileId, true); } /** * Removes a file of thumbnail that matches file id. * * @param {SubmissionId} id * @param {EntityId} fileId */ async removeFile(id: SubmissionId, fileId: EntityId) { const submission = (await this.repository.findById( id, )) as unknown as FileSubmission; this.guardIsFileSubmission(submission); await this.fileService.remove(fileId); await this.repository.update(submission.id, { metadata: submission.metadata, }); } getAltFileText(id: EntityId) { return this.fileService.getAltText(id); } updateAltFileText(id: EntityId, update: UpdateAltFileDto) { return this.fileService.updateAltText(id, update); } updateMetadata(id: EntityId, update: SubmissionFileMetadata) { return this.fileService.updateMetadata(id, update); } reorderFiles(update: ReorderSubmissionFilesDto) { return this.fileService.reorderFiles(update); } } ================================================ FILE: apps/client-server/src/app/submission/services/message-submission.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { MessageSubmission } from '@postybirb/types'; import { CreateSubmissionDto } from '../dtos/create-submission.dto'; import { ISubmissionService } from './submission-service.interface'; @Injectable() export class MessageSubmissionService implements ISubmissionService { async populate( submission: MessageSubmission, createSubmissionDto: CreateSubmissionDto, ): Promise { // Do nothing for now } } ================================================ FILE: apps/client-server/src/app/submission/services/submission-service.interface.ts ================================================ import { ISubmission, SubmissionMetadataType } from '@postybirb/types'; import { MulterFileInfo } from '../../file/models/multer-file-info'; import { CreateSubmissionDto } from '../dtos/create-submission.dto'; export interface ISubmissionService< T extends ISubmission, > { populate( submission: T, createSubmissionDto: CreateSubmissionDto, file?: MulterFileInfo, ): Promise; } ================================================ FILE: apps/client-server/src/app/submission/services/submission.service.spec.ts ================================================ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { PostyBirbDirectories, writeSync } from '@postybirb/fs'; import { FileSubmissionMetadata, IWebsiteFormFields, ScheduleType, SubmissionRating, SubmissionType, WebsiteOptionsDto, } from '@postybirb/types'; import { readFileSync } from 'fs'; import { join } from 'path'; import { AccountModule } from '../../account/account.module'; import { AccountService } from '../../account/account.service'; import { CreateAccountDto } from '../../account/dtos/create-account.dto'; import { FileConverterService } from '../../file-converter/file-converter.service'; import { FileService } from '../../file/file.service'; import { MulterFileInfo } from '../../file/models/multer-file-info'; import { CreateFileService } from '../../file/services/create-file.service'; import { UpdateFileService } from '../../file/services/update-file.service'; import { FormGeneratorModule } from '../../form-generator/form-generator.module'; import { SharpInstanceManager } from '../../image-processing/sharp-instance-manager'; import { PostParsersModule } from '../../post-parsers/post-parsers.module'; import { UserSpecifiedWebsiteOptionsModule } from '../../user-specified-website-options/user-specified-website-options.module'; import { UserSpecifiedWebsiteOptionsService } from '../../user-specified-website-options/user-specified-website-options.service'; import { waitUntilPromised } from '../../utils/wait.util'; import { ValidationService } from '../../validation/validation.service'; import { WebsiteOptionsService } from '../../website-options/website-options.service'; import { WebsiteImplProvider } from '../../websites/implementations/provider'; import { WebsiteRegistryService } from '../../websites/website-registry.service'; import { WebsitesModule } from '../../websites/websites.module'; import { CreateSubmissionDto } from '../dtos/create-submission.dto'; import { UpdateSubmissionDto } from '../dtos/update-submission.dto'; import { FileSubmissionService } from './file-submission.service'; import { MessageSubmissionService } from './message-submission.service'; import { SubmissionService } from './submission.service'; describe('SubmissionService', () => { let testFile: Buffer | null = null; let service: SubmissionService; let websiteOptionsService: WebsiteOptionsService; let accountService: AccountService; let module: TestingModule; beforeAll(() => { testFile = readFileSync( join(__dirname, '../../../test-files/small_image.jpg'), ); }); beforeEach(async () => { clearDatabase(); try { module = await Test.createTestingModule({ imports: [ AccountModule, WebsitesModule, UserSpecifiedWebsiteOptionsModule, PostParsersModule, FormGeneratorModule, ], providers: [ SubmissionService, CreateFileService, UpdateFileService, SharpInstanceManager, FileService, FileSubmissionService, MessageSubmissionService, AccountService, WebsiteRegistryService, UserSpecifiedWebsiteOptionsService, ValidationService, WebsiteOptionsService, WebsiteImplProvider, FileConverterService, ], }).compile(); service = module.get(SubmissionService); websiteOptionsService = module.get( WebsiteOptionsService, ); accountService = module.get(AccountService); await accountService.onModuleInit(); } catch (e) { console.error(e); } }); afterAll(async () => { await module.close(); }); function setup(): string { const path = `${PostyBirbDirectories.DATA_DIRECTORY}/${Date.now()}.jpg`; writeSync(path, testFile); return path; } async function createAccount() { const dto = new CreateAccountDto(); dto.groups = ['test']; dto.name = 'test'; dto.website = 'test'; const record = await accountService.create(dto); return record; } function createSubmissionDto(): CreateSubmissionDto { const dto = new CreateSubmissionDto(); dto.name = 'Test'; dto.type = SubmissionType.MESSAGE; return dto; } function createMulterData(path: string): MulterFileInfo { return { fieldname: 'file', originalname: 'small_image.jpg', encoding: '', mimetype: 'image/jpeg', size: testFile.length, destination: '', filename: 'small_image.jpg', path, origin: undefined, }; } it('should be defined', () => { expect(service).toBeDefined(); }); it('should create message entities', async () => { const createDto = createSubmissionDto(); const record = await service.create(createDto); const records = await service.findAll(); expect(records).toHaveLength(1); expect(records[0].type).toEqual(createDto.type); expect(records[0].options).toHaveLength(1); expect(record.toDTO()).toEqual({ id: record.id, createdAt: record.createdAt, updatedAt: record.updatedAt, type: record.type, isScheduled: false, isTemplate: false, isArchived: false, isInitialized: true, isMultiSubmission: false, schedule: { scheduleType: ScheduleType.NONE, }, metadata: {}, files: [], order: 1, posts: [], postQueueRecord: undefined, options: [record.options[0].toDTO()], validations: [], }); }); it('should delete entity', async () => { const createDto = createSubmissionDto(); const record = await service.create(createDto); const records = await service.findAll(); expect(records).toHaveLength(1); expect(records[0].type).toEqual(createDto.type); await service.remove(record.id); expect(await service.findAll()).toHaveLength(0); }); it('should throw exception on message submission with provided file', async () => { const createDto = createSubmissionDto(); createDto.type = SubmissionType.MESSAGE; await expect( service.create(createDto, {} as MulterFileInfo), ).rejects.toThrow(BadRequestException); }); it('should create file entities', async () => { const createDto = createSubmissionDto(); delete createDto.name; // To ensure file name check createDto.type = SubmissionType.FILE; const path = setup(); const fileInfo = createMulterData(path); const record = await service.create(createDto, fileInfo); const defaultOptions = record.options[0]; const file = record.files[0]; const records = await service.findAll(); expect(records).toHaveLength(1); expect(records[0].type).toEqual(createDto.type); expect(records[0].options).toHaveLength(1); expect(records[0].files).toHaveLength(1); expect(record.toDTO()).toEqual({ id: record.id, createdAt: record.createdAt, updatedAt: record.updatedAt, type: record.type, isScheduled: false, postQueueRecord: undefined, isTemplate: false, isMultiSubmission: false, isArchived: false, isInitialized: true, schedule: { scheduleType: ScheduleType.NONE, }, metadata: {}, files: [ { createdAt: file.createdAt, primaryFileId: file.primaryFileId, fileName: fileInfo.originalname, hasThumbnail: true, hasCustomThumbnail: false, hasAltFile: false, hash: file.hash, height: 202, width: 138, id: file.id, mimeType: fileInfo.mimetype, size: testFile.length, submissionId: record.id, altFileId: null, thumbnailId: file.thumbnailId, updatedAt: file.updatedAt, metadata: { altText: '', dimensions: { default: { height: 202, width: 138, }, }, ignoredWebsites: [], sourceUrls: [], spoilerText: '', }, order: file.order, }, ], posts: [], order: 1, options: [defaultOptions.toObject()], validations: [], }); }); it('should throw on missing file on file submission', async () => { const createDto = createSubmissionDto(); createDto.type = SubmissionType.FILE; await expect(service.create(createDto)).rejects.toThrow( BadRequestException, ); }); it('should remove entities', async () => { const fileService = module.get(FileService); const optionsService = module.get( WebsiteOptionsService, ); const createDto = createSubmissionDto(); createDto.type = SubmissionType.FILE; const path = setup(); const fileInfo = createMulterData(path); const record = await service.create(createDto, fileInfo); const fileId = record.files[0].id; expect(await service.findAll()).toHaveLength(1); expect(await optionsService.findAll()).toHaveLength(1); expect(await fileService.findFile(fileId)).toBeDefined(); await service.remove(record.id); expect(await service.findAll()).toHaveLength(0); expect(await optionsService.findAll()).toHaveLength(0); await expect(fileService.findFile(fileId)).rejects.toThrow( NotFoundException, ); }); it('should update entity props', async () => { const createDto = createSubmissionDto(); const record = await service.create(createDto); const updateDto = new UpdateSubmissionDto(); updateDto.isScheduled = true; updateDto.scheduleType = ScheduleType.RECURRING; updateDto.scheduledFor = '*'; updateDto.metadata = { test: 'test', } as unknown; const updatedRecord = await service.update(record.id, updateDto); expect(updatedRecord.isScheduled).toEqual(updateDto.isScheduled); expect(updatedRecord.schedule.scheduleType).toEqual(updateDto.scheduleType); expect(updatedRecord.schedule.scheduledFor).toEqual(updateDto.scheduledFor); expect(updatedRecord.metadata).toEqual(updateDto.metadata); }); it('should remove entity options', async () => { const createDto = createSubmissionDto(); const record = await service.create(createDto); const updateDto = new UpdateSubmissionDto(); updateDto.deletedWebsiteOptions = [record.options[0].id]; const updatedRecord = await service.update(record.id, updateDto); expect(updatedRecord.options).toHaveLength(0); }); it('should update entity options', async () => { const createDto = createSubmissionDto(); const record = await service.create(createDto); const updateDto = new UpdateSubmissionDto(); updateDto.newOrUpdatedOptions = [ { ...record.options[0], data: { rating: SubmissionRating.GENERAL, title: 'Updated', }, } as unknown as WebsiteOptionsDto, ]; const updatedRecord = await service.update(record.id, updateDto); expect(updatedRecord.options[0].data.title).toEqual('Updated'); }); it('should serialize entity', async () => { const createDto = createSubmissionDto(); const record = await service.create(createDto); const serialized = JSON.stringify(record.toDTO()); expect(serialized).toBeDefined(); }); it('should reorder entities', async () => { const createDto = createSubmissionDto(); const record1 = await service.create(createDto); const record2 = await service.create(createDto); const record3 = await service.create(createDto); // Move record1 after record2 (from position 0 to after position 1) await service.reorder(record1.id, record2.id, 'after'); const records = (await service.findAll()).sort((a, b) => a.order - b.order); expect(records[0].id).toEqual(record2.id); expect(records[1].id).toEqual(record1.id); expect(records[2].id).toEqual(record3.id); }); it('should create multi submissions onModuleInit', async () => { service.onModuleInit(); await waitUntilPromised(async () => { const records = await service.findAll(); return records.length === 2; }, 50); const records = await service.findAll(); expect(records).toHaveLength(2); expect(records[0].isMultiSubmission).toBeTruthy(); expect(records[1].isMultiSubmission).toBeTruthy(); }); it('should apply template', async () => { const account = await createAccount(); const createDto = createSubmissionDto(); const record = await service.create(createDto); createDto.isTemplate = true; createDto.name = 'Template'; const template = await service.create(createDto); await websiteOptionsService.create({ submissionId: template.id, accountId: account.id, data: { title: 'Template Test', rating: SubmissionRating.MATURE, }, }); const updatedTemplate = await service.updateTemplateName(template.id, { name: 'Updated', }); expect(updatedTemplate.metadata.template.name).toEqual('Updated'); const updatedRecord = await service.applyOverridingTemplate( record.id, template.id, ); const defaultOptions = updatedRecord.options[0]; // The default title should not be updated expect(defaultOptions.data.title).not.toEqual('Template Test'); const nonDefault = updatedRecord.options.find((o) => !o.isDefault); expect(nonDefault).toBeDefined(); expect(nonDefault?.data.title).toEqual('Template Test'); expect(nonDefault?.data.rating).toEqual(SubmissionRating.MATURE); }); it('should apply multi submission merge', async () => { const account = await createAccount(); const createDto = createSubmissionDto(); const record = await service.create(createDto); createDto.isMultiSubmission = true; createDto.name = 'Multi'; const multi = await service.create(createDto); await websiteOptionsService.create({ submissionId: multi.id, accountId: account.id, data: { title: 'Multi Test', rating: SubmissionRating.MATURE, }, }); await service.applyMultiSubmission({ submissionToApply: multi.id, submissionIds: [record.id], merge: true, }); const updatedRecord = await service.findById(record.id); const defaultOptions = updatedRecord.options[0]; const multiDefaultOptions = multi.options.find((o) => o.isDefault); // The default title should not be updated expect(defaultOptions.data.title).not.toEqual( multiDefaultOptions?.data.title, ); const nonDefault = updatedRecord.options.find((o) => !o.isDefault); expect(nonDefault).toBeDefined(); expect(nonDefault?.data.title).toEqual('Multi Test'); expect(nonDefault?.data.rating).toEqual(SubmissionRating.MATURE); }); it('should apply multi submission without merge', async () => { const account = await createAccount(); const createDto = createSubmissionDto(); const record = await service.create(createDto); createDto.isMultiSubmission = true; createDto.name = 'Multi'; const multi = await service.create(createDto); await websiteOptionsService.create({ submissionId: multi.id, accountId: account.id, data: { title: 'Multi Test', rating: SubmissionRating.MATURE, }, }); await service.applyMultiSubmission({ submissionToApply: multi.id, submissionIds: [record.id], merge: false, }); const updatedRecord = await service.findById(record.id); const multiSubmission = await service.findById(multi.id); expect(updatedRecord.options).toHaveLength(multiSubmission.options.length); const defaultOptions = updatedRecord.options.find((o) => o.isDefault); const nonDefault = updatedRecord.options.find((o) => !o.isDefault); expect(nonDefault).toBeDefined(); expect(nonDefault.data).toEqual(multiSubmission.options[1].data); expect(defaultOptions).toBeDefined(); expect(defaultOptions.data).toEqual({ ...multiSubmission.options[0].data, title: defaultOptions.data.title, }); }); it('should duplicate submission', async () => { const account = await createAccount(); const createDto = createSubmissionDto(); createDto.type = SubmissionType.FILE; const path = setup(); const fileInfo = createMulterData(path); const record = await service.create(createDto, fileInfo); await websiteOptionsService.create({ submissionId: record.id, accountId: account.id, data: { title: 'Duplicate Test', rating: SubmissionRating.MATURE, }, }); await service.duplicate(record.id); const records = await service.findAll(); const duplicated = records.find((r) => r.id !== record.id); expect(duplicated).toBeDefined(); expect(duplicated?.type).toEqual(record.type); expect(duplicated?.options).toHaveLength(2); expect(duplicated?.files).toHaveLength(1); expect(duplicated.order).toEqual(record.order); // Check that the metadata references the new file IDs const duplicatedFileId = duplicated?.files[0].id; const duplicatedMetadata = duplicated?.metadata as FileSubmissionMetadata; expect(duplicatedFileId).toBeDefined(); for (const file of duplicated.files) { expect(record.files.find((f) => f.id === file.id)).toBeUndefined(); } // Check that the original metadata is preserved const originalMetadata = record?.metadata as FileSubmissionMetadata; for (const file of record.files) { expect(duplicated.files.find((f) => f.id === file.id)).toBeUndefined(); } }); }); ================================================ FILE: apps/client-server/src/app/submission/services/submission.service.ts ================================================ /* eslint-disable no-param-reassign */ import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException, OnModuleInit, Optional, } from '@nestjs/common'; import { FileBufferSchema, Insert, SubmissionFileSchema, SubmissionSchema, WebsiteOptionsSchema, } from '@postybirb/database'; import { SUBMISSION_UPDATES } from '@postybirb/socket-events'; import { FileSubmission, FileSubmissionMetadata, ISubmissionDto, ISubmissionMetadata, MessageSubmission, NULL_ACCOUNT_ID, ScheduleType, SubmissionId, SubmissionMetadataType, SubmissionType, } from '@postybirb/types'; import { IsTestEnvironment } from '@postybirb/utils/electron'; import { eq } from 'drizzle-orm'; import * as path from 'path'; import { PostyBirbService } from '../../common/service/postybirb-service'; import { FileBuffer, Submission, WebsiteOptions } from '../../drizzle/models'; import { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database'; import { withTransactionContext } from '../../drizzle/transaction-context'; import { MulterFileInfo } from '../../file/models/multer-file-info'; import { WSGateway } from '../../web-socket/web-socket-gateway'; import { WebsiteOptionsService } from '../../website-options/website-options.service'; import { ApplyMultiSubmissionDto } from '../dtos/apply-multi-submission.dto'; import { ApplyTemplateOptionsDto } from '../dtos/apply-template-options.dto'; import { CreateSubmissionDto } from '../dtos/create-submission.dto'; import { UpdateSubmissionTemplateNameDto } from '../dtos/update-submission-template-name.dto'; import { UpdateSubmissionDto } from '../dtos/update-submission.dto'; import { FileSubmissionService } from './file-submission.service'; import { MessageSubmissionService } from './message-submission.service'; type SubmissionEntity = Submission; /** * Service that handles the vast majority of submission management logic. * @class SubmissionService */ @Injectable() export class SubmissionService extends PostyBirbService<'SubmissionSchema'> implements OnModuleInit { private emitDebounceTimer: ReturnType | null = null; constructor( @Inject(forwardRef(() => WebsiteOptionsService)) private readonly websiteOptionsService: WebsiteOptionsService, @Inject(forwardRef(() => FileSubmissionService)) private readonly fileSubmissionService: FileSubmissionService, private readonly messageSubmissionService: MessageSubmissionService, @Optional() webSocket: WSGateway, ) { super( new PostyBirbDatabase('SubmissionSchema', { options: { with: { account: true, }, }, posts: { with: { events: { account: true, }, }, }, postQueueRecord: true, files: true, }), webSocket, ); this.repository.subscribe( [ 'PostRecordSchema', 'PostQueueRecordSchema', 'SubmissionFileSchema', 'FileBufferSchema', ], () => { this.emit(); }, ); this.repository.subscribe(['WebsiteOptionsSchema'], (_, action) => { if (action === 'delete') { this.emit(); } }); } async onModuleInit() { await this.cleanupUninitializedSubmissions(); await this.normalizeOrders(); for (const type of Object.values(SubmissionType)) { // eslint-disable-next-line no-await-in-loop await this.populateMultiSubmission(type); } } /** * Normalizes order values to sequential integers on startup. * This cleans up fractional values that accumulate from reordering operations. */ private async normalizeOrders() { this.logger.info('Normalizing submission orders'); for (const type of [SubmissionType.FILE, SubmissionType.MESSAGE]) { const submissions = (await this.repository.findAll()) .filter((s) => s.type === type && !s.isTemplate && !s.isMultiSubmission) .sort((a, b) => a.order - b.order); for (let i = 0; i < submissions.length; i++) { if (submissions[i].order !== i) { // eslint-disable-next-line no-await-in-loop await this.repository.update(submissions[i].id, { order: i }); } } } this.logger.info('Order normalization complete'); } /** * Cleans up any submissions that were left in an uninitialized state * (e.g., from a crash during creation). */ private async cleanupUninitializedSubmissions() { const all = await super.findAll(); const uninitialized = all.filter((s) => !s.isInitialized); if (uninitialized.length > 0) { const ids = uninitialized.map((s) => s.id); this.logger .withMetadata({ submissionIds: ids }) .info( `Cleaning up ${uninitialized.length} uninitialized submission(s) from previous session`, ); await this.repository.deleteById(ids); } } /** * Emits submissions onto websocket. * Debounced by 50ms to avoid rapid consecutive emits. * Overrides base class emit to provide submission-specific behavior. */ public async emit() { if (IsTestEnvironment()) { return; } if (this.emitDebounceTimer) { clearTimeout(this.emitDebounceTimer); } this.emitDebounceTimer = setTimeout(() => { this.emitDebounceTimer = null; this.performEmit(); }, 50); } private async performEmit() { const now = Date.now(); super.emit({ event: SUBMISSION_UPDATES, data: await this.findAllAsDto(), }); this.logger.info(`Emitted submission updates in ${Date.now() - now}ms`); } public async findAllAsDto(): Promise[]> { const all = (await super.findAll()).filter((s) => s.isInitialized); // Separate archived from non-archived for efficient processing const archived = all.filter((s) => s.isArchived); const nonArchived = all.filter((s) => !s.isArchived); // Validate non-archived submissions in parallel batches to avoid overwhelming the system const BATCH_SIZE = 10; const validatedNonArchived: ISubmissionDto[] = []; for (let i = 0; i < nonArchived.length; i += BATCH_SIZE) { const batch = nonArchived.slice(i, i + BATCH_SIZE); const batchResults = await Promise.all( batch.map( async (s) => ({ ...s.toDTO(), validations: await this.websiteOptionsService.validateSubmission(s), }) as ISubmissionDto, ), ); validatedNonArchived.push(...batchResults); } // Archived submissions don't need validation const archivedDtos = archived.map( (s) => ({ ...s.toDTO(), validations: [], }) as ISubmissionDto, ); return [...validatedNonArchived, ...archivedDtos]; } /** * Returns all initialized submissions. * Overrides base class to filter out submissions still being created. */ public async findAll() { const all = await super.findAll(); return all.filter((s) => s.isInitialized); } private async populateMultiSubmission(type: SubmissionType) { const existing = await this.repository.findOne({ where: (submission, { eq: equals, and }) => and( eq(submission.type, type), equals(submission.isMultiSubmission, true), ), }); if (existing) { return; } await this.create({ name: type, type, isMultiSubmission: true }); } /** * Creates a submission. * * @param {CreateSubmissionDto} createSubmissionDto * @param {MulterFileInfo} [file] * @return {*} {Promise>} */ async create( createSubmissionDto: CreateSubmissionDto, file?: MulterFileInfo, ): Promise { this.logger.withMetadata(createSubmissionDto).info('Creating Submission'); // Templates and multi-submissions are immediately initialized since they don't need file population const isImmediatelyInitialized = !!createSubmissionDto.isMultiSubmission || !!createSubmissionDto.isTemplate; let submission = new Submission({ isScheduled: false, isMultiSubmission: !!createSubmissionDto.isMultiSubmission, isTemplate: !!createSubmissionDto.isTemplate, isInitialized: isImmediatelyInitialized, ...createSubmissionDto, schedule: { scheduledFor: undefined, scheduleType: ScheduleType.NONE, cron: undefined, }, metadata: { template: createSubmissionDto.isTemplate ? { name: createSubmissionDto.name.trim() } : undefined, }, order: (await this.repository.count()) + 1, }); submission = await this.repository.insert(submission); // Determine the submission name/title let name = 'New submission'; if (createSubmissionDto.name) { name = createSubmissionDto.name; } else if (file) { // Check for per-file title override from fileMetadata const fileMetadata = createSubmissionDto.fileMetadata?.find( (meta) => meta.filename === file.originalname, ); if (fileMetadata?.title) { name = fileMetadata.title; } else { name = path.parse(file.filename).name; } } // Convert defaultOptions from DTO format to IWebsiteFormFields format const defaultOptions = createSubmissionDto.defaultOptions ? { tags: createSubmissionDto.defaultOptions.tags ? { overrideDefault: false, tags: createSubmissionDto.defaultOptions.tags, } : undefined, description: createSubmissionDto.defaultOptions.description, rating: createSubmissionDto.defaultOptions.rating, } : undefined; try { await this.websiteOptionsService.createDefaultSubmissionOptions( submission, name, defaultOptions, ); switch (createSubmissionDto.type) { case SubmissionType.MESSAGE: { if (file) { throw new BadRequestException( 'A file was provided for SubmissionType Message.', ); } await this.messageSubmissionService.populate( submission as unknown as MessageSubmission, createSubmissionDto, ); break; } case SubmissionType.FILE: { if ( createSubmissionDto.isTemplate || createSubmissionDto.isMultiSubmission ) { // Don't need to populate on a template break; } if (!file) { throw new BadRequestException( 'No file provided for SubmissionType FILE.', ); } // This currently mutates the submission object metadata await this.fileSubmissionService.populate( submission as unknown as FileSubmission, createSubmissionDto, file, ); break; } default: { throw new BadRequestException( `Unknown SubmissionType: ${createSubmissionDto.type}.`, ); } } // Re-save to capture any mutations during population and mark as initialized await this.repository.update(submission.id, { ...submission.toObject(), isInitialized: true, }); this.emit(); return await this.findById(submission.id); } catch (err) { // Clean up on error, tx is too much work this.logger.error(err, 'Error creating submission'); await this.repository.deleteById([submission.id]); throw err; } } /** * Applies a template to a submission. * Primarily used when a submission is created from a template. * Or when applying overriding multi-submission options. * * @param {SubmissionId} id * @param {SubmissionId} templateId */ async applyOverridingTemplate(id: SubmissionId, templateId: SubmissionId) { this.logger .withMetadata({ id, templateId }) .info('Applying template to submission'); const submission = await this.findById(id, { failOnMissing: true }); const template: Submission = await this.findById(templateId, { failOnMissing: true, }); if (!template.metadata.template) { throw new BadRequestException('Template Id provided is not a template.'); } const defaultOption: WebsiteOptions = submission.options.find( (option: WebsiteOptions) => option.accountId === NULL_ACCOUNT_ID, ); const defaultTitle = defaultOption?.data?.title; // Prepare all option insertions before the transaction const newOptionInsertions: Insert<'WebsiteOptionsSchema'>[] = await Promise.all( template.options.map((option) => this.websiteOptionsService.createOptionInsertObject( submission, option.accountId, option.data, (option.isDefault ? defaultTitle : option?.data?.title) ?? '', ), ), ); await withTransactionContext(this.repository.db, async (ctx) => { // clear all existing options await ctx .getDb() .delete(WebsiteOptionsSchema) .where(eq(WebsiteOptionsSchema.submissionId, id)); await ctx .getDb() .insert(WebsiteOptionsSchema) .values(newOptionInsertions); // Track all created options for cleanup if needed newOptionInsertions.forEach((option) => { if (option.id) { ctx.track('WebsiteOptionsSchema', option.id); } }); }); try { return await this.findById(id); } catch (err) { throw new BadRequestException(err); } } /** * Updates a submission. * * @param {SubmissionId} id * @param {UpdateSubmissionDto} update */ async update(id: SubmissionId, update: UpdateSubmissionDto) { this.logger.withMetadata(update).info(`Updating Submission '${id}'`); const submission = await this.findById(id, { failOnMissing: true }); const scheduleType = update.scheduleType ?? submission.schedule.scheduleType; const updates: Pick< SubmissionEntity, 'metadata' | 'isArchived' | 'isScheduled' | 'schedule' > = { metadata: { ...submission.metadata, ...(update.metadata ?? {}), }, isArchived: update.isArchived ?? submission.isArchived, isScheduled: scheduleType === ScheduleType.NONE ? false : (update.isScheduled ?? submission.isScheduled), schedule: scheduleType === ScheduleType.NONE ? { scheduleType: ScheduleType.NONE, scheduledFor: undefined, cron: undefined, } : { scheduledFor: scheduleType === ScheduleType.SINGLE && update.scheduledFor ? new Date(update.scheduledFor).toISOString() : (update.scheduledFor ?? submission.schedule.scheduledFor), scheduleType: update.scheduleType ?? submission.schedule.scheduleType, cron: update.cron ?? submission.schedule.cron, }, }; const optionChanges: Promise[] = []; // Removes unused website options if (update.deletedWebsiteOptions?.length) { update.deletedWebsiteOptions.forEach((deletedOptionId) => { optionChanges.push(this.websiteOptionsService.remove(deletedOptionId)); }); } // Creates or updates new website options if (update.newOrUpdatedOptions?.length) { update.newOrUpdatedOptions.forEach((option) => { if (option.createdAt) { optionChanges.push( this.websiteOptionsService.update(option.id, { data: option.data, }), ); } else { optionChanges.push( this.websiteOptionsService.create({ accountId: option.accountId, data: option.data, submissionId: submission.id, }), ); } }); } await Promise.allSettled(optionChanges); try { // Update Here await this.repository.update(id, updates); this.emit(); return await this.findById(id); } catch (err) { throw new BadRequestException(err); } } public async remove(id: SubmissionId) { const result = await super.remove(id); this.emit(); return result; } async applyMultiSubmission(applyMultiSubmissionDto: ApplyMultiSubmissionDto) { const { submissionToApply, submissionIds, merge } = applyMultiSubmissionDto; const origin = await this.repository.findById(submissionToApply, { failOnMissing: true, }); const submissions = await this.repository.find({ where: (submission, { inArray }) => inArray(submission.id, submissionIds), }); if (merge) { // Keeps unique options, overwrites overlapping options for (const submission of submissions) { for (const option of origin.options) { const existingOption = submission.options.find( (o) => o.accountId === option.accountId, ); if (existingOption) { // Don't overwrite set title const opts = { ...option.data, title: existingOption.data.title }; await this.websiteOptionsService.update(existingOption.id, { data: opts, }); } else { await this.websiteOptionsService.createOption( submission, option.accountId, option.data, option.isDefault ? undefined : option.data.title, ); } } } } else { // Removes all options not included in the origin submission for (const submission of submissions) { const { options } = submission; const defaultOptions = options.find((option) => option.isDefault); const defaultTitle = defaultOptions?.data.title; await Promise.all( options.map((option) => this.websiteOptionsService.remove(option.id)), ); // eslint-disable-next-line no-restricted-syntax for (const option of origin.options) { const opts = { ...option.data }; if (option.isDefault) { opts.title = defaultTitle; } await this.websiteOptionsService.createOption( submission, option.accountId, opts, option.isDefault ? defaultTitle : option.data.title, ); } } } this.emit(); } /** * Applies selected template options to multiple submissions. * Upserts options (update if exists, create if new) with merge behavior. * * @param dto - The apply template options DTO * @returns Object with success/failure counts */ async applyTemplateOptions(dto: ApplyTemplateOptionsDto): Promise<{ success: number; failed: number; errors: Array<{ submissionId: SubmissionId; error: string }>; }> { const { targetSubmissionIds, options, overrideTitle, overrideDescription } = dto; this.logger .withMetadata({ targetCount: targetSubmissionIds.length, optionCount: options.length, }) .info('Applying template options to submissions'); const results = { success: 0, failed: 0, errors: [] as Array<{ submissionId: SubmissionId; error: string }>, }; for (const submissionId of targetSubmissionIds) { try { const submission = await this.findById(submissionId, { failOnMissing: true, }); for (const templateOption of options) { // Find existing option for this account const existingOption = submission.options.find( (o) => o.accountId === templateOption.accountId, ); // Prepare the data to apply const dataToApply = { ...templateOption.data }; // Handle title override: only replace if overrideTitle is true AND template has non-empty title if (!overrideTitle || !dataToApply.title?.trim()) { delete dataToApply.title; } // Handle description override: only replace if overrideDescription is true AND template has non-empty description if ( !overrideDescription || !dataToApply.description?.description?.content?.length ) { delete dataToApply.description; } if (existingOption) { // Upsert: merge existing data with template data const mergedData = { ...existingOption.data, ...dataToApply, }; await this.websiteOptionsService.update(existingOption.id, { data: mergedData, }); } else { // Create new option - only pass title if it exists in dataToApply await this.websiteOptionsService.createOption( submission, templateOption.accountId, dataToApply, 'title' in dataToApply ? dataToApply.title : undefined, ); } } results.success++; } catch (error) { results.failed++; results.errors.push({ submissionId, error: error instanceof Error ? error.message : String(error), }); this.logger .withMetadata({ submissionId, error }) .error('Failed to apply template options to submission'); } } this.emit(); return results; } /** * Duplicates a submission. * @param {string} id */ public async duplicate(id: SubmissionId) { this.logger.info(`Duplicating Submission '${id}'`); const entityToDuplicate = await this.repository.findOne({ where: (submission, { eq: equals }) => equals(submission.id, id), with: { options: { with: { account: true, }, }, files: true, }, }); await withTransactionContext(this.repository.db, async (ctx) => { const newSubmission = ( await ctx .getDb() .insert(SubmissionSchema) .values({ metadata: entityToDuplicate.metadata, type: entityToDuplicate.type, isScheduled: entityToDuplicate.isScheduled, schedule: entityToDuplicate.schedule, isMultiSubmission: entityToDuplicate.isMultiSubmission, isTemplate: entityToDuplicate.isTemplate, isInitialized: false, // Will be set to true at the end of the transaction order: entityToDuplicate.order, }) .returning() )[0]; ctx.track('SubmissionSchema', newSubmission.id); const optionValues = entityToDuplicate.options.map((option) => ({ ...option, id: undefined, submissionId: newSubmission.id, })); await ctx.getDb().insert(WebsiteOptionsSchema).values(optionValues); for (const file of entityToDuplicate.files) { await file.load(); const newFile = ( await ctx .getDb() .insert(SubmissionFileSchema) .values({ submissionId: newSubmission.id, fileName: file.fileName, mimeType: file.mimeType, hash: file.hash, size: file.size, height: file.height, width: file.width, hasThumbnail: file.hasThumbnail, hasCustomThumbnail: file.hasCustomThumbnail, hasAltFile: file.hasAltFile, metadata: file.metadata, order: file.order, }) .returning() )[0]; ctx.track('SubmissionFileSchema', newFile.id); const primaryFile = ( await ctx .getDb() .insert(FileBufferSchema) .values({ ...file.file, id: undefined, submissionFileId: newFile.id, }) .returning() )[0]; ctx.track('FileBufferSchema', primaryFile.id); const thumbnail: FileBuffer | undefined = file.thumbnail ? ( await ctx .getDb() .insert(FileBufferSchema) .values({ ...file.thumbnail, id: undefined, submissionFileId: newFile.id, }) .returning() )[0] : undefined; if (thumbnail) { ctx.track('FileBufferSchema', thumbnail.id); } const altFile: FileBuffer | undefined = file.altFile ? ( await ctx .getDb() .insert(FileBufferSchema) .values({ ...file.altFile, id: undefined, submissionFileId: newFile.id, }) .returning() )[0] : undefined; if (altFile) { ctx.track('FileBufferSchema', altFile.id); } await ctx .getDb() .update(SubmissionFileSchema) .set({ primaryFileId: primaryFile.id, thumbnailId: thumbnail?.id, altFileId: altFile?.id, }) .where(eq(SubmissionFileSchema.id, newFile.id)); const oldId = file.id; // eslint-disable-next-line prefer-destructuring const metadata: FileSubmissionMetadata = newSubmission.metadata as FileSubmissionMetadata; } // Save updated metadata and mark as initialized await ctx .getDb() .update(SubmissionSchema) .set({ metadata: newSubmission.metadata, isInitialized: true }) .where(eq(SubmissionSchema.id, newSubmission.id)); }); this.emit(); } async updateTemplateName( id: SubmissionId, updateSubmissionDto: UpdateSubmissionTemplateNameDto, ) { const entity = await this.findById(id, { failOnMissing: true }); if (!entity.isTemplate) { throw new BadRequestException(`Submission '${id}' is not a template`); } const name = updateSubmissionDto.name.trim(); if (!updateSubmissionDto.name) { throw new BadRequestException( 'Template name cannot be empty or whitespace', ); } if (entity.metadata.template) { entity.metadata.template.name = name; } const result = await this.repository.update(id, { metadata: entity.metadata, }); this.emit(); return result; } async reorder( id: SubmissionId, targetId: SubmissionId, position: 'before' | 'after', ) { const moving = await this.findById(id, { failOnMissing: true }); const target = await this.findById(targetId, { failOnMissing: true }); // Ensure same type (FILE or MESSAGE) if (moving.type !== target.type) { throw new BadRequestException( 'Cannot reorder across different submission types', ); } // Get all submissions of the same type, sorted by order // Exclude templates and multi-submissions from ordering const allOfType = (await this.repository.findAll()) .filter( (s) => s.type === moving.type && !s.isTemplate && !s.isMultiSubmission, ) .sort((a, b) => a.order - b.order); const targetIndex = allOfType.findIndex((s) => s.id === targetId); if (targetIndex === -1) { throw new NotFoundException(`Target submission '${targetId}' not found`); } let newOrder: number; if (position === 'before') { if (targetIndex === 0) { // Insert at the very beginning newOrder = target.order - 1; } else { // Insert between previous and target const prevOrder = allOfType[targetIndex - 1].order; newOrder = (prevOrder + target.order) / 2; } } else if (targetIndex === allOfType.length - 1) { // position === 'after', Insert at the very end newOrder = target.order + 1; } else { // position === 'after', Insert between target and next const nextOrder = allOfType[targetIndex + 1].order; newOrder = (target.order + nextOrder) / 2; } await this.repository.update(id, { order: newOrder }); this.emit(); } async unarchive(id: SubmissionId) { const submission = await this.findById(id, { failOnMissing: true }); if (!submission.isArchived) { throw new BadRequestException(`Submission '${id}' is not archived`); } await this.repository.update(id, { isArchived: false, }); this.emit(); } async archive(id: SubmissionId) { const submission = await this.findById(id, { failOnMissing: true }); if (submission.isArchived) { throw new BadRequestException(`Submission '${id}' is already archived`); } await this.repository.update(id, { isArchived: true, isScheduled: false, schedule: { scheduledFor: undefined, scheduleType: ScheduleType.NONE, cron: undefined, }, }); this.emit(); } } ================================================ FILE: apps/client-server/src/app/submission/submission.controller.ts ================================================ import { Body, Controller, Get, Param, Patch, Post, UploadedFiles, UseInterceptors, } from '@nestjs/common'; import { FilesInterceptor } from '@nestjs/platform-express'; import { ApiBadRequestResponse, ApiConsumes, ApiNotFoundResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { ISubmissionDto, SubmissionId, SubmissionType } from '@postybirb/types'; import { parse } from 'path'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { MulterFileInfo } from '../file/models/multer-file-info'; import { ApplyMultiSubmissionDto } from './dtos/apply-multi-submission.dto'; import { ApplyTemplateOptionsDto } from './dtos/apply-template-options.dto'; import { CreateSubmissionDto } from './dtos/create-submission.dto'; import { ReorderSubmissionDto } from './dtos/reorder-submission.dto'; import { UpdateSubmissionTemplateNameDto } from './dtos/update-submission-template-name.dto'; import { UpdateSubmissionDto } from './dtos/update-submission.dto'; import { SubmissionService } from './services/submission.service'; /** * CRUD operations on Submission data. * * @class SubmissionController */ @ApiTags('submissions') @Controller('submissions') export class SubmissionController extends PostyBirbController<'SubmissionSchema'> { constructor(readonly service: SubmissionService) { super(service); } @Get() async findAll(): Promise { return this.service.findAllAsDto(); } @Post() @ApiConsumes('multipart/form-data') @ApiOkResponse({ description: 'Submission created.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) @UseInterceptors(FilesInterceptor('files', undefined, { preservePath: true })) async create( @Body() createSubmissionDto: CreateSubmissionDto, @UploadedFiles() files: MulterFileInfo[], ) { const mapper = (res) => res.toDTO(); if ((files || []).length) { const results = []; // !NOTE: Currently this shouldn't be able to happen with the current UI, but may need to be addressed in the future. // Efforts have been made to prevent this from happening, with the removal of using entity.create({}) but it may still be possible. // There appears to be an issue where if trying to create many submissions in parallel // the database will attempt to create them all at once and fail on a race condition. // not sure if this is a database issue or a typeorm issue. for (const file of files) { const createFileSubmission = new CreateSubmissionDto(); Object.assign(createFileSubmission, createSubmissionDto); if (!createSubmissionDto.name) { createFileSubmission.name = parse(file.originalname).name; } createFileSubmission.type = SubmissionType.FILE; results.push(await this.service.create(createFileSubmission, file)); } return results.map(mapper); } return ( await Promise.all([await this.service.create(createSubmissionDto)]) ).map(mapper); } @Post('duplicate/:id') @ApiOkResponse({ description: 'Submission duplicated.' }) @ApiNotFoundResponse({ description: 'Submission Id not found.' }) async duplicate(@Param('id') id: SubmissionId) { this.service.duplicate(id); } @Post('unarchive/:id') @ApiOkResponse({ description: 'Submission unarchived.' }) @ApiNotFoundResponse({ description: 'Submission Id not found.' }) async unarchive(@Param('id') id: SubmissionId) { return this.service.unarchive(id); } @Post('archive/:id') @ApiOkResponse({ description: 'Submission archived.' }) @ApiNotFoundResponse({ description: 'Submission Id not found.' }) async archive(@Param('id') id: SubmissionId) { return this.service.archive(id); } // IMPORTANT: This route MUST be defined BEFORE @Patch(':id') to prevent // Express from matching 'reorder' as an :id parameter @Patch('reorder') @ApiOkResponse({ description: 'Submission reordered.' }) @ApiNotFoundResponse({ description: 'Submission Id not found.' }) async reorder(@Body() reorderDto: ReorderSubmissionDto) { return this.service.reorder( reorderDto.id, reorderDto.targetId, reorderDto.position, ); } @Patch(':id') @ApiOkResponse({ description: 'Submission updated.' }) @ApiNotFoundResponse({ description: 'Submission Id not found.' }) async update( @Param('id') id: SubmissionId, @Body() updateSubmissionDto: UpdateSubmissionDto, ) { return this.service .update(id, updateSubmissionDto) .then((entity) => entity.toDTO()); } @Patch('template/:id') @ApiOkResponse({ description: 'Submission updated.' }) @ApiNotFoundResponse({ description: 'Submission Id not found.' }) async updateTemplateName( @Param('id') id: SubmissionId, @Body() updateSubmissionDto: UpdateSubmissionTemplateNameDto, ) { return this.service .updateTemplateName(id, updateSubmissionDto) .then((entity) => entity.toDTO()); } @Patch('apply/multi') @ApiOkResponse({ description: 'Submission applied to multiple submissions.' }) @ApiNotFoundResponse({ description: 'Submission Id not found.' }) async applyMulti(@Body() applyMultiSubmissionDto: ApplyMultiSubmissionDto) { return this.service.applyMultiSubmission(applyMultiSubmissionDto); } @Patch('apply/template/options') @ApiOkResponse({ description: 'Template options applied to submissions.' }) @ApiBadRequestResponse({ description: 'Invalid request.' }) async applyTemplateOptions( @Body() applyTemplateOptionsDto: ApplyTemplateOptionsDto, ) { return this.service.applyTemplateOptions(applyTemplateOptionsDto); } @Patch('apply/template/:id/:templateId') @ApiOkResponse({ description: 'Template applied to submission.' }) @ApiNotFoundResponse({ description: 'Submission Id or Template Id not found.' }) async applyTemplate( @Param('id') id: SubmissionId, @Param('templateId') templateId: SubmissionId, ) { return this.service .applyOverridingTemplate(id, templateId) .then((entity) => entity.toDTO()); } } ================================================ FILE: apps/client-server/src/app/submission/submission.events.ts ================================================ import { SUBMISSION_UPDATES } from '@postybirb/socket-events'; import { ISubmissionDto, ISubmissionMetadata } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type SubmissionEventTypes = SubmissionUpdateEvent; class SubmissionUpdateEvent implements WebsocketEvent[]> { event: string = SUBMISSION_UPDATES; data: ISubmissionDto[]; } ================================================ FILE: apps/client-server/src/app/submission/submission.module.ts ================================================ import { forwardRef, Module } from '@nestjs/common'; import { MulterModule } from '@nestjs/platform-express'; import { PostyBirbDirectories } from '@postybirb/fs'; import { diskStorage } from 'multer'; import { extname } from 'path'; import { v4 } from 'uuid'; import { AccountModule } from '../account/account.module'; import { FileModule } from '../file/file.module'; import { WebsiteOptionsModule } from '../website-options/website-options.module'; import { WebsitesModule } from '../websites/websites.module'; import { FileSubmissionController } from './file-submission.controller'; import { FileSubmissionService } from './services/file-submission.service'; import { MessageSubmissionService } from './services/message-submission.service'; import { SubmissionService } from './services/submission.service'; import { SubmissionController } from './submission.controller'; @Module({ imports: [ WebsitesModule, AccountModule, FileModule, forwardRef(() => WebsiteOptionsModule), MulterModule.register({ limits: { fileSize: 3e8, // Max 300MB }, storage: diskStorage({ destination(req, file, cb) { cb(null, PostyBirbDirectories.TEMP_DIRECTORY); }, filename(req, file, cb) { cb(null, v4() + extname(file.originalname)); // Appending extension }, }), }), ], providers: [ SubmissionService, MessageSubmissionService, FileSubmissionService, ], controllers: [SubmissionController, FileSubmissionController], exports: [SubmissionService, FileSubmissionService], }) export class SubmissionModule {} ================================================ FILE: apps/client-server/src/app/tag-converters/dtos/create-tag-converter.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ICreateTagConverterDto } from '@postybirb/types'; import { IsObject, IsString } from 'class-validator'; export class CreateTagConverterDto implements ICreateTagConverterDto { @ApiProperty() @IsString() tag: string; @ApiProperty() @IsObject() convertTo: Record; } ================================================ FILE: apps/client-server/src/app/tag-converters/dtos/update-tag-converter.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IUpdateTagConverterDto } from '@postybirb/types'; import { IsObject, IsString } from 'class-validator'; export class UpdateTagConverterDto implements IUpdateTagConverterDto { @ApiProperty() @IsString() tag: string; @ApiProperty() @IsObject() convertTo: Record; } ================================================ FILE: apps/client-server/src/app/tag-converters/tag-converter.events.ts ================================================ import { TAG_CONVERTER_UPDATES } from '@postybirb/socket-events'; import { TagConverterDto } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type TagConverterEventTypes = TagConverterUpdateEvent; class TagConverterUpdateEvent implements WebsocketEvent { event: string = TAG_CONVERTER_UPDATES; data: TagConverterDto[]; } ================================================ FILE: apps/client-server/src/app/tag-converters/tag-converters.controller.ts ================================================ import { Body, Controller, Param, Patch, Post } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { EntityId } from '@postybirb/types'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { CreateTagConverterDto } from './dtos/create-tag-converter.dto'; import { UpdateTagConverterDto } from './dtos/update-tag-converter.dto'; import { TagConvertersService } from './tag-converters.service'; /** * CRUD operations on TagConverters * @class TagConvertersController */ @ApiTags('tag-converters') @Controller('tag-converters') export class TagConvertersController extends PostyBirbController<'TagConverterSchema'> { constructor(readonly service: TagConvertersService) { super(service); } @Post() @ApiOkResponse({ description: 'Tag converter created.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) create(@Body() createTagConverterDto: CreateTagConverterDto) { return this.service .create(createTagConverterDto) .then((entity) => entity.toDTO()); } @Patch(':id') @ApiOkResponse({ description: 'Tag converter updated.' }) @ApiNotFoundResponse({ description: 'Tag converter not found.' }) update(@Body() updateDto: UpdateTagConverterDto, @Param('id') id: EntityId) { return this.service.update(id, updateDto).then((entity) => entity.toDTO()); } } ================================================ FILE: apps/client-server/src/app/tag-converters/tag-converters.module.ts ================================================ import { Module } from '@nestjs/common'; import { TagConvertersController } from './tag-converters.controller'; import { TagConvertersService } from './tag-converters.service'; @Module({ controllers: [TagConvertersController], providers: [TagConvertersService], exports: [TagConvertersService], }) export class TagConvertersModule {} ================================================ FILE: apps/client-server/src/app/tag-converters/tag-converters.service.spec.ts ================================================ import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { CreateTagConverterDto } from './dtos/create-tag-converter.dto'; import { UpdateTagConverterDto } from './dtos/update-tag-converter.dto'; import { TagConvertersService } from './tag-converters.service'; describe('TagConvertersService', () => { let service: TagConvertersService; let module: TestingModule; function createTagConverterDto( tag: string, convertTo: Record, ) { const dto = new CreateTagConverterDto(); dto.tag = tag; dto.convertTo = convertTo; return dto; } beforeEach(async () => { clearDatabase(); try { module = await Test.createTestingModule({ imports: [], providers: [TagConvertersService], }).compile(); service = module.get(TagConvertersService); } catch (e) { console.log(e); } }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should create entities', async () => { const dto = createTagConverterDto('test', { default: 'converted' }); const record = await service.create(dto); const groups = await service.findAll(); expect(groups).toHaveLength(1); expect(groups[0].tag).toEqual(dto.tag); expect(groups[0].convertTo).toEqual(dto.convertTo); expect(record.toObject()).toEqual({ tag: dto.tag, convertTo: dto.convertTo, id: record.id, createdAt: record.createdAt, updatedAt: record.updatedAt, }); }); it('should fail to create duplicate tag converters', async () => { const dto = createTagConverterDto('test', { default: 'converted' }); const dto2 = createTagConverterDto('test', { default: 'converted' }); await service.create(dto); let expectedException = null; try { await service.create(dto2); } catch (err) { expectedException = err; } expect(expectedException).toBeInstanceOf(BadRequestException); }); it('should update entities', async () => { const dto = createTagConverterDto('test', { default: 'converted' }); const record = await service.create(dto); const groups = await service.findAll(); expect(groups).toHaveLength(1); const updateDto = new UpdateTagConverterDto(); updateDto.tag = 'test'; updateDto.convertTo = { default: 'converted', test: 'converted2' }; await service.update(record.id, updateDto); const updatedRec = await service.findById(record.id); expect(updatedRec.tag).toBe(updateDto.tag); expect(updatedRec.convertTo).toEqual(updateDto.convertTo); }); it('should delete entities', async () => { const dto = createTagConverterDto('test', { default: 'converted' }); const record = await service.create(dto); expect(await service.findAll()).toHaveLength(1); await service.remove(record.id); expect(await service.findAll()).toHaveLength(0); }); it('should convert tags', async () => { const dto = createTagConverterDto('test', { default: 'converted' }); await service.create(dto); const result = await service.convert( { decoratedProps: { metadata: { name: 'default' } } } as any, ['test', 'test2'], ); expect(result).toEqual(['converted', 'test2']); }); }); ================================================ FILE: apps/client-server/src/app/tag-converters/tag-converters.service.ts ================================================ import { Injectable, Optional } from '@nestjs/common'; import { TAG_CONVERTER_UPDATES } from '@postybirb/socket-events'; import { EntityId } from '@postybirb/types'; import { eq } from 'drizzle-orm'; import { PostyBirbService } from '../common/service/postybirb-service'; import { TagConverter } from '../drizzle/models'; import { WSGateway } from '../web-socket/web-socket-gateway'; import { Website } from '../websites/website'; import { CreateTagConverterDto } from './dtos/create-tag-converter.dto'; import { UpdateTagConverterDto } from './dtos/update-tag-converter.dto'; @Injectable() export class TagConvertersService extends PostyBirbService<'TagConverterSchema'> { constructor(@Optional() webSocket?: WSGateway) { super('TagConverterSchema', webSocket); this.repository.subscribe('TagConverterSchema', () => { this.emit(); }); } async create(createDto: CreateTagConverterDto): Promise { this.logger .withMetadata(createDto) .info(`Creating TagConverter '${createDto.tag}'`); await this.throwIfExists(eq(this.schema.tag, createDto.tag)); return this.repository.insert(createDto); } update(id: EntityId, update: UpdateTagConverterDto) { this.logger.withMetadata(update).info(`Updating TagConverter '${id}'`); return this.repository.update(id, update); } /** * Converts a list of tags using user defined conversion table. * * @param {Website} instance * @param {string[]} tags * @return {*} {Promise} */ async convert(instance: Website, tags: string): Promise; async convert(instance: Website, tags: string[]): Promise; async convert( instance: Website, tags: string[] | string, ): Promise { if (typeof tags === 'string') { return (await this.convert(instance, [tags]))[0]; } // { tag: { $in: tags } } const converters = await this.repository.find({ where: (converter, { inArray }) => inArray(converter.tag, tags), }); return tags.map((tag) => { const converter = converters.find((c) => c.tag === tag); if (!converter) { return tag; } return ( converter.convertTo[instance.decoratedProps.metadata.name] ?? converter.convertTo.default ?? // NOTE: This is not currently used, but it's here for future proofing tag ); }); } protected async emit() { super.emit({ event: TAG_CONVERTER_UPDATES, data: (await this.repository.findAll()).map((entity) => entity.toDTO()), }); } } ================================================ FILE: apps/client-server/src/app/tag-groups/dtos/create-tag-group.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ICreateTagGroupDto } from '@postybirb/types'; import { IsArray, IsString } from 'class-validator'; export class CreateTagGroupDto implements ICreateTagGroupDto { @ApiProperty() @IsString() name: string; @ApiProperty() @IsArray() tags: string[]; } ================================================ FILE: apps/client-server/src/app/tag-groups/dtos/update-tag-group.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IUpdateTagGroupDto } from '@postybirb/types'; import { IsArray, IsString } from 'class-validator'; export class UpdateTagGroupDto implements IUpdateTagGroupDto { @ApiProperty() @IsString() name: string; @ApiProperty() @IsArray() tags: string[]; } ================================================ FILE: apps/client-server/src/app/tag-groups/tag-group.events.ts ================================================ import { TAG_GROUP_UPDATES } from '@postybirb/socket-events'; import { TagGroupDto } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type TagGroupEventTypes = TagGroupUpdateEvent; class TagGroupUpdateEvent implements WebsocketEvent { event: string = TAG_GROUP_UPDATES; data: TagGroupDto[]; } ================================================ FILE: apps/client-server/src/app/tag-groups/tag-groups.controller.ts ================================================ import { Body, Controller, Param, Patch, Post } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { EntityId } from '@postybirb/types'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { CreateTagGroupDto } from './dtos/create-tag-group.dto'; import { UpdateTagGroupDto } from './dtos/update-tag-group.dto'; import { TagGroupsService } from './tag-groups.service'; /** * CRUD operations for TagGroups. * @class TagGroupsController */ @ApiTags('tag-groups') @Controller('tag-groups') export class TagGroupsController extends PostyBirbController<'TagGroupSchema'> { constructor(readonly service: TagGroupsService) { super(service); } @Post() @ApiOkResponse({ description: 'Tag group created.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) create(@Body() createDto: CreateTagGroupDto) { return this.service.create(createDto).then((entity) => entity.toDTO()); } @Patch(':id') @ApiOkResponse({ description: 'Tag group updated.', type: Boolean }) @ApiNotFoundResponse({ description: 'Tag group not found.' }) update(@Param('id') id: EntityId, @Body() updateDto: UpdateTagGroupDto) { return this.service.update(id, updateDto).then((entity) => entity.toDTO()); } } ================================================ FILE: apps/client-server/src/app/tag-groups/tag-groups.module.ts ================================================ import { Module } from '@nestjs/common'; import { TagGroupsController } from './tag-groups.controller'; import { TagGroupsService } from './tag-groups.service'; @Module({ providers: [TagGroupsService], controllers: [TagGroupsController], }) export class TagGroupsModule {} ================================================ FILE: apps/client-server/src/app/tag-groups/tag-groups.service.spec.ts ================================================ import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { CreateTagGroupDto } from './dtos/create-tag-group.dto'; import { TagGroupsService } from './tag-groups.service'; describe('TagGroupsService', () => { let service: TagGroupsService; let module: TestingModule; function createTagGroupDto(name: string, tags: string[]) { const dto = new CreateTagGroupDto(); dto.name = name; dto.tags = tags; return dto; } beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ providers: [TagGroupsService], }).compile(); service = module.get(TagGroupsService); }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should create entities', async () => { const dto = createTagGroupDto('test', ['test', 'tag group']); const record = await service.create(dto); const groups = await service.findAll(); expect(groups).toHaveLength(1); expect(groups[0].name).toEqual(dto.name); expect(groups[0].tags).toEqual(dto.tags); expect(record.toDTO()).toEqual({ name: dto.name, tags: dto.tags, id: record.id, createdAt: record.createdAt, updatedAt: record.updatedAt, }); }); it('should fail to create duplicate named groups', async () => { const dto = createTagGroupDto('test', ['test']); const dto2 = createTagGroupDto('test', ['test', 'test-dupe']); await service.create(dto); const groups = await service.findAll(); expect(groups).toHaveLength(1); expect(groups[0].name).toEqual(dto.name); expect(groups[0].tags).toEqual(dto.tags); let expectedException = null; try { await service.create(dto2); } catch (err) { expectedException = err; } expect(expectedException).toBeInstanceOf(BadRequestException); }); it('should update entities', async () => { const dto = createTagGroupDto('test', ['test', 'tag group']); const record = await service.create(dto); const groups = await service.findAll(); expect(groups).toHaveLength(1); const updateDto = new CreateTagGroupDto(); updateDto.name = 'test'; updateDto.tags = ['test', 'updated']; await service.update(record.id, updateDto); const updatedRec = await service.findById(record.id); expect(updatedRec.name).toBe(updateDto.name); expect(updatedRec.tags).toEqual(updateDto.tags); }); it('should delete entities', async () => { const dto = createTagGroupDto('test', ['test', 'tag group']); const record = await service.create(dto); expect(await service.findAll()).toHaveLength(1); await service.remove(record.id); expect(await service.findAll()).toHaveLength(0); }); }); ================================================ FILE: apps/client-server/src/app/tag-groups/tag-groups.service.ts ================================================ import { Injectable, Optional } from '@nestjs/common'; import { TAG_GROUP_UPDATES } from '@postybirb/socket-events'; import { EntityId } from '@postybirb/types'; import { eq } from 'drizzle-orm'; import { PostyBirbService } from '../common/service/postybirb-service'; import { TagGroup } from '../drizzle/models'; import { WSGateway } from '../web-socket/web-socket-gateway'; import { CreateTagGroupDto } from './dtos/create-tag-group.dto'; import { UpdateTagGroupDto } from './dtos/update-tag-group.dto'; @Injectable() export class TagGroupsService extends PostyBirbService<'TagGroupSchema'> { constructor(@Optional() webSocket?: WSGateway) { super('TagGroupSchema', webSocket); this.repository.subscribe('TagGroupSchema', () => this.emit()); } async create(createDto: CreateTagGroupDto): Promise { this.logger .withMetadata(createDto) .info(`Creating TagGroup '${createDto.name}'`); await this.throwIfExists(eq(this.schema.name, createDto.name)); return this.repository.insert(createDto); } update(id: EntityId, update: UpdateTagGroupDto) { this.logger.withMetadata(update).info(`Updating TagGroup '${id}'`); return this.repository.update(id, update); } protected async emit() { super.emit({ event: TAG_GROUP_UPDATES, data: (await this.repository.findAll()).map((entity) => entity.toDTO()), }); } } ================================================ FILE: apps/client-server/src/app/update/update.controller.ts ================================================ import { Controller, Get, Post } from '@nestjs/common'; import { UpdateState } from '@postybirb/types'; import { UpdateService } from './update.service'; @Controller('update') export class UpdateController { constructor(private readonly service: UpdateService) {} /** * Checks for updates. * @returns For some reason, to fix error while * build we need to directly import and specify type */ @Get() checkForUpdates(): UpdateState { return this.service.getUpdateState(); } @Post('start') update() { return this.service.update(); } /** * Quit and install the downloaded update. */ @Post('install') install() { return this.service.install(); } } ================================================ FILE: apps/client-server/src/app/update/update.events.ts ================================================ import { UPDATE_UPDATES } from '@postybirb/socket-events'; import { UpdateState } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type UpdateEventTypes = UpdateUpdateEvent; class UpdateUpdateEvent implements WebsocketEvent { event: string = UPDATE_UPDATES; data: UpdateState; } ================================================ FILE: apps/client-server/src/app/update/update.module.ts ================================================ import { Module } from '@nestjs/common'; import { WebSocketModule } from '../web-socket/web-socket.module'; import { UpdateController } from './update.controller'; import { UpdateService } from './update.service'; @Module({ imports: [WebSocketModule], providers: [UpdateService], controllers: [UpdateController], }) export class UpdateModule {} ================================================ FILE: apps/client-server/src/app/update/update.service.ts ================================================ import { Injectable, Optional } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { UPDATE_UPDATES } from '@postybirb/socket-events'; import { ReleaseNoteInfo, UpdateState } from '@postybirb/types'; import { ProgressInfo, UpdateInfo, autoUpdater } from 'electron-updater'; import isDocker from 'is-docker'; import { WSGateway } from '../web-socket/web-socket-gateway'; /** * Handles updates for the application. * * @class UpdateService */ @Injectable() export class UpdateService { private readonly logger = Logger('Updates'); private updateState: UpdateState = { updateAvailable: false, updateDownloaded: false, updateDownloading: false, updateError: undefined, updateProgress: undefined, updateNotes: undefined, }; constructor(@Optional() private readonly webSocket?: WSGateway) { autoUpdater.logger = this.logger; autoUpdater.autoDownload = false; autoUpdater.fullChangelog = true; autoUpdater.allowPrerelease = true; this.registerListeners(); if (!isDocker()) setTimeout(() => this.checkForUpdates(), 5_000); } /** * Emit update state changes via WebSocket. */ private emit() { if (this.webSocket) { this.webSocket.emit({ event: UPDATE_UPDATES, data: this.getUpdateState(), }); } } private registerListeners() { autoUpdater.on('update-available', (update) => { this.onUpdateAvailable(update); }); autoUpdater.on('download-progress', (progress) => { this.onDownloadProgress(progress); }); autoUpdater.on('error', (error) => { this.onUpdateError(error); }); autoUpdater.on('update-downloaded', () => { this.onUpdateDownloaded(); }); } private onUpdateDownloaded() { this.updateState = { ...this.updateState, updateDownloaded: true, updateDownloading: false, updateProgress: 100, }; this.emit(); } private onUpdateAvailable(update: UpdateInfo) { this.updateState = { ...this.updateState, updateAvailable: true, updateNotes: (update.releaseNotes as ReleaseNoteInfo[]) ?? [], }; this.emit(); } private onUpdateError(error: Error) { this.logger.withError(error).error(); this.updateState = { ...this.updateState, updateError: error.message, updateDownloading: false, }; this.emit(); } private onDownloadProgress(progress: ProgressInfo) { this.updateState = { ...this.updateState, updateProgress: progress.percent, }; this.emit(); } public checkForUpdates() { if ( this.updateState.updateDownloading || this.updateState.updateDownloaded ) { return; } autoUpdater.checkForUpdates(); } public getUpdateState() { return { ...this.updateState }; } public update() { if ( !this.updateState.updateAvailable || this.updateState.updateDownloaded || this.updateState.updateDownloading ) { return; } this.updateState = { ...this.updateState, updateDownloading: true, }; this.emit(); autoUpdater.downloadUpdate(); } /** * Quit the application and install the downloaded update. * Only works if an update has been downloaded. */ public install() { if (!this.updateState.updateDownloaded) { return; } autoUpdater.quitAndInstall(false, true); } } ================================================ FILE: apps/client-server/src/app/user-converters/dtos/create-user-converter.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ICreateUserConverterDto } from '@postybirb/types'; import { IsObject, IsString } from 'class-validator'; export class CreateUserConverterDto implements ICreateUserConverterDto { @ApiProperty() @IsString() username: string; @ApiProperty() @IsObject() convertTo: Record; } ================================================ FILE: apps/client-server/src/app/user-converters/dtos/update-user-converter.dto.ts ================================================ import { ApiProperty, PartialType } from '@nestjs/swagger'; import { IUpdateUserConverterDto } from '@postybirb/types'; import { CreateUserConverterDto } from './create-user-converter.dto'; export class UpdateUserConverterDto extends PartialType(CreateUserConverterDto) implements IUpdateUserConverterDto { @ApiProperty() username?: string; @ApiProperty() convertTo?: Record; } ================================================ FILE: apps/client-server/src/app/user-converters/user-converter.events.ts ================================================ import { USER_CONVERTER_UPDATES } from '@postybirb/socket-events'; import { UserConverterDto } from '@postybirb/types'; import { WebsocketEvent } from '../web-socket/models/web-socket-event'; export type UserConverterEventTypes = UserConverterUpdateEvent; class UserConverterUpdateEvent implements WebsocketEvent { event: string = USER_CONVERTER_UPDATES; data: UserConverterDto[]; } ================================================ FILE: apps/client-server/src/app/user-converters/user-converters.controller.ts ================================================ import { Body, Controller, Param, Patch, Post } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { EntityId } from '@postybirb/types'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { CreateUserConverterDto } from './dtos/create-user-converter.dto'; import { UpdateUserConverterDto } from './dtos/update-user-converter.dto'; import { UserConvertersService } from './user-converters.service'; /** * CRUD operations on UserConverters * @class UserConvertersController */ @ApiTags('user-converters') @Controller('user-converters') export class UserConvertersController extends PostyBirbController<'UserConverterSchema'> { constructor(readonly service: UserConvertersService) { super(service); } @Post() @ApiOkResponse({ description: 'User converter created.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) create(@Body() createUserConverterDto: CreateUserConverterDto) { return this.service .create(createUserConverterDto) .then((entity) => entity.toDTO()); } @Patch(':id') @ApiOkResponse({ description: 'User converter updated.' }) @ApiNotFoundResponse({ description: 'User converter not found.' }) update(@Body() updateDto: UpdateUserConverterDto, @Param('id') id: EntityId) { return this.service.update(id, updateDto).then((entity) => entity.toDTO()); } } ================================================ FILE: apps/client-server/src/app/user-converters/user-converters.module.ts ================================================ import { Module } from '@nestjs/common'; import { UserConvertersController } from './user-converters.controller'; import { UserConvertersService } from './user-converters.service'; @Module({ controllers: [UserConvertersController], providers: [UserConvertersService], exports: [UserConvertersService], }) export class UserConvertersModule {} ================================================ FILE: apps/client-server/src/app/user-converters/user-converters.service.spec.ts ================================================ import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { CreateUserConverterDto } from './dtos/create-user-converter.dto'; import { UpdateUserConverterDto } from './dtos/update-user-converter.dto'; import { UserConvertersService } from './user-converters.service'; describe('UserConvertersService', () => { let service: UserConvertersService; let module: TestingModule; function createUserConverterDto( username: string, convertTo: Record, ) { const dto = new CreateUserConverterDto(); dto.username = username; dto.convertTo = convertTo; return dto; } beforeEach(async () => { clearDatabase(); try { module = await Test.createTestingModule({ imports: [], providers: [UserConvertersService], }).compile(); service = module.get(UserConvertersService); } catch (e) { console.log(e); } }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should create entities', async () => { const dto = createUserConverterDto('my_friend', { default: 'converted_friend', }); const record = await service.create(dto); const converters = await service.findAll(); expect(converters).toHaveLength(1); expect(converters[0].username).toEqual(dto.username); expect(converters[0].convertTo).toEqual(dto.convertTo); expect(record.toObject()).toEqual({ username: dto.username, convertTo: dto.convertTo, id: record.id, createdAt: record.createdAt, updatedAt: record.updatedAt, }); }); it('should fail to create duplicate user converters', async () => { const dto = createUserConverterDto('my_friend', { default: 'converted_friend', }); const dto2 = createUserConverterDto('my_friend', { default: 'converted_friend2', }); await service.create(dto); let expectedException = null; try { await service.create(dto2); } catch (err) { expectedException = err; } expect(expectedException).toBeInstanceOf(BadRequestException); }); it('should update entities', async () => { const dto = createUserConverterDto('my_friend', { default: 'converted_friend', }); const record = await service.create(dto); const converters = await service.findAll(); expect(converters).toHaveLength(1); const updateDto = new UpdateUserConverterDto(); updateDto.username = 'my_friend'; updateDto.convertTo = { default: 'converted_friend', bluesky: 'converted_friend2', }; await service.update(record.id, updateDto); const updatedRec = await service.findById(record.id); expect(updatedRec.username).toBe(updateDto.username); expect(updatedRec.convertTo).toEqual(updateDto.convertTo); }); it('should delete entities', async () => { const dto = createUserConverterDto('my_friend', { default: 'converted_friend', }); const record = await service.create(dto); expect(await service.findAll()).toHaveLength(1); await service.remove(record.id); expect(await service.findAll()).toHaveLength(0); }); it('should convert usernames', async () => { const dto = createUserConverterDto('my_friend', { default: 'default_friend', bluesky: 'friend.bsky.social', }); await service.create(dto); // Test conversion for bluesky const resultBluesky = await service.convert( { decoratedProps: { metadata: { name: 'bluesky' } } } as any, 'my_friend', ); expect(resultBluesky).toEqual('friend.bsky.social'); // Test conversion for unknown website (should use default) const resultDefault = await service.convert( { decoratedProps: { metadata: { name: 'unknown' } } } as any, 'my_friend', ); expect(resultDefault).toEqual('default_friend'); // Test conversion for username not in converter (should return original) const resultNotFound = await service.convert( { decoratedProps: { metadata: { name: 'bluesky' } } } as any, 'unknown_user', ); expect(resultNotFound).toEqual('unknown_user'); }); }); ================================================ FILE: apps/client-server/src/app/user-converters/user-converters.service.ts ================================================ import { Injectable, Optional } from '@nestjs/common'; import { USER_CONVERTER_UPDATES } from '@postybirb/socket-events'; import { EntityId } from '@postybirb/types'; import { eq } from 'drizzle-orm'; import { PostyBirbService } from '../common/service/postybirb-service'; import { UserConverter } from '../drizzle/models'; import { WSGateway } from '../web-socket/web-socket-gateway'; import { Website } from '../websites/website'; import { CreateUserConverterDto } from './dtos/create-user-converter.dto'; import { UpdateUserConverterDto } from './dtos/update-user-converter.dto'; @Injectable() export class UserConvertersService extends PostyBirbService<'UserConverterSchema'> { constructor(@Optional() webSocket?: WSGateway) { super('UserConverterSchema', webSocket); this.repository.subscribe('UserConverterSchema', () => { this.emit(); }); } async create(createDto: CreateUserConverterDto): Promise { this.logger .withMetadata(createDto) .info(`Creating UserConverter '${createDto.username}'`); await this.throwIfExists(eq(this.schema.username, createDto.username)); return this.repository.insert(createDto); } update(id: EntityId, update: UpdateUserConverterDto) { this.logger.withMetadata(update).info(`Updating UserConverter '${id}'`); return this.repository.update(id, update); } /** * Converts a username using user defined conversion table. * * @param {Website} instance * @param {string} username * @return {*} {Promise} */ async convert(instance: Website, username: string): Promise { const converter = await this.repository.findOne({ where: (c, { eq: eqFn }) => eqFn(c.username, username), }); if (!converter) { return username; } return ( converter.convertTo[instance.decoratedProps.metadata.name] ?? converter.convertTo.default ?? username ); } protected async emit() { super.emit({ event: USER_CONVERTER_UPDATES, data: (await this.repository.findAll()).map((entity) => entity.toDTO()), }); } } ================================================ FILE: apps/client-server/src/app/user-specified-website-options/dtos/create-user-specified-website-options.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { DynamicObject, EntityId, ICreateUserSpecifiedWebsiteOptionsDto, SubmissionType, } from '@postybirb/types'; import { IsEnum, IsObject, IsString } from 'class-validator'; export class CreateUserSpecifiedWebsiteOptionsDto implements ICreateUserSpecifiedWebsiteOptionsDto { @ApiProperty() @IsObject() options: DynamicObject; @ApiProperty({ enum: SubmissionType }) @IsEnum(SubmissionType) type: SubmissionType; @IsString() accountId: EntityId; } ================================================ FILE: apps/client-server/src/app/user-specified-website-options/dtos/update-user-specified-website-options.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { DynamicObject, IUpdateUserSpecifiedWebsiteOptionsDto, SubmissionType, } from '@postybirb/types'; import { IsEnum, IsObject } from 'class-validator'; export class UpdateUserSpecifiedWebsiteOptionsDto implements IUpdateUserSpecifiedWebsiteOptionsDto { @ApiProperty({ enum: SubmissionType }) @IsEnum(SubmissionType) type: SubmissionType; @ApiProperty() @IsObject() options: DynamicObject; } ================================================ FILE: apps/client-server/src/app/user-specified-website-options/user-specified-website-options.controller.ts ================================================ import { Body, Controller, Param, Patch, Post } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { EntityId } from '@postybirb/types'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { CreateUserSpecifiedWebsiteOptionsDto } from './dtos/create-user-specified-website-options.dto'; import { UpdateUserSpecifiedWebsiteOptionsDto } from './dtos/update-user-specified-website-options.dto'; import { UserSpecifiedWebsiteOptionsService } from './user-specified-website-options.service'; /** * CRUD operations for UserSpecifiedWebsiteOptions * @class UserSpecifiedWebsiteOptionsController */ @ApiTags('user-specified-website-options') @Controller('user-specified-website-options') export class UserSpecifiedWebsiteOptionsController extends PostyBirbController<'UserSpecifiedWebsiteOptionsSchema'> { constructor(readonly service: UserSpecifiedWebsiteOptionsService) { super(service); } @Post() @ApiOkResponse({ description: 'Entity created or updated.' }) @ApiBadRequestResponse({ description: 'Bad request made.' }) async create(@Body() createDto: CreateUserSpecifiedWebsiteOptionsDto) { // Use upsert to handle both create and update based on accountId+type return this.service.upsert(createDto).then((entity) => entity.toDTO()); } @Patch(':id') @ApiOkResponse({ description: 'Entity updated.', type: Boolean }) @ApiNotFoundResponse() update( @Param('id') id: EntityId, @Body() updateDto: UpdateUserSpecifiedWebsiteOptionsDto, ) { return this.service.update(id, updateDto).then((entity) => entity.toDTO()); } } ================================================ FILE: apps/client-server/src/app/user-specified-website-options/user-specified-website-options.module.ts ================================================ import { Module } from '@nestjs/common'; import { UserSpecifiedWebsiteOptionsController } from './user-specified-website-options.controller'; import { UserSpecifiedWebsiteOptionsService } from './user-specified-website-options.service'; @Module({ controllers: [UserSpecifiedWebsiteOptionsController], providers: [UserSpecifiedWebsiteOptionsService], exports: [UserSpecifiedWebsiteOptionsService], }) export class UserSpecifiedWebsiteOptionsModule {} ================================================ FILE: apps/client-server/src/app/user-specified-website-options/user-specified-website-options.service.spec.ts ================================================ import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { NULL_ACCOUNT_ID, SubmissionType } from '@postybirb/types'; import { AccountModule } from '../account/account.module'; import { AccountService } from '../account/account.service'; import { CreateUserSpecifiedWebsiteOptionsDto } from './dtos/create-user-specified-website-options.dto'; import { UpdateUserSpecifiedWebsiteOptionsDto } from './dtos/update-user-specified-website-options.dto'; import { UserSpecifiedWebsiteOptionsService } from './user-specified-website-options.service'; describe('UserSpecifiedWebsiteOptionsService', () => { let service: UserSpecifiedWebsiteOptionsService; let module: TestingModule; beforeEach(async () => { clearDatabase(); module = await Test.createTestingModule({ imports: [AccountModule], providers: [UserSpecifiedWebsiteOptionsService], }).compile(); service = module.get( UserSpecifiedWebsiteOptionsService, ); const accountService = module.get(AccountService); await accountService.onModuleInit(); }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should create entities', async () => { const dto = new CreateUserSpecifiedWebsiteOptionsDto(); dto.accountId = NULL_ACCOUNT_ID; dto.options = { test: 'test' }; dto.type = SubmissionType.MESSAGE; const record = await service.create(dto); expect(await service.findAll()).toHaveLength(1); expect(record.options).toEqual(dto.options); expect(record.type).toEqual(dto.type); expect(record.toDTO()).toEqual({ accountId: NULL_ACCOUNT_ID, createdAt: record.createdAt, id: record.id, options: dto.options, type: dto.type, updatedAt: record.updatedAt, }); }); it('should fail to create a duplicate entity', async () => { const dto = new CreateUserSpecifiedWebsiteOptionsDto(); dto.accountId = NULL_ACCOUNT_ID; dto.options = { test: 'test' }; dto.type = SubmissionType.MESSAGE; await service.create(dto); await expect(service.create(dto)).rejects.toThrow(BadRequestException); }); it('should upsert - create when not exists', async () => { const dto = new CreateUserSpecifiedWebsiteOptionsDto(); dto.accountId = NULL_ACCOUNT_ID; dto.options = { test: 'test' }; dto.type = SubmissionType.MESSAGE; const record = await service.upsert(dto); expect(await service.findAll()).toHaveLength(1); expect(record.options).toEqual(dto.options); }); it('should upsert - update when exists', async () => { const dto = new CreateUserSpecifiedWebsiteOptionsDto(); dto.accountId = NULL_ACCOUNT_ID; dto.options = { test: 'original' }; dto.type = SubmissionType.MESSAGE; // First create const created = await service.upsert(dto); expect(created.options).toEqual({ test: 'original' }); // Second upsert should update, not throw dto.options = { test: 'updated' }; const updated = await service.upsert(dto); expect(await service.findAll()).toHaveLength(1); // Still only one record expect(updated.id).toEqual(created.id); // Same record expect(updated.options).toEqual({ test: 'updated' }); // Updated options }); it('should upsert different account+type combinations independently', async () => { const dto1 = new CreateUserSpecifiedWebsiteOptionsDto(); dto1.accountId = NULL_ACCOUNT_ID; dto1.options = { test: 'message' }; dto1.type = SubmissionType.MESSAGE; const dto2 = new CreateUserSpecifiedWebsiteOptionsDto(); dto2.accountId = NULL_ACCOUNT_ID; dto2.options = { test: 'file' }; dto2.type = SubmissionType.FILE; await service.upsert(dto1); await service.upsert(dto2); expect(await service.findAll()).toHaveLength(2); }); it('should update entities', async () => { const dto = new CreateUserSpecifiedWebsiteOptionsDto(); dto.accountId = NULL_ACCOUNT_ID; dto.options = { test: 'test' }; dto.type = SubmissionType.MESSAGE; const record = await service.create(dto); const options = { ...record.options }; const updateDto = new UpdateUserSpecifiedWebsiteOptionsDto(); updateDto.type = SubmissionType.MESSAGE; updateDto.options = { test: 'updated' }; const updateRecord = await service.update(record.id, updateDto); expect(record.id).toEqual(updateRecord.id); expect(options).not.toEqual(updateRecord.options); }); }); ================================================ FILE: apps/client-server/src/app/user-specified-website-options/user-specified-website-options.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { AccountId, EntityId, SubmissionType } from '@postybirb/types'; import { and, eq } from 'drizzle-orm'; import { PostyBirbService } from '../common/service/postybirb-service'; import { UserSpecifiedWebsiteOptions } from '../drizzle/models'; import { CreateUserSpecifiedWebsiteOptionsDto } from './dtos/create-user-specified-website-options.dto'; import { UpdateUserSpecifiedWebsiteOptionsDto } from './dtos/update-user-specified-website-options.dto'; @Injectable() export class UserSpecifiedWebsiteOptionsService extends PostyBirbService<'UserSpecifiedWebsiteOptionsSchema'> { constructor() { super('UserSpecifiedWebsiteOptionsSchema'); } async create( createDto: CreateUserSpecifiedWebsiteOptionsDto, ): Promise { this.logger .withMetadata(createDto) .info(`Creating UserSpecifiedWebsiteOptions '${createDto.accountId}'`); await this.throwIfExists( and( eq(this.schema.accountId, createDto.accountId), eq(this.schema.type, createDto.type), ), ); return this.repository.insert({ accountId: createDto.accountId, ...createDto, }); } update(id: EntityId, update: UpdateUserSpecifiedWebsiteOptionsDto) { this.logger .withMetadata(update) .info(`Updating UserSpecifiedWebsiteOptions '${id}'`); return this.repository.update(id, { options: update.options }); } /** * Creates or updates user-specified website options. * If options already exist for this account+type combination, updates them. * Otherwise, creates new options. */ async upsert( dto: CreateUserSpecifiedWebsiteOptionsDto, ): Promise { const existing = await this.findByAccountAndSubmissionType( dto.accountId, dto.type, ); if (existing) { this.logger .withMetadata(dto) .info( `Updating existing UserSpecifiedWebsiteOptions for '${dto.accountId}'`, ); return this.repository.update(existing.id, { options: dto.options }); } return this.create(dto); } public findByAccountAndSubmissionType( accountId: AccountId, type: SubmissionType, ) { return this.repository.findOne({ // eslint-disable-next-line @typescript-eslint/no-shadow where: (options, { and, eq }) => and(eq(options.accountId, accountId), eq(options.type, type)), }); } } ================================================ FILE: apps/client-server/src/app/utils/blocknote-to-tiptap.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Logger } from '@postybirb/logger'; import { Description, TipTapMark, TipTapNode } from '@postybirb/types'; const logger = Logger(); /** * BlockNote block shape (old format): * { * id: string, * type: string, * props: Record, * content: Array<{ type: 'text', text: string, styles: Record } * | { type: 'link', href: string, content: [...textNodes] } * | { type: string, props: Record }>, * children: Block[] * } */ interface BNTextNode { type: 'text'; text: string; styles?: Record; } interface BNLinkNode { type: 'link'; href: string; content: BNTextNode[]; } interface BNInlineNode { type: string; props?: Record; } type BNInlineContent = BNTextNode | BNLinkNode | BNInlineNode; interface BNBlock { id?: string; type: string; props?: Record; content?: BNInlineContent[] | 'none' | string; children?: BNBlock[]; } // Default props that BlockNote includes but are meaningless — strip them const DEFAULT_PROP_VALUES: Record = { textColor: 'default', backgroundColor: 'default', textAlignment: 'left', level: 1, }; /** * Returns true if the value is a BlockNote-format description (old format). * BlockNote descriptions are stored as an array of blocks. * TipTap descriptions are stored as { type: 'doc', content: [...] }. */ export function isBlockNoteFormat(desc: unknown): desc is BNBlock[] { return Array.isArray(desc); } /** * Convert BlockNote styles object to TipTap marks array. * e.g. { bold: true, italic: true, textColor: '#ff0000' } * → [{ type: 'bold' }, { type: 'italic' }, { type: 'textColor', attrs: { color: '#ff0000' } }] */ function convertStyles(styles: Record): TipTapMark[] { const marks: TipTapMark[] = []; for (const [key, value] of Object.entries(styles)) { if (value === false || value === undefined || value === null) { continue; } switch (key) { case 'bold': case 'italic': case 'strike': case 'underline': case 'code': if (value === true) { marks.push({ type: key }); } break; case 'textColor': if (value && value !== 'default') { marks.push({ type: 'textStyle', attrs: { color: value } }); } break; case 'backgroundColor': if (value && value !== 'default') { marks.push({ type: 'highlight', attrs: { color: value } }); } break; default: // Unknown style — preserve as-is if (value === true) { marks.push({ type: key }); } else { marks.push({ type: key, attrs: { value } }); } break; } } return marks; } /** * Convert a BlockNote inline content node to TipTap inline nodes. * - text → { type: 'text', text, marks } * - link → text nodes with link mark added * - custom inline shortcuts → { type, attrs } */ function convertInlineContent( inlineNode: BNInlineContent, ): TipTapNode[] { if (inlineNode.type === 'text') { const bn = inlineNode as BNTextNode; const node: TipTapNode = { type: 'text', text: bn.text }; if (bn.styles && Object.keys(bn.styles).length > 0) { const marks = convertStyles(bn.styles); if (marks.length > 0) { node.marks = marks; } } return [node]; } if (inlineNode.type === 'link') { const bn = inlineNode as BNLinkNode; const linkMark: TipTapMark = { type: 'link', attrs: { href: bn.href, target: '_blank', rel: 'noopener noreferrer nofollow' }, }; // Each text node inside the link gets the link mark added return (bn.content || []).flatMap((textNode) => { const converted = convertInlineContent(textNode); return converted.map((n) => { if (n.type === 'text') { const existing = n.marks || []; return { ...n, marks: [...existing, linkMark] }; } return n; }); }); } // Custom inline nodes: customShortcut, titleShortcut, tagsShortcut, // contentWarningShortcut, username const bn = inlineNode as BNInlineNode; const attrs: Record = {}; if (bn.props) { for (const [key, value] of Object.entries(bn.props)) { if (value !== undefined && value !== null && value !== '') { attrs[key] = value; } } } const node: TipTapNode = { type: bn.type }; if (Object.keys(attrs).length > 0) { node.attrs = attrs; } return [node]; } /** * Convert a single BlockNote block to a TipTap node. */ function convertBlock(block: BNBlock): TipTapNode[] { const {type} = block; const props = block.props || {}; // Map BlockNote block types to TipTap node types switch (type) { case 'paragraph': { const node: TipTapNode = { type: 'paragraph' }; const attrs = extractBlockAttrs(props); if (Object.keys(attrs).length > 0) { node.attrs = attrs; } if ( block.content && Array.isArray(block.content) && block.content.length > 0 ) { node.content = block.content.flatMap(convertInlineContent); } const result: TipTapNode[] = [node]; // BlockNote children are nested blocks (e.g. indented content) if (block.children && block.children.length > 0) { result.push(...block.children.flatMap(convertBlock)); } return result; } case 'heading': { const level = props.level || 1; const node: TipTapNode = { type: 'heading', attrs: { level, ...extractBlockAttrs(props, ['level']) }, }; if ( block.content && Array.isArray(block.content) && block.content.length > 0 ) { node.content = block.content.flatMap(convertInlineContent); } const result: TipTapNode[] = [node]; if (block.children && block.children.length > 0) { result.push(...block.children.flatMap(convertBlock)); } return result; } case 'bulletListItem': { // BlockNote uses flat blocks with type 'bulletListItem' // TipTap uses bulletList > listItem > paragraph const paragraph: TipTapNode = { type: 'paragraph' }; if ( block.content && Array.isArray(block.content) && block.content.length > 0 ) { paragraph.content = block.content.flatMap(convertInlineContent); } const listItem: TipTapNode = { type: 'listItem', content: [paragraph], }; // Children of a list item become nested list items inside a sub-list if (block.children && block.children.length > 0) { const childItems = block.children.flatMap((child) => convertBlock(child), ); const subList: TipTapNode = { type: 'bulletList', content: childItems.filter((n) => n.type === 'listItem'), }; if (subList.content && subList.content.length > 0) { listItem.content = [paragraph, subList]; } } // Wrap in bulletList — caller will merge adjacent ones return [{ type: 'bulletList', content: [listItem] }]; } case 'numberedListItem': { const paragraph: TipTapNode = { type: 'paragraph' }; if ( block.content && Array.isArray(block.content) && block.content.length > 0 ) { paragraph.content = block.content.flatMap(convertInlineContent); } const listItem: TipTapNode = { type: 'listItem', content: [paragraph], }; if (block.children && block.children.length > 0) { const childItems = block.children.flatMap((child) => convertBlock(child), ); const subList: TipTapNode = { type: 'orderedList', content: childItems.filter((n) => n.type === 'listItem'), }; if (subList.content && subList.content.length > 0) { listItem.content = [paragraph, subList]; } } return [{ type: 'orderedList', content: [listItem] }]; } case 'blockquote': { // BlockNote stores blockquote content as inline content in a single block const paragraph: TipTapNode = { type: 'paragraph' }; if ( block.content && Array.isArray(block.content) && block.content.length > 0 ) { paragraph.content = block.content.flatMap(convertInlineContent); } const result: TipTapNode[] = [ { type: 'blockquote', content: [paragraph] }, ]; if (block.children && block.children.length > 0) { result.push(...block.children.flatMap(convertBlock)); } return result; } case 'codeBlock': { const node: TipTapNode = { type: 'codeBlock' }; if (props.language) { node.attrs = { language: props.language }; } // Code blocks in BlockNote have text content if ( block.content && Array.isArray(block.content) && block.content.length > 0 ) { node.content = block.content .filter( (c): c is BNTextNode => c.type === 'text' && 'text' in c, ) .map((c) => ({ type: 'text', text: c.text })); } return [node]; } // Custom block nodes — map directly case 'defaultShortcut': { const node: TipTapNode = { type: 'defaultShortcut' }; const attrs: Record = {}; if (props.only) attrs.only = props.only; if (Object.keys(attrs).length > 0) { node.attrs = attrs; } return [node]; } // Fallback for any other block type (e.g. horizontal rule, images, etc.) default: { // Map known aliases if (type === 'horizontalRule' || type === 'rule') { return [{ type: 'horizontalRule' }]; } // Generic passthrough — preserve type and convert content const node: TipTapNode = { type }; const attrs = extractBlockAttrs(props); if (Object.keys(attrs).length > 0) { node.attrs = attrs; } if ( block.content && Array.isArray(block.content) && block.content.length > 0 ) { node.content = block.content.flatMap(convertInlineContent); } return [node]; } } } /** * Extract meaningful attrs from BlockNote props, stripping defaults. */ function extractBlockAttrs( props: Record, skipKeys: string[] = [], ): Record { const attrs: Record = {}; for (const [key, value] of Object.entries(props)) { if (skipKeys.includes(key)) continue; if (value === undefined || value === null) continue; if (DEFAULT_PROP_VALUES[key] === value) continue; if (key === 'textAlignment' && value !== 'left') { attrs.textAlign = value; } else if ( key !== 'textColor' && key !== 'backgroundColor' && key !== 'textAlignment' ) { attrs[key] = value; } } return attrs; } /** * Merge adjacent list nodes of the same type. * BlockNote creates one bulletList/orderedList per list item, * but TipTap expects all items in a single list node. */ function mergeAdjacentLists(nodes: TipTapNode[]): TipTapNode[] { const result: TipTapNode[] = []; for (const node of nodes) { const prev = result[result.length - 1]; if ( prev && prev.type === node.type && (node.type === 'bulletList' || node.type === 'orderedList') && Array.isArray(prev.content) && Array.isArray(node.content) ) { prev.content = [...prev.content, ...node.content]; } else { result.push(node); } } return result; } /** * Convert an entire BlockNote description (array of blocks) to TipTap JSON. */ export function convertBlockNoteToTipTap(blocks: BNBlock[]): Description { if (!Array.isArray(blocks) || blocks.length === 0) { return { type: 'doc', content: [] }; } const converted = blocks.flatMap(convertBlock); const merged = mergeAdjacentLists(converted); return { type: 'doc', content: merged, }; } /** * Migrate a Description field if it is in BlockNote format. * Returns the migrated TipTap doc, or the original if already in TipTap format. */ export function migrateDescription(desc: unknown): Description { if (!desc) { return { type: 'doc', content: [] }; } // Already TipTap format if ( typeof desc === 'object' && !Array.isArray(desc) && (desc as any).type === 'doc' ) { return desc as Description; } // BlockNote format (array of blocks) if (Array.isArray(desc)) { try { return convertBlockNoteToTipTap(desc as BNBlock[]); } catch (err) { logger.error( `Failed to migrate BlockNote description: ${(err as Error).message}`, (err as Error).stack, ); return { type: 'doc', content: [] }; } } // Unknown format logger.warn(`Unknown description format, resetting to empty`); return { type: 'doc', content: [] }; } ================================================ FILE: apps/client-server/src/app/utils/coerce.util.ts ================================================ export class Coerce { static boolean(value: string | boolean): boolean { return !!value.toString().match(/^(true|[1-9][0-9]*|[0-9]*[1-9]+|yes)$/i); } } ================================================ FILE: apps/client-server/src/app/utils/filesize.util.ts ================================================ /** * Defines simple file size conversion methods * @class FileSize */ export default class FileSize { static megabytes(size: number): number { return size * 1000000; } static bytesToMB(size: number): number { return size / 1000000; } } ================================================ FILE: apps/client-server/src/app/utils/html-parser.util.ts ================================================ import { NotFoundException } from '@nestjs/common'; export default class HtmlParserUtil { public static getInputValue(html: string, name: string, index = 0): string { // eslint-disable-next-line no-param-reassign index = index || 0; const inputs = (html.match(//gim) || []) .filter((input?: string) => input?.includes(`name="${name}"`)) .map((input) => input.match(/value="(.*?)"/)[1]); const picked = inputs[index]; if (!picked) { throw new NotFoundException(`Could not find form key: ${name}[${index}]`); } return picked; } } ================================================ FILE: apps/client-server/src/app/utils/select-option.util.ts ================================================ import { SelectOption } from '@postybirb/form-builder'; export class SelectOptionUtil { static findOptionById( options: SelectOption[], id: string, ): SelectOption | undefined { for (const option of options) { if (option.value === id) { return option; } if ('items' in option && option.items) { const found = this.findOptionById(option.items, id); if (found) { return found; } } } return undefined; } } ================================================ FILE: apps/client-server/src/app/utils/wait.util.ts ================================================ import { setInterval } from 'timers/promises'; export function wait(milliseconds: number): Promise { return new Promise((resolve) => { setTimeout(resolve, milliseconds); }); } export async function waitUntil( fn: () => boolean, milliseconds: number, ): Promise { if (fn()) { return; } const interval = setInterval(milliseconds); // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars for await (const i of interval) { if (fn()) { break; } } } export async function waitUntilPromised( fn: () => Promise, milliseconds: number, ): Promise { if (await fn()) { return; } const interval = setInterval(milliseconds); // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars for await (const i of interval) { if (await fn()) { break; } } } ================================================ FILE: apps/client-server/src/app/validation/validation.module.ts ================================================ import { Module } from '@nestjs/common'; import { FileConverterModule } from '../file-converter/file-converter.module'; import { FileModule } from '../file/file.module'; import { PostParsersModule } from '../post-parsers/post-parsers.module'; import { WebsitesModule } from '../websites/websites.module'; import { ValidationService } from './validation.service'; @Module({ imports: [WebsitesModule, PostParsersModule, FileConverterModule, FileModule], providers: [ValidationService], exports: [ValidationService], }) export class ValidationModule {} ================================================ FILE: apps/client-server/src/app/validation/validation.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { FileConverterService } from '../file-converter/file-converter.service'; import { FileModule } from '../file/file.module'; import { FileService } from '../file/file.service'; import { CreateFileService } from '../file/services/create-file.service'; import { UpdateFileService } from '../file/services/update-file.service'; import { SharpInstanceManager } from '../image-processing/sharp-instance-manager'; import { PostParsersModule } from '../post-parsers/post-parsers.module'; import { PostParsersService } from '../post-parsers/post-parsers.service'; import { WebsiteImplProvider } from '../websites/implementations/provider'; import { WebsiteRegistryService } from '../websites/website-registry.service'; import { WebsitesModule } from '../websites/websites.module'; import { ValidationService } from './validation.service'; describe('ValidationService', () => { let service: ValidationService; beforeEach(async () => { clearDatabase(); const module: TestingModule = await Test.createTestingModule({ imports: [WebsitesModule, PostParsersModule, FileModule], providers: [ WebsiteImplProvider, ValidationService, WebsiteRegistryService, PostParsersService, FileConverterService, FileService, CreateFileService, UpdateFileService, SharpInstanceManager, ], }).compile(); service = module.get(ValidationService); }); it('should be defined', () => { expect(service).toBeDefined(); }); }); ================================================ FILE: apps/client-server/src/app/validation/validation.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Logger } from '@postybirb/logger'; import { EntityId, ISubmission, IWebsiteOptions, PostData, SimpleValidationResult, SubmissionId, SubmissionType, ValidationResult, } from '@postybirb/types'; import { Account, Submission, WebsiteOptions } from '../drizzle/models'; import { FileConverterService } from '../file-converter/file-converter.service'; import { FileService } from '../file/file.service'; import { PostParsersService } from '../post-parsers/post-parsers.service'; import DefaultWebsite from '../websites/implementations/default/default.website'; import { DefaultWebsiteOptions } from '../websites/models/default-website-options'; import { isFileWebsite } from '../websites/models/website-modifiers/file-website'; import { isMessageWebsite } from '../websites/models/website-modifiers/message-website'; import { UnknownWebsite } from '../websites/website'; import { WebsiteRegistryService } from '../websites/website-registry.service'; import { validators } from './validators'; import { FieldValidator, Validator, ValidatorParams, } from './validators/validator.type'; type ValidationCacheRecord = { submissionLastUpdatedTimestamp: string; results: Record< EntityId, // WebsiteOptionId { validationResult: ValidationResult; websiteOptionLastUpdatedTimestamp: string; } >; }; @Injectable() export class ValidationService { private readonly logger = Logger(this.constructor.name); private readonly validations: Validator[] = validators; private readonly validationCache = new Map< SubmissionId, ValidationCacheRecord >(); constructor( private readonly websiteRegistry: WebsiteRegistryService, private readonly postParserService: PostParsersService, private readonly fileConverterService: FileConverterService, private readonly fileService: FileService, ) {} private getCachedValidation( submissionId: SubmissionId, ): ValidationCacheRecord | undefined { return this.validationCache.get(submissionId); } private clearCachedValidation(submissionId: SubmissionId) { this.validationCache.delete(submissionId); } private setCachedValidation( submission: Submission, websiteOption: WebsiteOptions, validationResult: ValidationResult, ) { const cachedValidation = this.getCachedValidation(submission.id); if (!cachedValidation) { this.validationCache.set(submission.id, { submissionLastUpdatedTimestamp: submission.updatedAt, results: { [websiteOption.id]: { validationResult, websiteOptionLastUpdatedTimestamp: websiteOption.updatedAt, }, }, }); } else { cachedValidation.results[websiteOption.id] = { validationResult, websiteOptionLastUpdatedTimestamp: websiteOption.updatedAt, }; } } /** * Validate a submission for all website options. * * @param {ISubmission} submission * @return {*} {Promise} */ public async validateSubmission( submission: Submission, ): Promise { if (this.isStale(submission)) { this.clearCachedValidation(submission.id); } return Promise.all( submission.options.map((website) => this.validate(submission, website)), ); } /** * Check if a submission is stale by comparing the last updated timestamps of * the submission and the website options. * * @param {Submission} submission * @return {*} {boolean} */ private isStale(submission: Submission): boolean { const cachedValidation = this.getCachedValidation(submission.id); if (!cachedValidation) { return false; } if ( cachedValidation.submissionLastUpdatedTimestamp !== submission.updatedAt ) { return true; } return submission.options.some( (website) => cachedValidation.results[website.id] && cachedValidation.results[website.id] .websiteOptionLastUpdatedTimestamp !== website.updatedAt, ); } /** * Validate an individual website option. * * @param {ISubmission} submission * @param {IWebsiteOptions} websiteOption * @return {*} {Promise} */ public async validate( submission: Submission, websiteOption: WebsiteOptions, ): Promise { try { const cachedValidation = this.getCachedValidation(submission.id); if ( cachedValidation && cachedValidation.results[websiteOption.id] && cachedValidation.results[websiteOption.id] .websiteOptionLastUpdatedTimestamp === websiteOption.updatedAt ) { return cachedValidation.results[websiteOption.id].validationResult; } const website = websiteOption.isDefault ? new DefaultWebsite(new Account(websiteOption.account)) : this.websiteRegistry.findInstance(websiteOption.account); if (!website) { this.logger.error( `Failed to find website instance for account ${websiteOption.accountId}`, ); throw new Error( `Failed to find website instance for account ${websiteOption.accountId}`, ); } // All sub-validations mutate the result object const result: ValidationResult = { id: websiteOption.id, account: website.account.toDTO(), warnings: [], errors: [], }; const data = await this.postParserService.parse( submission, website, websiteOption, ); const defaultOptions: IWebsiteOptions = submission.options.find( (o) => o.isDefault, ); const defaultOpts = Object.assign(new DefaultWebsiteOptions(), { ...defaultOptions.data, }); const mergedWebsiteOptions = Object.assign( website.getModelFor(submission.type), websiteOption.data, ).mergeDefaults(defaultOpts); const params: ValidatorParams = { result, validator: new FieldValidator(result.errors, result.warnings), websiteInstance: website, data, submission, fileConverterService: this.fileConverterService, fileService: this.fileService, mergedWebsiteOptions, }; // eslint-disable-next-line no-restricted-syntax for (const validation of this.validations) { await validation(params); } const instanceResult = await this.validateWebsiteInstance( websiteOption.id, submission, website, data, ); result.warnings.push(...(instanceResult.warnings ?? [])); result.errors.push(...(instanceResult.errors ?? [])); this.setCachedValidation(submission, websiteOption, result); return result; } catch (error) { this.logger.warn( `Failed to validate website options ${websiteOption.id}`, error, ); return { id: websiteOption.id, account: new Account(websiteOption.account).toDTO(), warnings: [ { id: 'validation.failed', values: { message: error.message, }, }, ], }; } } private async validateWebsiteInstance( websiteId: EntityId, submission: ISubmission, website: UnknownWebsite, postData: PostData, ): Promise { let result: SimpleValidationResult; try { if (submission.type === SubmissionType.FILE && isFileWebsite(website)) { result = await website.onValidateFileSubmission(postData); } if ( submission.type === SubmissionType.MESSAGE && isMessageWebsite(website) ) { result = await website.onValidateMessageSubmission(postData); } return { id: websiteId, account: website.account.toDTO(), warnings: result?.warnings, errors: result?.errors, }; } catch (error) { this.logger.warn( `Failed to validate website instance for submission ${submission.id}, website ${websiteId}, type ${submission.type}, instance ${website.constructor.name}`, error, ); return { id: websiteId, account: website.account.toDTO(), warnings: [ { id: 'validation.failed', values: { message: error.message, }, }, ], }; } } } ================================================ FILE: apps/client-server/src/app/validation/validators/common-field-validators.ts ================================================ import { ValidatorParams } from './validator.type'; /** * Validates that a required text field (input/textarea) is not empty. */ export async function validateRequiredTextField({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { // Skip if field is not required or hidden if (!field.required || field.hidden) continue; // Only check text field types (includes title, content warning etc.) if (field.formField !== 'input' && field.formField !== 'textarea') continue; // Check if the field is a title field // Check if the field is a content warning field if ( (field.formField === 'input' && field.label === 'title') || (field.formField === 'input' && field.label === 'contentWarning') ) { continue; } const value = data.options[fieldName] as string; // Check if the value is empty if (!value || value.trim() === '') { result.errors.push({ id: 'validation.field.required', field: fieldName, values: {}, }); } } } /** * Validates that a required select field has a value selected. */ export async function validateRequiredSelectField({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { // Skip if field is not required or hidden if (!field.required || field.hidden) continue; // Only check select fields if (field.formField !== 'select') continue; // Skip fields with min selected, they gets handled by selectFieldValidator if ('minSelected' in field && typeof field.minSelected === 'number') continue; const value = data.options[fieldName]; const isEmpty = Array.isArray(value) ? value.length === 0 : !value; if (isEmpty) { result.errors.push({ id: 'validation.field.required', field: fieldName, values: {}, }); } } } /** * Validates that a required radio field has a value selected. */ export async function validateRequiredRadioField({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { // Skip if field is not required or hidden if (!field.required || field.hidden) continue; // Only check radio fields (includes rating fields) if (field.formField !== 'radio' && field.formField !== 'rating') continue; const value = data.options[fieldName]; if (!value) { result.errors.push({ id: 'validation.field.required', field: fieldName, values: {}, }); } } } /** * Validates that a required boolean field (checkbox) is checked. */ export async function validateRequiredBooleanField({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { // Skip if field is not required or hidden if (!field.required || field.hidden) continue; // Only check checkbox fields if (field.formField !== 'checkbox') continue; const value = data.options[fieldName] as boolean; if (typeof value !== 'boolean') { result.errors.push({ id: 'validation.field.required', field: fieldName, values: {}, }); } } } /** * Validates that a required description field has content. */ export async function validateRequiredDescriptionField({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { // Skip if field is not required or hidden if (!field.required || field.hidden) continue; // Only check description fields if (field.formField !== 'description') continue; // Description field value structure let value: string = data.options[fieldName] || ''; value = value.replaceAll('
      ', '').trim(); if (!value || value.length === 0) { result.errors.push({ id: 'validation.field.required', field: fieldName, values: {}, }); } } } ================================================ FILE: apps/client-server/src/app/validation/validators/datetime-field-validators.ts ================================================ import { ValidatorParams } from './validator.type'; /** * Validates that a datetime field value is a valid ISO date string. */ export async function validateDateTimeFormat({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { // Skip if field is hidden if (field.hidden) continue; // Only check datetime fields if (field.formField !== 'datetime') continue; const value = data.options[fieldName] as string; // Skip if no value (empty is handled by required validator) if (!value || value.trim() === '') continue; // Try to parse as ISO date string const date = new Date(value); if (Number.isNaN(date.getTime())) { result.errors.push({ id: 'validation.datetime.invalid-format', field: fieldName, values: { value, }, }); } } } /** * Validates that a datetime field value is not before the minimum allowed date. */ export async function validateDateTimeMinimum({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { // Skip if field is hidden if (field.hidden) continue; // Only check datetime fields if (field.formField !== 'datetime') continue; // Skip if no min constraint if (!('min' in field) || !field.min) continue; const value = data.options[fieldName] as string; // Skip if no value if (!value || value.trim() === '') continue; const date = new Date(value); const minDate = new Date(field.min); // Skip if date is invalid (handled by format validator) if (Number.isNaN(date.getTime())) continue; if (date < minDate) { result.errors.push({ id: 'validation.datetime.min', field: fieldName, values: { currentDate: value, minDate: field.min, }, }); } } } /** * Validates that a datetime field value is not after the maximum allowed date. */ export async function validateDateTimeMaximum({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { // Skip if field is hidden if (field.hidden) continue; // Only check datetime fields if (field.formField !== 'datetime') continue; // Skip if no max constraint if (!('max' in field) || !field.max) continue; const value = data.options[fieldName] as string; // Skip if no value if (!value || value.trim() === '') continue; const date = new Date(value); const maxDate = new Date(field.max); // Skip if date is invalid (handled by format validator) if (Number.isNaN(date.getTime())) continue; if (date > maxDate) { result.errors.push({ id: 'validation.datetime.max', field: fieldName, values: { currentDate: value, maxDate: field.max, }, }); } } } /** * Validates that a datetime field value is within the allowed range (min and max). * This is a convenience validator that checks both min and max constraints. */ export async function validateDateTimeRange({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { // Skip if field is hidden if (field.hidden) continue; // Only check datetime fields if (field.formField !== 'datetime') continue; // Skip if no range constraints const hasMin = 'min' in field && field.min; const hasMax = 'max' in field && field.max; if (!hasMin || !hasMax) continue; const value = data.options[fieldName] as string; // Skip if no value if (!value || value.trim() === '') continue; const date = new Date(value); const minDate = new Date(field.min); const maxDate = new Date(field.max); // Skip if date is invalid (handled by format validator) if (Number.isNaN(date.getTime())) continue; if (date < minDate || date > maxDate) { result.errors.push({ id: 'validation.datetime.range', field: fieldName, values: { currentDate: value, minDate: field.min, maxDate: field.max, }, }); } } } ================================================ FILE: apps/client-server/src/app/validation/validators/description-validators.ts ================================================ import { DescriptionType } from '@postybirb/types'; import DefaultWebsite from '../../websites/implementations/default/default.website'; import { ValidatorParams } from './validator.type'; export async function validateDescriptionMaxLength({ data, mergedWebsiteOptions, validator, }: ValidatorParams) { const { hidden, descriptionType, maxDescriptionLength } = mergedWebsiteOptions.getFormFieldFor('description'); if ( descriptionType === undefined || descriptionType === DescriptionType.NONE || hidden ) { return; } const { description } = data.options; const maxLength = maxDescriptionLength ?? Number.MAX_SAFE_INTEGER; if (description.length > maxLength) { validator.warning( 'validation.description.max-length', { currentLength: description.length, maxLength }, 'description', ); } } export async function validateDescriptionMinLength({ data, mergedWebsiteOptions, validator, }: ValidatorParams) { const { hidden, descriptionType, minDescriptionLength } = mergedWebsiteOptions.getFormFieldFor('description'); if ( descriptionType === undefined || descriptionType === DescriptionType.NONE || hidden ) { return; } const { description } = data.options; const minLength = minDescriptionLength ?? -1; if (description.length < minLength) { validator.error( 'validation.description.min-length', { minLength, currentLength: description.length }, 'description', ); } } export async function validateTagsPresence({ data, mergedWebsiteOptions, validator, websiteInstance, }: ValidatorParams) { if (websiteInstance instanceof DefaultWebsite) return; const tagsField = mergedWebsiteOptions.getFormFieldFor('tags'); const descriptionField = mergedWebsiteOptions.getFormFieldFor('description'); const { tags, description } = data.options; if (tagsField.hidden || descriptionField.hidden) return; if (!description || !tags.length) return; const presentTags = tags.filter((e) => description.includes(`#${e}`)); if (descriptionField.expectsInlineTags) { if (presentTags.length === 0) { // Tags are missing in the description validator.warning( 'validation.description.missing-tags', {}, 'description', ); } } else if (presentTags.length === tags.length) { // All tags are in description validator.warning( 'validation.description.unexpected-tags', {}, 'description', ); } } export async function validateTitlePresence({ data, mergedWebsiteOptions, validator, websiteInstance, }: ValidatorParams) { if (websiteInstance instanceof DefaultWebsite) return; const titleField = mergedWebsiteOptions.getFormFieldFor('tags'); const descriptionField = mergedWebsiteOptions.getFormFieldFor('description'); const { title, description } = data.options; if (titleField.hidden || descriptionField.hidden) return; if (!description || !title) return; const hasTitleText = description.includes(title); if (descriptionField.expectsInlineTitle) { if (!hasTitleText) { // Title is missing in the description validator.warning( 'validation.description.missing-title', {}, 'description', ); } } else if (hasTitleText) { // Title is in the description validator.warning( 'validation.description.unexpected-title', {}, 'description', ); } } ================================================ FILE: apps/client-server/src/app/validation/validators/file-submission-validators.ts ================================================ import { FileSubmission, FileType, ISubmission, ISubmissionFile, SubmissionType, ValidationMessage, } from '@postybirb/types'; import { getFileType } from '@postybirb/utils/file-type'; import { parse } from 'path'; import { getSupportedFileSize } from '../../websites/decorators/supports-files.decorator'; import DefaultWebsite from '../../websites/implementations/default/default.website'; import { ImplementedFileWebsite, isFileWebsite, } from '../../websites/models/website-modifiers/file-website'; import { UnknownWebsite } from '../../websites/website'; import { ValidatorParams } from './validator.type'; function isFileHandlingWebsite( websiteInstance: UnknownWebsite, ): websiteInstance is ImplementedFileWebsite { return isFileWebsite(websiteInstance); } function isFileSubmission( submission: ISubmission, ): submission is FileSubmission { return submission.type === SubmissionType.FILE; } function isFileFiltered( file: ISubmissionFile, submission: FileSubmission, websiteInstance: UnknownWebsite, ): boolean { if (file.metadata?.ignoredWebsites?.includes(websiteInstance.accountId)) { return true; } return false; } async function validateTextFileRequiresFallback({ result, websiteInstance, submission, fileService, }: ValidatorParams & { file: ISubmissionFile }) { if ( !isFileHandlingWebsite(websiteInstance) || !isFileSubmission(submission) || websiteInstance instanceof DefaultWebsite ) { return; } for (const file of submission.files) { if (isFileFiltered(file, submission, websiteInstance)) { continue; } if (getFileType(file.fileName) === FileType.TEXT) { const supportedMimeTypes = websiteInstance.decoratedProps.fileOptions?.acceptedMimeTypes ?? []; if (supportedMimeTypes.length === 0) { // Assume empty to accept all file types if no accepted mime types are specified continue; } // Check if the alt file has content by querying its size let altFileHasContent = false; if (file.altFileId) { const altFileSize = await fileService.getAltFileSize(file.altFileId); altFileHasContent = altFileSize > 0; } // Fail validation if the file is not supported and alt file is empty or missing if (!supportedMimeTypes.includes(file.mimeType) && !altFileHasContent) { result.errors.push({ id: 'validation.file.text-file-no-fallback', field: 'files', values: { fileName: file.fileName, fileExtension: parse(file.fileName).ext, fileId: file.id, }, }); } } } } export async function validateNotAllFilesIgnored({ result, websiteInstance, submission, }: ValidatorParams) { if ( !isFileHandlingWebsite(websiteInstance) || !isFileSubmission(submission) || websiteInstance instanceof DefaultWebsite ) { return; } const numFiles = submission.files.filter( (file) => !isFileFiltered(file, submission, websiteInstance), ).length; if (numFiles === 0) { result.warnings.push({ id: 'validation.file.all-ignored', field: 'files', values: {}, }); } } export async function validateAcceptedFiles({ result, websiteInstance, submission, data, fileConverterService, ...rest }: ValidatorParams) { if ( !isFileHandlingWebsite(websiteInstance) || !isFileSubmission(submission) || websiteInstance instanceof DefaultWebsite ) { return; } const acceptedMimeTypes = websiteInstance.decoratedProps.fileOptions?.acceptedMimeTypes ?? []; const supportedFileTypes = websiteInstance.decoratedProps.fileOptions?.supportedFileTypes ?? []; if (!acceptedMimeTypes.length && !supportedFileTypes.length) { return; } submission.files.forEach((file) => { if (isFileFiltered(file, submission, websiteInstance)) { return; } if (!acceptedMimeTypes.includes(file.mimeType)) { const fileType = getFileType(file.fileName); if (!supportedFileTypes.includes(fileType)) { result.errors.push({ id: 'validation.file.unsupported-file-type', field: 'files', values: { fileName: file.fileName, fileType: getFileType(file.fileName), fileId: file.id, }, }); } if (fileType === FileType.TEXT) { validateTextFileRequiresFallback({ result, websiteInstance, submission, file, data, fileConverterService, ...rest, }); return; } if (!fileConverterService.canConvert(file.mimeType, acceptedMimeTypes)) { result.errors.push({ id: 'validation.file.invalid-mime-type', field: 'files', values: { mimeType: file.mimeType, acceptedMimeTypes, fileId: file.id, }, }); } } }); } export async function validateFileBatchSize({ result, websiteInstance, submission, }: ValidatorParams) { if ( !isFileHandlingWebsite(websiteInstance) || !isFileSubmission(submission) || websiteInstance instanceof DefaultWebsite ) { return; } const maxBatchSize = websiteInstance.decoratedProps.fileOptions?.fileBatchSize ?? 0; const numFiles = submission.files.filter( (file) => !isFileFiltered(file, submission, websiteInstance), ).length; if (numFiles > maxBatchSize) { const expectedBatchesToCreate = Math.ceil(numFiles / maxBatchSize); result.warnings.push({ id: 'validation.file.file-batch-size', field: 'files', values: { maxBatchSize, expectedBatchesToCreate, }, }); } } export async function validateFileSize({ result, websiteInstance, submission, }: ValidatorParams) { if ( !isFileHandlingWebsite(websiteInstance) || !isFileSubmission(submission) || websiteInstance instanceof DefaultWebsite ) { return; } submission.files.forEach((file) => { if (isFileFiltered(file, submission, websiteInstance)) { return; } const maxFileSize = getSupportedFileSize(websiteInstance, file); if (maxFileSize && file.size > maxFileSize) { const issue: ValidationMessage = { id: 'validation.file.file-size', field: 'files', values: { maxFileSize, fileSize: file.size, fileName: file.fileName, fileId: file.id, }, }; if (getFileType(file.fileName) === FileType.IMAGE) { result.warnings.push(issue); } else { result.errors.push(issue); } } }); } export async function validateImageFileDimensions({ result, websiteInstance, submission, }: ValidatorParams) { if ( !isFileHandlingWebsite(websiteInstance) || !isFileSubmission(submission) || websiteInstance instanceof DefaultWebsite ) { return; } submission.files.forEach((file) => { if (isFileFiltered(file, submission, websiteInstance)) { return; } if (getFileType(file.fileName) === FileType.IMAGE) { const resizeProps = websiteInstance.calculateImageResize(file); if (resizeProps) { result.warnings.push({ id: 'validation.file.image-resize', field: 'files', values: { fileName: file.fileName, resizeProps, fileId: file.id, }, }); } } }); } ================================================ FILE: apps/client-server/src/app/validation/validators/index.ts ================================================ import * as commonFieldValidators from './common-field-validators'; import * as dateTimeValidators from './datetime-field-validators'; import * as descriptionValidators from './description-validators'; import * as fileValidators from './file-submission-validators'; import * as selectFieldValidators from './select-field-validators'; import * as tagValidators from './tag-validators'; import * as titleValidators from './title-validators'; import { Validator } from './validator.type'; export const validators: Validator[] = [ ...Object.values(titleValidators), ...Object.values(descriptionValidators), ...Object.values(tagValidators), ...Object.values(fileValidators), ...Object.values(commonFieldValidators), ...Object.values(selectFieldValidators), ...Object.values(dateTimeValidators), ]; ================================================ FILE: apps/client-server/src/app/validation/validators/select-field-validators.ts ================================================ import { FieldAggregateType, SelectFieldType, SelectOption, } from '@postybirb/form-builder'; import { ValidatorParams } from './validator.type'; function isSelectField(field: FieldAggregateType): field is SelectFieldType { return field.formField === 'select'; } export async function validateSelectFieldMinSelected({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { if (!isSelectField(field)) continue; const options = data.options[fieldName]; const { minSelected } = field; if (!minSelected) continue; const selected = options?.length ?? 0; if (selected < minSelected) { result.errors.push({ id: 'validation.select-field.min-selected', field: fieldName, values: { currentSelected: selected, minSelected, }, }); } } } /** * Validates that the selected value(s) for a select field are among the valid options. */ export async function validateSelectFieldValidOptions({ result, data, mergedWebsiteOptions, }: ValidatorParams) { const fields = mergedWebsiteOptions.getFormFields(); for (const [fieldName, field] of Object.entries(fields)) { if (!isSelectField(field)) continue; if (!field.options) continue; // Skip if field has no value if ( data.options[fieldName] === undefined || data.options[fieldName] === null ) { continue; } // Get the current value(s) const currentValue = data.options[fieldName]; // Skip if no value if ( currentValue === undefined || currentValue === null || currentValue === '' ) continue; // Handle discriminator-based options differently (like overallFileType) if ('discriminator' in field.options) { // Skip validation for discriminator-based options as they're dynamically determined continue; } // Flatten all available options to a simple array of values const availableOptions = flattenSelectOptions(field.options); if (field.allowMultiple) { // For multi-select, validate each selected value if (Array.isArray(currentValue)) { const invalidOptions = currentValue.filter( (value) => !availableOptions.includes(value), ); if (invalidOptions.length > 0) { result.errors.push({ id: 'validation.select-field.invalid-option', field: fieldName, values: { invalidOptions, fieldName, fieldLabel: field.label, }, }); } } } else if (currentValue && !availableOptions.includes(currentValue)) { // For single-select, validate the selected value result.errors.push({ id: 'validation.select-field.invalid-option', field: fieldName, values: { invalidOptions: [currentValue], }, }); } } } /** * Helper function to flatten nested select options into a single array of values */ function flattenSelectOptions(options: SelectOption[]): string[] { const result: string[] = []; for (const option of options) { if ('items' in option && Array.isArray(option.items)) { // This is a group of options for (const item of option.items) { if ('value' in item) { result.push(String(item.value)); } } } else if ('value' in option) { // This is a single option result.push(String(option.value)); } } return result; } ================================================ FILE: apps/client-server/src/app/validation/validators/tag-validators.ts ================================================ import { ValidatorParams } from './validator.type'; export async function validateMaxTags({ data, mergedWebsiteOptions, validator, }: ValidatorParams) { const { hidden, maxTags } = mergedWebsiteOptions.getFormFieldFor('tags'); if (hidden) return; const { tags } = data.options; const maxLength = maxTags ?? Number.MAX_SAFE_INTEGER; if (tags.length > maxLength) { validator.warning( 'validation.tags.max-tags', { maxLength, currentLength: tags.length }, 'tags', ); } } export async function validateMinTags({ data, validator, mergedWebsiteOptions, }: ValidatorParams) { const tagField = mergedWebsiteOptions.getFormFieldFor('tags'); if (tagField.hidden) return; const { tags } = data.options; const minLength = tagField.minTags ?? -1; if (tags.length < minLength) { validator.error( 'validation.tags.min-tags', { currentLength: tags.length, minLength }, 'tags', ); } } export async function validateMaxTagLength({ data, mergedWebsiteOptions, validator, }: ValidatorParams) { const { hidden, maxTagLength } = mergedWebsiteOptions.getFormFieldFor('tags'); if (hidden) return; const { tags } = data.options; const maxLength = maxTagLength ?? Number.MAX_SAFE_INTEGER; const invalidTags = tags.filter((tag) => tag.length > maxLength); if (invalidTags.length > 0) { validator.warning( 'validation.tags.max-tag-length', { tags: invalidTags, maxLength }, 'tags', ); } } export async function validateTagHashtag({ data, mergedWebsiteOptions, validator, }: ValidatorParams) { const { hidden } = mergedWebsiteOptions.getFormFieldFor('tags'); if (hidden) return; const { tags } = data.options; const invalidTags = tags.filter((tag) => tag.startsWith('#')); if (invalidTags.length > 0) { validator.error( 'validation.tags.double-hashtag', { tags: invalidTags }, 'tags', ); } } ================================================ FILE: apps/client-server/src/app/validation/validators/title-validators.ts ================================================ import { ValidatorParams } from './validator.type'; export async function validateTitleMaxLength({ data, mergedWebsiteOptions, validator, }: ValidatorParams) { const { hidden, maxLength } = mergedWebsiteOptions.getFormFieldFor('title'); if (hidden) return; const { title } = data.options; const maxTitleLength = maxLength ?? Number.MAX_SAFE_INTEGER; if (title.length > maxLength) { validator.warning( 'validation.title.max-length', { currentLength: title.length, maxLength: maxTitleLength }, 'title', ); } } export async function validateTitleMinLength({ data, mergedWebsiteOptions, validator, }: ValidatorParams) { const { hidden, minLength } = mergedWebsiteOptions.getFormFieldFor('title'); if (hidden) return; const { title } = data.options; const minTitleLength = minLength ?? -1; if (title.length < minTitleLength) { validator.error( 'validation.title.min-length', { currentLength: title.length, minLength: minTitleLength }, 'title', ); } } ================================================ FILE: apps/client-server/src/app/validation/validators/validator.type.ts ================================================ import { ISubmission, PostData, ValidationMessage, ValidationResult, } from '@postybirb/types'; import { FileConverterService } from '../../file-converter/file-converter.service'; import { FileService } from '../../file/file.service'; import { SubmissionValidator } from '../../websites/commons/validator'; import { BaseWebsiteOptions } from '../../websites/models/base-website-options'; import { UnknownWebsite } from '../../websites/website'; export type ValidatorParams = { result: ValidationResult; validator: FieldValidator; websiteInstance: UnknownWebsite; data: PostData; submission: ISubmission; fileConverterService: FileConverterService; fileService: FileService; mergedWebsiteOptions: BaseWebsiteOptions; }; export type Validator = (props: ValidatorParams) => Promise; export class FieldValidator extends SubmissionValidator { constructor( override errors: ValidationMessage[], override warnings: ValidationMessage[], ) { super(); } } ================================================ FILE: apps/client-server/src/app/web-socket/models/web-socket-event.ts ================================================ export abstract class WebsocketEvent { event: string; data: D; constructor(data: D) { this.data = data; } } ================================================ FILE: apps/client-server/src/app/web-socket/web-socket-adapter.ts ================================================ import { IoAdapter } from '@nestjs/platform-socket.io'; import { ServerOptions } from 'socket.io'; export class WebSocketAdapter extends IoAdapter { createIOServer( port: number, options?: ServerOptions & { namespace?: string; server?: unknown; }, ) { const server = super.createIOServer(port, { ...options, cors: true }); return server; } } ================================================ FILE: apps/client-server/src/app/web-socket/web-socket-gateway.ts ================================================ import { OnGatewayInit, WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; import { getRemoteConfig } from '@postybirb/utils/electron'; import { Server } from 'socket.io'; import { WebSocketEvents } from './web-socket.events'; @WebSocketGateway({ cors: true }) export class WSGateway implements OnGatewayInit { @WebSocketServer() private server: Server; afterInit(server: Server) { server.use(async (socket, next) => { const remoteConfig = await getRemoteConfig(); if (socket.handshake.headers.authorization === remoteConfig.password) { return next(); } return next(new Error('Authentication Error')); }); } public emit(socketEvent: WebSocketEvents) { const { event, data } = socketEvent; this.server.emit(event, data); } } ================================================ FILE: apps/client-server/src/app/web-socket/web-socket.events.ts ================================================ import { AccountEventTypes } from '../account/account.events'; import { CustomShortcutEventTypes } from '../custom-shortcuts/custom-shortcut.events'; import { DirectoryWatcherEventTypes } from '../directory-watchers/directory-watcher.events'; import { NotificationEventTypes } from '../notifications/notification.events'; import { SettingsEventTypes } from '../settings/settings.events'; import { SubmissionEventTypes } from '../submission/submission.events'; import { TagConverterEventTypes } from '../tag-converters/tag-converter.events'; import { TagGroupEventTypes } from '../tag-groups/tag-group.events'; import { UpdateEventTypes } from '../update/update.events'; import { UserConverterEventTypes } from '../user-converters/user-converter.events'; import { WebsiteEventTypes } from '../websites/website.events'; export type WebSocketEvents = | AccountEventTypes | DirectoryWatcherEventTypes | SettingsEventTypes | SubmissionEventTypes | TagGroupEventTypes | TagConverterEventTypes | UpdateEventTypes | UserConverterEventTypes | WebsiteEventTypes | NotificationEventTypes | CustomShortcutEventTypes; ================================================ FILE: apps/client-server/src/app/web-socket/web-socket.module.ts ================================================ import { Global, Module } from '@nestjs/common'; import { WSGateway } from './web-socket-gateway'; @Global() @Module({ providers: [WSGateway], exports: [WSGateway], }) export class WebSocketModule {} ================================================ FILE: apps/client-server/src/app/website-options/dtos/create-website-options.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { AccountId, ICreateWebsiteOptionsDto, IWebsiteFormFields, SubmissionId, } from '@postybirb/types'; import { IsObject, IsOptional, IsString } from 'class-validator'; export class CreateWebsiteOptionsDto implements ICreateWebsiteOptionsDto { @ApiProperty() @IsString() accountId: AccountId; @ApiProperty({ type: Object }) @IsOptional() @IsObject() data: IWebsiteFormFields; @ApiProperty() @IsString() submissionId: SubmissionId; isDefault?: boolean = false; } ================================================ FILE: apps/client-server/src/app/website-options/dtos/preview-description.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { EntityId, IPreviewDescriptionDto, SubmissionId, } from '@postybirb/types'; import { IsString } from 'class-validator'; export class PreviewDescriptionDto implements IPreviewDescriptionDto { @ApiProperty() @IsString() submissionId: SubmissionId; @ApiProperty() @IsString() websiteOptionId: EntityId; } ================================================ FILE: apps/client-server/src/app/website-options/dtos/update-submission-website-options.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { EntityId, ICreateWebsiteOptionsDto, IUpdateSubmissionWebsiteOptionsDto, } from '@postybirb/types'; import { IsArray, IsOptional } from 'class-validator'; export class UpdateSubmissionWebsiteOptionsDto implements IUpdateSubmissionWebsiteOptionsDto { @ApiProperty() @IsOptional() @IsArray() remove?: EntityId[]; @ApiProperty() @IsOptional() @IsArray() add?: ICreateWebsiteOptionsDto[]; } ================================================ FILE: apps/client-server/src/app/website-options/dtos/update-website-options.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IUpdateWebsiteOptionsDto, IWebsiteFormFields } from '@postybirb/types'; import { IsObject } from 'class-validator'; export class UpdateWebsiteOptionsDto implements IUpdateWebsiteOptionsDto { @ApiProperty({ type: Object }) @IsObject() data: IWebsiteFormFields; } ================================================ FILE: apps/client-server/src/app/website-options/dtos/validate-website-options.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { EntityId, IValidateWebsiteOptionsDto, SubmissionId, } from '@postybirb/types'; import { IsString } from 'class-validator'; export class ValidateWebsiteOptionsDto implements IValidateWebsiteOptionsDto { @ApiProperty() @IsString() submissionId: SubmissionId; @ApiProperty() @IsString() websiteOptionId: EntityId; } ================================================ FILE: apps/client-server/src/app/website-options/website-options.controller.ts ================================================ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { EntityId, SubmissionId } from '@postybirb/types'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { CreateWebsiteOptionsDto } from './dtos/create-website-options.dto'; import { PreviewDescriptionDto } from './dtos/preview-description.dto'; import { UpdateSubmissionWebsiteOptionsDto } from './dtos/update-submission-website-options.dto'; import { UpdateWebsiteOptionsDto } from './dtos/update-website-options.dto'; import { ValidateWebsiteOptionsDto } from './dtos/validate-website-options.dto'; import { WebsiteOptionsService } from './website-options.service'; /** * CRUD operation on WebsiteOptions. * * @class WebsiteOptionsController */ @ApiTags('website-option') @Controller('website-option') export class WebsiteOptionsController extends PostyBirbController<'WebsiteOptionsSchema'> { constructor(readonly service: WebsiteOptionsService) { super(service); } @Post() @ApiOkResponse({ description: 'Website option created.' }) @ApiBadRequestResponse({ description: 'Bad request.', }) @ApiNotFoundResponse({ description: 'Account or website instance not found.', }) create( @Body() createDto: CreateWebsiteOptionsDto, ) { return this.service.create(createDto).then((entity) => entity.toDTO()); } @Patch(':id') @ApiOkResponse({ description: 'Submission option updated.', type: Boolean }) @ApiNotFoundResponse({ description: 'Submission option Id not found.' }) update( @Body() updateDto: UpdateWebsiteOptionsDto, @Param('id') id: EntityId, ) { return this.service.update(id, updateDto).then((entity) => entity.toDTO()); } @Patch('submission/:id') @ApiOkResponse({ description: 'Submission updated.', type: Boolean }) @ApiNotFoundResponse({ description: 'Submission Id not found.' }) updateSubmission( @Body() updateDto: UpdateSubmissionWebsiteOptionsDto, @Param('id') submissionId: SubmissionId, ) { return this.service .updateSubmissionOptions(submissionId, updateDto) .then((entity) => entity.toDTO()); } @Post('validate') @ApiOkResponse({ description: 'Submission validation completed.' }) @ApiBadRequestResponse() @ApiNotFoundResponse({ description: 'Submission not found.' }) validate(@Body() validateOptionsDto: ValidateWebsiteOptionsDto) { return this.service.validateWebsiteOption(validateOptionsDto); } @Get('validate/:submissionId') @ApiOkResponse({ description: 'Submission validation completed.' }) @ApiBadRequestResponse() @ApiNotFoundResponse({ description: 'Submission not found.' }) validateSubmission(@Param('submissionId') submissionId: SubmissionId) { return this.service.validateSubmission(submissionId); } @Post('preview-description') @ApiOkResponse({ description: 'Description preview generated.' }) @ApiBadRequestResponse() @ApiNotFoundResponse({ description: 'Submission or option not found.' }) previewDescription(@Body() dto: PreviewDescriptionDto) { return this.service.previewDescription(dto); } } ================================================ FILE: apps/client-server/src/app/website-options/website-options.module.ts ================================================ import { forwardRef, Module } from '@nestjs/common'; import { AccountModule } from '../account/account.module'; import { FormGeneratorModule } from '../form-generator/form-generator.module'; import { PostParsersModule } from '../post-parsers/post-parsers.module'; import { SubmissionModule } from '../submission/submission.module'; import { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module'; import { ValidationModule } from '../validation/validation.module'; import { WebsitesModule } from '../websites/websites.module'; import { WebsiteOptionsController } from './website-options.controller'; import { WebsiteOptionsService } from './website-options.service'; @Module({ imports: [ forwardRef(() => SubmissionModule), WebsitesModule, AccountModule, UserSpecifiedWebsiteOptionsModule, FormGeneratorModule, ValidationModule, PostParsersModule, ], providers: [WebsiteOptionsService], controllers: [WebsiteOptionsController], exports: [WebsiteOptionsService], }) export class WebsiteOptionsModule {} ================================================ FILE: apps/client-server/src/app/website-options/website-options.service.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { clearDatabase } from '@postybirb/database'; import { DefaultDescriptionValue, DefaultTagValue, SubmissionRating, SubmissionType, TipTapNode, } from '@postybirb/types'; import { AccountModule } from '../account/account.module'; import { AccountService } from '../account/account.service'; import { CreateAccountDto } from '../account/dtos/create-account.dto'; import { FileConverterService } from '../file-converter/file-converter.service'; import { FileService } from '../file/file.service'; import { CreateFileService } from '../file/services/create-file.service'; import { UpdateFileService } from '../file/services/update-file.service'; import { SharpInstanceManager } from '../image-processing/sharp-instance-manager'; import { FormGeneratorModule } from '../form-generator/form-generator.module'; import { PostParsersModule } from '../post-parsers/post-parsers.module'; import { CreateSubmissionDto } from '../submission/dtos/create-submission.dto'; import { FileSubmissionService } from '../submission/services/file-submission.service'; import { MessageSubmissionService } from '../submission/services/message-submission.service'; import { SubmissionService } from '../submission/services/submission.service'; import { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module'; import { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service'; import { ValidationService } from '../validation/validation.service'; import { WebsiteImplProvider } from '../websites/implementations/provider'; import { WebsiteRegistryService } from '../websites/website-registry.service'; import { WebsitesModule } from '../websites/websites.module'; import { CreateWebsiteOptionsDto } from './dtos/create-website-options.dto'; import { WebsiteOptionsService } from './website-options.service'; describe('WebsiteOptionsService', () => { let service: WebsiteOptionsService; let submissionService: SubmissionService; let accountService: AccountService; let module: TestingModule; async function createAccount() { const dto = new CreateAccountDto(); dto.groups = ['test']; dto.name = 'test'; dto.website = 'test'; const record = await accountService.create(dto); return record; } async function createSubmission() { const dto = new CreateSubmissionDto(); dto.name = 'test'; dto.type = SubmissionType.MESSAGE; const record = await submissionService.create(dto); return record; } beforeEach(async () => { clearDatabase(); try { module = await Test.createTestingModule({ imports: [ WebsitesModule, AccountModule, UserSpecifiedWebsiteOptionsModule, PostParsersModule, FormGeneratorModule, ], providers: [ SubmissionService, CreateFileService, UpdateFileService, SharpInstanceManager, FileService, SubmissionService, FileSubmissionService, MessageSubmissionService, AccountService, WebsiteRegistryService, ValidationService, WebsiteOptionsService, WebsiteImplProvider, UserSpecifiedWebsiteOptionsService, FileConverterService, ], }).compile(); service = module.get(WebsiteOptionsService); submissionService = module.get(SubmissionService); accountService = module.get(AccountService); await accountService.onModuleInit(); } catch (e) { console.error(e); } }); afterAll(async () => { await module.close(); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should create entity', async () => { const account = await createAccount(); const submission = await createSubmission(); const dto = new CreateWebsiteOptionsDto(); dto.data = { contentWarning: '', title: 'title', tags: DefaultTagValue(), description: DefaultDescriptionValue(), rating: SubmissionRating.GENERAL, }; dto.accountId = account.id; dto.submissionId = submission.id; const record = await service.create(dto); const groups = await service.findAll(); expect(groups).toHaveLength(2); // 2 because default expect(groups[1].accountId).toEqual(dto.accountId); expect(groups[1].isDefault).toEqual(false); expect(groups[1].data).toEqual(dto.data); expect(groups[1].submission.id).toEqual(dto.submissionId); expect(record.toDTO()).toEqual({ data: record.data, isDefault: false, id: record.id, accountId: account.id, createdAt: record.createdAt, updatedAt: record.updatedAt, account: record.account.toObject(), submissionId: submission.id, submission: record.submission.toDTO(), }); }); it('should remove entity', async () => { const account = await createAccount(); const submission = await createSubmission(); const dto = new CreateWebsiteOptionsDto(); dto.data = { title: 'title', tags: DefaultTagValue(), description: DefaultDescriptionValue(), rating: SubmissionRating.GENERAL, }; dto.accountId = account.id; dto.submissionId = submission.id; const record = await service.create(dto); expect(await service.findAll()).toHaveLength(2); // 2 because default await service.remove(record.id); expect(await service.findAll()).toHaveLength(1); }); it('should remove entity when parent is removed', async () => { const account = await createAccount(); const submission = await createSubmission(); const dto = new CreateWebsiteOptionsDto(); dto.data = { title: 'title', tags: DefaultTagValue(), description: DefaultDescriptionValue(), rating: SubmissionRating.GENERAL, }; dto.accountId = account.id; dto.submissionId = submission.id; await service.create(dto); expect(await service.findAll()).toHaveLength(2); // 2 because default await submissionService.remove(submission.id); expect(await service.findAll()).toHaveLength(0); }); it('should update entity', async () => { const account = await createAccount(); const submission = await createSubmission(); const dto = new CreateWebsiteOptionsDto(); dto.data = { contentWarning: '', title: 'title', tags: DefaultTagValue(), description: DefaultDescriptionValue(), rating: SubmissionRating.GENERAL, }; dto.accountId = account.id; dto.submissionId = submission.id; const record = await service.create(dto); expect(record.accountId).toEqual(dto.accountId); expect(record.isDefault).toEqual(false); expect(record.data).toEqual(dto.data); expect(record.submission.id).toEqual(dto.submissionId); const update = await service.update(record.id, { data: { title: 'title updated', tags: DefaultTagValue(), description: DefaultDescriptionValue(), rating: SubmissionRating.GENERAL, }, }); expect(update.data.title).toEqual('title updated'); }); it('filters nested inline content', async () => { const blocks: TipTapNode[] = [ { type: 'paragraph', content: [ { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] }, { type: 'customShortcut', attrs: { id: 'to-delete', }, content: [ { type: 'text', text: 'User', }, ], }, ], }, ]; const { changed, filtered } = service.filterCustomShortcut( blocks, 'to-delete', ); expect(changed).toBeTruthy(); expect(filtered).toEqual([ { type: 'paragraph', content: [{ type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] }], }, ]); }); }); ================================================ FILE: apps/client-server/src/app/website-options/website-options.service.ts ================================================ import { forwardRef, Inject, Injectable, NotFoundException, OnModuleInit, } from '@nestjs/common'; import { Insert } from '@postybirb/database'; import { AccountId, Description, DescriptionType, DescriptionValue, DynamicObject, EntityId, IDescriptionPreviewResult, ISubmission, ISubmissionMetadata, IWebsiteFormFields, NULL_ACCOUNT_ID, SubmissionId, SubmissionMetadataType, SubmissionType, TipTapNode, ValidationResult, } from '@postybirb/types'; import { AccountService } from '../account/account.service'; import { PostyBirbService } from '../common/service/postybirb-service'; import { Account, Submission, WebsiteOptions } from '../drizzle/models'; import { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database'; import { FormGeneratorService } from '../form-generator/form-generator.service'; import { PostParsersService } from '../post-parsers/post-parsers.service'; import { SubmissionService } from '../submission/services/submission.service'; import { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service'; import { isBlockNoteFormat, migrateDescription, } from '../utils/blocknote-to-tiptap'; import { ValidationService } from '../validation/validation.service'; import DefaultWebsite from '../websites/implementations/default/default.website'; import { DefaultWebsiteOptions } from '../websites/models/default-website-options'; import { WebsiteRegistryService } from '../websites/website-registry.service'; import { CreateWebsiteOptionsDto } from './dtos/create-website-options.dto'; import { PreviewDescriptionDto } from './dtos/preview-description.dto'; import { UpdateSubmissionWebsiteOptionsDto } from './dtos/update-submission-website-options.dto'; import { UpdateWebsiteOptionsDto } from './dtos/update-website-options.dto'; import { ValidateWebsiteOptionsDto } from './dtos/validate-website-options.dto'; @Injectable() export class WebsiteOptionsService extends PostyBirbService<'WebsiteOptionsSchema'> implements OnModuleInit { private readonly submissionRepository = new PostyBirbDatabase( 'SubmissionSchema', ); constructor( @Inject(forwardRef(() => SubmissionService)) private readonly submissionService: SubmissionService, private readonly accountService: AccountService, private readonly userSpecifiedOptionsService: UserSpecifiedWebsiteOptionsService, private readonly formGeneratorService: FormGeneratorService, private readonly validationService: ValidationService, private readonly postParsersService: PostParsersService, private readonly websiteRegistry: WebsiteRegistryService, ) { super( new PostyBirbDatabase('WebsiteOptionsSchema', { account: true, submission: true, }), ); this.repository.subscribe('CustomShortcutSchema', (ids, action) => { if (action === 'delete') { for (const id of ids) { this.onCustomShortcutDelete(id).catch((err) => this.logger.error( `Error handling custom shortcut delete for id '${id}': ${err.message}`, err.stack, ), ); } } }); } async onModuleInit() { await this.migrateBlockNoteDescriptions(); } /** * One-time migration: convert any BlockNote-format descriptions * (stored as arrays) to TipTap format ({ type: 'doc', content: [] }). * Covers website options, custom shortcuts, and user-specified website options. */ private async migrateBlockNoteDescriptions() { let migrated = 0; // 1. Migrate website options const options = await this.findAll(); for (const option of options) { const descValue = option.data?.description as | DescriptionValue | undefined; const desc = descValue?.description; if (desc && isBlockNoteFormat(desc)) { const converted = migrateDescription(desc); await this.repository.update(option.id, { data: { ...option.data, description: { ...descValue, description: converted, }, }, }); migrated++; } } // 2. Migrate custom shortcuts const customShortcutRepo = new PostyBirbDatabase('CustomShortcutSchema'); const shortcuts = await customShortcutRepo.findAll(); for (const shortcut of shortcuts) { const desc = (shortcut as DynamicObject).shortcut; if (desc && isBlockNoteFormat(desc)) { const converted = migrateDescription(desc); await customShortcutRepo.update(shortcut.id, { shortcut: converted, } as unknown); migrated++; } } // 3. Migrate user-specified website options const userOptsRepo = new PostyBirbDatabase( 'UserSpecifiedWebsiteOptionsSchema', ); const userOpts = await userOptsRepo.findAll(); for (const userOpt of userOpts) { const opts = (userOpt as DynamicObject).options as DynamicObject; if (opts?.description) { const descValue = opts.description as DescriptionValue | undefined; const desc = descValue?.description; if (desc && isBlockNoteFormat(desc)) { const converted = migrateDescription(desc); await userOptsRepo.update(userOpt.id, { options: { ...opts, description: { ...descValue, description: converted, }, }, } as unknown); migrated++; } } } if (migrated > 0) { this.logger.info( `Migrated ${migrated} BlockNote description(s) to TipTap format`, ); } } /** * Creates a submission option for a submission. * No longer remember why this is a separate method from create. * * @param {ISubmission} submission * @param {AccountId} accountId * @param {DynamicObject} data * @param {string} [title] * @return {*} {Promise} */ async createOption( submission: ISubmission, accountId: AccountId, data: DynamicObject, title?: string, ): Promise { const option = await this.createOptionInsertObject( submission, accountId, data, title, ); return this.repository.insert(option); } /** * Creates a submission option for a submission. * * @param {Submission} submission * @param {AccountId} accountId * @param {DynamicObject} data * @param {string} [title] */ async createOptionInsertObject( submission: ISubmission, accountId: AccountId, data: DynamicObject, title?: string, ): Promise> { const account = await this.accountService.findById(accountId, { failOnMissing: true, }); const isDefault = accountId === NULL_ACCOUNT_ID; const userDefinedDefaultOptions = await this.userSpecifiedOptionsService.findByAccountAndSubmissionType( accountId, submission.type, ); const formFields = isDefault ? await this.formGeneratorService.getDefaultForm(submission.type) : await this.formGeneratorService.generateForm({ accountId: account.id, type: submission.type, }); // Populate with the form fields to get the default values const websiteData: IWebsiteFormFields = { ...Object.entries(formFields).reduce( (acc, [key, field]) => ({ ...acc, [key]: field.defaultValue, }), {} as IWebsiteFormFields, ), }; const mergedData: IWebsiteFormFields = { ...(isDefault ? new DefaultWebsiteOptions() : {}), // Only merge default options if this is the default option ...websiteData, // Merge default form fields ...(userDefinedDefaultOptions?.options ?? {}), // Merge user defined options ...data, // Merge user defined data title, // Override title (optional) }; // For non-default options, keep rating as undefined to represent // "inherit from default" mode unless explicitly provided in data. if (!isDefault && !data.rating) { mergedData.rating = undefined as unknown as typeof mergedData.rating; } const option: Insert<'WebsiteOptionsSchema'> = { submissionId: submission.id, accountId: account.id, data: mergedData, isDefault, }; return option; } /** * The default create method for WebsiteOptions. * Performs user saved options and other merging operations. * Performs and update if it already exists. * * @param {CreateWebsiteOptionsDto} createDto * @return {*} */ async create(createDto: CreateWebsiteOptionsDto) { const account = await this.accountService.findById(createDto.accountId, { failOnMissing: true, }); let submission: ISubmission; try { submission = await this.submissionRepository.findById( createDto.submissionId, { failOnMissing: true }, ); } catch (err) { throw new NotFoundException( `Submission ${createDto.submissionId} not found.`, ); } const exists = await this.repository.findOne({ where: (wo, { and, eq }) => and(eq(wo.submissionId, submission.id), eq(wo.accountId, account.id)), }); if (exists) { // Opt to just update the existing option return this.update(exists.id, { data: createDto.data }); } const formFields = account.id === NULL_ACCOUNT_ID ? await this.formGeneratorService.getDefaultForm(submission.type) : await this.formGeneratorService.generateForm({ accountId: account.id, type: submission.type, }); // Populate with the form fields to get the default values const websiteData: IWebsiteFormFields = { ...Object.entries(formFields).reduce( (acc, [key, field]) => ({ ...acc, [key]: createDto.data?.[key as keyof IWebsiteFormFields] === undefined ? field.defaultValue : createDto.data?.[key as keyof IWebsiteFormFields], }), {} as IWebsiteFormFields, ), }; const isDefault = account.id === NULL_ACCOUNT_ID; // For non-default options, keep rating as undefined to represent // "inherit from default" mode unless explicitly provided in the DTO. if (!isDefault && createDto.data?.rating === undefined) { websiteData.rating = undefined as unknown as typeof websiteData.rating; } const record = await this.repository.insert({ submissionId: submission.id, data: websiteData, accountId: account.id, isDefault, }); this.submissionService.emit(); return record; } async update(id: EntityId, update: UpdateWebsiteOptionsDto) { this.logger.withMetadata(update).info(`Updating WebsiteOptions '${id}'`); const result = await this.repository.update(id, update); this.submissionService.emit(); return result; } /** * Creates the default submission option that stores shared data * across multiple submission options. * * @param {Submission} submission * @param {string} title * @param {Partial} [defaultOptions] - Optional default options to merge * @return {*} {Promise} */ async createDefaultSubmissionOptions( submission: ISubmission, title: string, defaultOptions?: Partial, ): Promise { this.logger .withMetadata({ id: submission.id }) .info('Creating Default Website Options'); const options: Insert<'WebsiteOptionsSchema'> = { isDefault: true, submissionId: submission.id, accountId: NULL_ACCOUNT_ID, data: await this.populateDefaultWebsiteOptions( NULL_ACCOUNT_ID, submission.type, title, defaultOptions, ), }; return this.repository.insert(options); } private async populateDefaultWebsiteOptions( accountId: AccountId, type: SubmissionType, title?: string, defaultOptions?: Partial, ): Promise { const userSpecifiedOptions = ( await this.userSpecifiedOptionsService.findByAccountAndSubmissionType( NULL_ACCOUNT_ID, type, ) )?.options ?? {}; const websiteFormFields: IWebsiteFormFields = { ...new DefaultWebsiteOptions(), ...userSpecifiedOptions, title, }; // Merge provided default options (tags, description, rating) if (defaultOptions) { if (defaultOptions.tags) { websiteFormFields.tags = { overrideDefault: false, tags: defaultOptions.tags.tags ?? defaultOptions.tags.tags ?? [], }; } if (defaultOptions.description) { websiteFormFields.description = defaultOptions.description; } if (defaultOptions.rating) { websiteFormFields.rating = defaultOptions.rating; } } return websiteFormFields; } /** * Validates a submission option against a website instance. * @param {ValidateWebsiteOptionsDto} validate * @return {Promise} */ async validateWebsiteOption( validate: ValidateWebsiteOptionsDto, ): Promise { const { websiteOptionId, submissionId } = validate; const submission = await this.submissionService.findById(submissionId, { failOnMissing: true, }); const websiteOption = submission.options.find( (option) => option.id === websiteOptionId, ); return this.validationService.validate(submission, websiteOption); } /** * Validates all submission options for a submission. * Accepts either a submission ID (will fetch from DB) or a Submission object directly. * When a Submission object is provided, it avoids a redundant database query. * @param {SubmissionId | Submission} submissionOrId * @return {*} {Promise} */ async validateSubmission( submissionOrId: SubmissionId | Submission, ): Promise { const submission = typeof submissionOrId === 'string' ? await this.submissionService.findById(submissionOrId) : submissionOrId; return this.validationService.validateSubmission(submission); } /** * Previews the parsed description for a specific website option. * Parses the description the same way it would be parsed during posting, * and returns both the output format type and the rendered string. * @param {PreviewDescriptionDto} dto * @return {Promise} */ async previewDescription( dto: PreviewDescriptionDto, ): Promise { const { websiteOptionId, submissionId } = dto; const submission = await this.submissionService.findById(submissionId, { failOnMissing: true, }); const websiteOption = submission.options.find( (option) => option.id === websiteOptionId, ); if (!websiteOption) { throw new NotFoundException( `Website option ${websiteOptionId} not found`, ); } const website = websiteOption.isDefault ? new DefaultWebsite(new Account(websiteOption.account)) : this.websiteRegistry.findInstance(websiteOption.account); if (!website) { throw new NotFoundException( `Website instance for account ${websiteOption.accountId} not found`, ); } const data = await this.postParsersService.parse( submission, website, websiteOption, ); // Determine the description output type using the same logic as the parser const defaultOptions = submission.options.find((o) => o.isDefault); const defaultOpts = Object.assign(new DefaultWebsiteOptions(), { ...defaultOptions.data, }); const websiteOpts = Object.assign(website.getModelFor(submission.type), { ...websiteOption.data, }); const mergedOptions = websiteOpts.mergeDefaults(defaultOpts); const { descriptionType } = mergedOptions.getFormFieldFor('description'); return { descriptionType: descriptionType as DescriptionType, description: data.options.description ?? '', }; } async updateSubmissionOptions( submissionId: SubmissionId, updateDto: UpdateSubmissionWebsiteOptionsDto, ) { const submission = await this.submissionService.findById(submissionId, { failOnMissing: true, }); const { remove, add } = updateDto; if (remove?.length) { const items = submission.options; const removableIds = []; for (const id of remove) { const option = items.find((opt) => opt.id === id); if (option) { removableIds.push(id); } } this.logger.debug( `Removing option(s) [${removableIds.join(', ')}] from submission ${submissionId}`, ); await this.repository.deleteById(removableIds); } if (add?.length) { const options = await Promise.all( add.map((dto) => this.createOptionInsertObject(submission, dto.accountId, dto.data), ), ); await this.repository.insert(options); } this.submissionService.emit(); return this.submissionService.findById(submissionId); } private async onCustomShortcutDelete(id: EntityId) { const websiteOptions = await this.findAll(); for (const option of websiteOptions) { const { data } = option; const descValue: DescriptionValue | undefined = data?.description; const doc: Description | undefined = descValue?.description; const blocks = doc?.content; if (!blocks || !Array.isArray(blocks) || blocks.length === 0) { continue; } const { changed, filtered } = this.filterCustomShortcut( blocks, String(id), ); if (changed) { const updatedDescription: DescriptionValue = { ...(descValue as DescriptionValue), description: { type: 'doc', content: filtered }, }; await this.repository.update(option.id, { data: { ...data, description: updatedDescription, }, }); this.submissionService.emit(); } } } /** * Removes inline customShortcut items matching the given id from a TipTap content array. * Simple recursive filter without whitespace normalization. */ public filterCustomShortcut( blocks: TipTapNode[], deleteId: string, ): { changed: boolean; filtered: TipTapNode[]; } { let changed = false; const isObject = (v: unknown): v is Record => typeof v === 'object' && v !== null; const filterInline = (content: unknown[]): unknown[] => { const out: unknown[] = []; for (const node of content) { if (!isObject(node)) { out.push(node); continue; } const { type, attrs, content: nodeContent, } = node as { type?: string; attrs?: Record; content?: unknown[]; }; if (type === 'customShortcut' && String(attrs?.id ?? '') === deleteId) { changed = true; continue; // drop this inline } // Recurse if this inline node has its own content if (Array.isArray(nodeContent)) { const clone = { ...node } as Record & { content?: unknown[]; }; clone.content = filterInline(nodeContent); out.push(clone); } else { out.push(node); } } return out; }; const filterBlocks = (arr: TipTapNode[]): TipTapNode[] => arr.map((blk) => { const clone: TipTapNode = { ...blk }; if (Array.isArray(clone.content)) { clone.content = filterInline(clone.content) as TipTapNode[]; } return clone; }); const filtered = filterBlocks(blocks); return { changed, filtered }; } } ================================================ FILE: apps/client-server/src/app/websites/commons/post-builder.spec.ts ================================================ import { IFileBuffer } from '@postybirb/types'; import { FormFile } from '../../../../../../libs/http/src/lib/form-file'; // Direct import to avoid electron loading import { CancellableToken } from '../../post/models/cancellable-token'; import { PostingFile } from '../../post/models/posting-file'; import { PostBuilder } from './post-builder'; // Mocks const mockWebsite = { account: { id: 'test-account' }, constructor: { name: 'MockWebsite' }, }; jest.mock('@postybirb/logger', () => ({ Logger: () => ({ withMetadata: () => ({ debug: jest.fn() }), debug: jest.fn(), }), })); jest.mock('@postybirb/http', () => ({ Http: { post: jest.fn().mockResolvedValue({ statusCode: 200, body: { id: '123' } }), }, FormFile: FormFile, })); function createPostingFile(overrides = {}) { // Minimal IFileBuffer const fileBuffer: IFileBuffer = { id: 'file1', buffer: Buffer.from('test'), mimeType: 'image/png', width: 100, height: 100, fileName: 'file1.png', ...overrides, submissionFileId: '', size: 0, createdAt: '', updatedAt: '', }; return new PostingFile('file1', fileBuffer); } describe('PostBuilder', () => { let builder: PostBuilder; let token: CancellableToken; beforeEach(() => { token = new CancellableToken(); builder = new PostBuilder(mockWebsite as any, token); }); it('should set headers', () => { builder.withHeader('X-Test', 'abc'); expect((builder as any).headers['X-Test']).toBe('abc'); }); it('should set post type', () => { builder.asMultipart(); expect((builder as any).postType).toBe('multipart'); builder.asJson(); expect((builder as any).postType).toBe('json'); builder.asUrlEncoded(); expect((builder as any).postType).toBe('urlencoded'); }); it('should merge data with withData', () => { builder.withData({ a: 1, b: 2 }); builder.withData({ b: 3, c: 4 }); expect((builder as any).data).toEqual({ a: 1, b: 3, c: 4 }); }); it('should set, get, and remove fields', () => { builder.setField('foo', 'bar'); expect(builder.getField('foo')).toBe('bar'); builder.removeField('foo'); expect(builder.getField('foo')).toBeUndefined(); }); it('should set fields conditionally', () => { builder.setConditional('x', true, 1, 2); expect((builder as any).data['x']).toBe(1); builder.setConditional('y', false, 1, 2); expect((builder as any).data['y']).toBe(2); builder.setConditional('z', false, 1); expect((builder as any).data['z']).toBeUndefined(); }); it('should iterate with forEach', () => { builder.forEach(['a', 'b'], (item, idx, b) => { b.setField(`item${idx}`, item); }); expect((builder as any).data['item0']).toBe('a'); expect((builder as any).data['item1']).toBe('b'); }); it('should add files and thumbnails', () => { const file = createPostingFile(); builder.addFile('file', file); expect((builder as any).data['file']).toBeInstanceOf(Object); // FormFile builder.addFiles('files', [file, file]); expect(Array.isArray((builder as any).data['files'])).toBe(true); builder.addThumbnail('thumb', file); expect((builder as any).data['thumb']).toBeInstanceOf(Object); // FormFile }); it('should add image as thumbnail if no thumbnail', () => { const file = createPostingFile(); builder.addThumbnail('thumb', file); expect((builder as any).data['thumb']).toBeInstanceOf(Object); // FormFile }); it('should set empty string as thumbnail for non-image', () => { const file = createPostingFile({ fileName: 'file1.txt', mimeType: 'text/plain', }); builder.addThumbnail('thumb', file); expect((builder as any).data['thumb']).toBe(''); }); it('should call whenTrue only if predicate is true', () => { const cb = jest.fn(); builder.whenTrue(true, cb); expect(cb).toHaveBeenCalled(); cb.mockClear(); builder.whenTrue(false, cb); expect(cb).not.toHaveBeenCalled(); }); it('should build data for json and multipart', () => { builder.setField('a', true).setField('b', [true, false, undefined]); expect(builder.build()).toEqual({ a: true, b: [true, false, undefined] }); builder.asMultipart(); expect(builder.build()).toEqual({ a: 'true', b: ['true', 'false'] }); }); it('should sanitize file fields for logging', () => { const file = createPostingFile(); builder.addFile('file', file); const data = { file: builder.getField('file'), other: 123 }; const sanitized = (builder as any).sanitizeDataForLogging(data); expect(typeof sanitized.file).toBe('string'); expect(sanitized.other).toBe(123); }); it('should throw if cancelled before send', async () => { token.cancel(); await expect(builder.send('http://test')).rejects.toThrow( 'Task was cancelled.', ); }); it('should call Http.post and return value on send', async () => { const result = await builder.send<{ id: string }>('http://test'); expect(result.body.id).toBe('123'); }); it('should convert PostingFile to FormFile', () => { const file = createPostingFile(); const builder = new PostBuilder(mockWebsite as any, token); builder.setField('file', file); expect((builder as any).data['file']).toBeInstanceOf(FormFile); builder.addFile('file2', file); expect((builder as any).data['file2']).toBeInstanceOf(FormFile); }); }); ================================================ FILE: apps/client-server/src/app/websites/commons/post-builder.ts ================================================ import { FormFile, Http, HttpRequestOptions, PostOptions, } from '@postybirb/http'; import { Logger } from '@postybirb/logger'; import { FileType, PostResponse } from '@postybirb/types'; import { CancellableToken } from '../../post/models/cancellable-token'; import { PostingFile } from '../../post/models/posting-file'; import { UnknownWebsite } from '../website'; /** * Represents a field value that can be stored in the post data. */ type FieldValue = string | number | boolean | null | undefined | object; /** * Represents a value that can be either a single field value or an array of field values. */ type Value = FieldValue | FieldValue[]; /** * A builder class for constructing HTTP POST requests to various websites. * Uses the builder pattern to allow fluent method chaining for configuring * request data, headers, and content type. * * @example * ```typescript * const response = await new PostBuilder(website, cancellationToken) * .asMultipart() * .withHeader('Authorization', 'Bearer token') * .setField('title', 'My Post') * .addFile('image', postingFile) * .send('https://api.example.com/posts'); * ``` */ export class PostBuilder { private readonly logger = Logger('PostBuilder'); /** * The type of POST request to send (json, multipart, or urlencoded). * @private */ private postType: PostOptions['type'] = 'json'; /** * The data payload that will be sent in the POST request. * @private */ private data: Record = {}; /** * Custom HTTP request options to be send with the request. * @private * @type {HttpRequestOptions} */ private readonly httpRequestOptions: HttpRequestOptions = {}; /** * HTTP headers to include with the request. * @private */ private readonly headers: Record = {}; /** * Set of field names that are expected to contain file data based on input. * Used to enhance logging and debugging by identifying which fields * are intended for file uploads. * @private */ private readonly fileFields = new Set(); /** * When true, the request will be sent via Electron's BrowserWindow.loadURL * with raw data bytes instead of using net.request ClientRequest. * @private */ private rawData = false; /** * Creates a new PostBuilder instance. * * @param website - The website instance for which the post is being built * @param cancellationToken - Token used to cancel the request if needed */ constructor( private readonly website: UnknownWebsite, private readonly cancellationToken: CancellableToken, ) {} /** * Adds an HTTP header to the request. * * @param key - The header name * @param value - The header value * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.withHeader('Content-Type', 'application/json') * .withHeader('Authorization', 'Bearer token'); * ``` */ withHeader(key: string, value: string) { this.headers[key] = value; return this; } /** * Adds multiple headers to the request. * Merges the provided headers with existing ones. * * @param headers - Object containing key-value pairs of headers * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.withHeaders({ * 'Content-Type': 'application/json', * 'Authorization': 'Bearer token' * }); * ``` */ withHeaders(headers: Record) { Object.entries(headers).forEach(([key, value]) => { this.headers[key] = value; }); return this; } /** * Configures the request to use multipart/form-data encoding. * This is typically used when uploading files or sending binary data. * * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.asMultipart().addFile('image', file); * ``` */ asMultipart() { this.postType = 'multipart'; return this; } /** * Configures the request to use JSON encoding (default). * The request body will be sent as JSON with appropriate Content-Type header. * * @returns The PostBuilder instance for method chaining */ asJson() { this.postType = 'json'; return this; } /** * Configures the request to use URL-encoded form data. * The request body will be sent as application/x-www-form-urlencoded. * * @returns The PostBuilder instance for method chaining */ asUrlEncoded(skipIndex = false) { this.postType = 'urlencoded'; this.httpRequestOptions.skipUrlEncodedIndexing = skipIndex; return this; } /** * Configures the request to be sent via Electron's BrowserWindow.loadURL * with raw data bytes instead of the standard net.request flow. * Can be combined with any content type (multipart, json, urlencoded). * * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.asMultipart().asRawData().addFile('image', file).send(url); * ``` */ asRawData() { this.rawData = true; return this; } /** * Merges the provided data object with the existing request data. * Existing keys will be overwritten with new values. * * @param data - Object containing key-value pairs to add to the request * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.withData({ * title: 'My Post', * description: 'Post description', * tags: ['tag1', 'tag2'] * }); * ``` */ withData(data: Record) { this.data = { ...this.data, ...data }; return this; } getField(key: string): T | undefined { return this.data[key] as T | undefined; } removeField(key: string) { delete this.data[key]; if (this.fileFields.has(key)) { this.fileFields.delete(key); } return this; } /** * Sets a single field in the request data. * Handles null values by converting them to undefined. * * @param key - The field name * @param value - The field value (can be a single value or array) * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.setField('title', 'My Post Title') * .setField('tags', ['art', 'digital', 'illustration']); * ``` */ setField(key: string, value: Value) { this.insert(key, value); return this; } /** * Conditionally sets a field based on a predicate. * Useful for setting fields based on user preferences or feature flags. * * @param key - The field name * @param predicate - Boolean condition to evaluate * @param truthy - Value to set if predicate is true * @param falsy - Value to set if predicate is false (optional) * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.setConditional('nsfw', post.isNsfw, true, false) * .setConditional('rating', post.rating > 0, post.rating); * ``` */ setConditional( key: string, predicate: boolean, truthy: Value, falsy?: Value, ) { this.insert(key, predicate ? truthy : falsy); return this; } /** * Iterates over an array and executes a callback for each item. * * @param items - Array of items to iterate over * @param callback - Function to execute for each item * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.forEach(options.matureContent, (item, index, b) => { * b.setField(`attributes[${item}]`, 'true'); * }); * ``` */ forEach( items: T[] | undefined | null, callback: (item: T, index: number, builder: PostBuilder) => void, ) { if (items) { items.forEach((item, index) => callback(item, index, this)); } return this; } /** * Adds a file to the request data using the specified field name. * The file is converted to the appropriate post format. * * @param key - The field name for the file * @param file - The PostingFile instance to add * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.addFile('artwork', postingFile) * .addFile('reference', referenceFile); * ``` */ addFile(key: string, file: PostingFile | FormFile) { this.insert(key, file); return this; } /** * Adds multiple files to the request data under the specified field name. * Each file is converted to the appropriate post format. * * @param key - The field name for the files * @param files - Array of PostingFile instances to add * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.addFiles('images', [file1, file2, file3]); * ``` */ addFiles(key: string, files: PostingFile[]) { this.data[key] = files.map((file) => file.toPostFormat()); this.fileFields.add(key); return this; } /** * Adds a thumbnail to the request data. * If the file has a thumbnail, it uses that; otherwise, for image files, * it uses the original file as the thumbnail. * * @param key - The field name for the thumbnail * @param file - The PostingFile instance from which to extract the thumbnail * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.addFile('video', videoFile) * .addThumbnail('thumbnail', videoFile); * ``` */ addThumbnail(key: string, file: PostingFile) { if (file.thumbnail) { this.data[key] = file.thumbnailToPostFormat(); this.fileFields.add(key); } else if (file.fileType === FileType.IMAGE) { this.data[key] = file.toPostFormat(); this.fileFields.add(key); } else { this.data[key] = ''; } return this; } /** * Conditionally executes a callback based on a predicate. * * @param predicate - Boolean condition to evaluate * @param callback - Function to execute if predicate is true * @returns The PostBuilder instance for method chaining * * @example * ```typescript * builder.whenTrue(rating !== 'general', (b) => { * b.removeField('explicit'); * }); * ``` */ whenTrue(predicate: boolean, callback: (builder: PostBuilder) => void) { if (predicate) { callback(this); } return this; } /** * Sends the constructed POST request to the specified URL. * Validates the response and handles cancellation. * * @template ReturnValue - The expected type of the response body * @param url - The URL to send the POST request to * @returns Promise resolving to the response body * @throws {Error} If the request is cancelled or the response is invalid * * @example * ```typescript * interface ApiResponse { * id: string; * status: 'success' | 'error'; * } * * const response = await builder.send('https://api.example.com/posts'); * console.log(response.body.id); * ``` */ async send(url: string) { this.cancellationToken.throwIfCancelled(); const data = this.build(); this.logger .withMetadata({ website: this.website.constructor.name, postType: this.postType, url, headers: Object.keys(this.headers), data: this.sanitizeDataForLogging(data), httpRequestOptions: this.httpRequestOptions, }) .debug(`Sending ${this.postType} request to ${url} with data:`); const maxRetries = 2; let attempt = 0; let lastError: unknown; while (attempt <= maxRetries) { try { const value = await Http.post(url, { partition: this.website.account.id, type: this.postType, data, headers: this.headers, options: this.httpRequestOptions, uploadAsRawData: this.rawData, }); this.logger.debug(`Received response from ${url}:`, value.statusCode); PostResponse.validateBody(this.website, value, undefined, url); return value; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { const knownErrors = ['ECONNRESET', 'ERR_CONNECTION_RESET']; let isKnownError = false; for (const knownError of knownErrors) { const isKnown = error && (error.code === knownError || (typeof error.message === 'string' && error.message.includes(knownError))); if (isKnown) { attempt++; lastError = error; if (attempt > maxRetries) break; this.logger.debug( `Retrying request to ${url} due to ${knownError} (attempt ${attempt})`, ); isKnownError = true; break; } } // If the error is not a known retryable error, log it and throw if (!isKnownError) { this.logger.error( `Failed to send request to ${url} after ${attempt} attempts:`, error, ); throw error; } // If known error, continue loop (unless maxRetries exceeded) await new Promise((resolve) => { // Wait for 1 second before retrying setTimeout(resolve, 1_000); }); } } throw lastError; } /** * Builds and returns the final data object that will be sent in the request. * Handles special formatting for multipart requests: * - Removes undefined values * - Converts boolean values to strings * - Filters undefined values from arrays * - Converts boolean values in arrays to strings * * @returns The processed data object ready for transmission * * @example * ```typescript * const data = builder.build(); * // For multipart: { "nsfw": "true", "tags": ["art", "digital"] } * // For JSON: { "nsfw": true, "tags": ["art", "digital"] } * ``` */ public build(): Record { const data = { ...this.data }; if (this.postType === 'multipart') { Object.keys(data).forEach((key) => { // If the value is undefined and we don't allow it, delete the key // This is necessary for multipart/form-data where undefined values are not allowed if (data[key] === undefined) { delete data[key]; } if (typeof data[key] === 'boolean') { // Convert boolean values to string for multipart/form-data data[key] = data[key] ? 'true' : 'false'; } if (Array.isArray(data[key])) { // If the value is an array, filter out undefined values data[key] = (data[key] as FieldValue[]) .filter((v) => v !== undefined) .map((v) => { // Convert boolean values to string for multipart/form-data if (typeof v === 'boolean') { return v ? 'true' : 'false'; } return v; }); } }); } return data; } /** * Converts a PostingFile or FieldValue to the appropriate format for posting. * If the value is a PostingFile, it converts it to a FormFile. * * @param value - The value to convert * @returns The converted value in the appropriate format */ private convert(value: FieldValue | PostingFile): FieldValue | FormFile { if (value instanceof PostingFile) { return value.toPostFormat(); } return value; } /** * Inserts a key-value pair into the data object. * If the value is a PostingFile, it converts it to the appropriate format. * If the value is a FormFile, it adds the key to the fileFields set. * * @param key - The field name * @param value - The field value (can be a PostingFile or FieldValue) */ private insert( key: string, value: FieldValue | PostingFile | FormFile, ): void { const v = this.convert(value); this.data[key] = v; if (v instanceof FormFile) { this.fileFields.add(key); } } /** * Exposes ability to retrieve data for logging outside of the builder * if needed. */ public getSanitizedData(): Record { return this.sanitizeDataForLogging(this.data); } private sanitizeDataForLogging( data: Record, ): Record { const sanitizedData: Record = {}; for (const [key, value] of Object.entries(data)) { if (this.fileFields.has(key)) { // For file fields, we don't log the actual file content if (Array.isArray(value)) { sanitizedData[key] = value.map((v) => v instanceof FormFile ? v.toString() : v, ); } else { sanitizedData[key] = value.toString(); } } else { sanitizedData[key] = value; } } return sanitizedData; } } ================================================ FILE: apps/client-server/src/app/websites/commons/validator-passthru.ts ================================================ import { IWebsiteFormFields, SimpleValidationResult } from '@postybirb/types'; export async function validatorPassthru(): Promise< SimpleValidationResult > { return {}; } ================================================ FILE: apps/client-server/src/app/websites/commons/validator.ts ================================================ import { IWebsiteFormFields, SimpleValidationResult, ValidationMessage, ValidationMessages, } from '@postybirb/types'; type KeysToOmit = | 'mergeDefaults' | 'getFormFieldFor' | 'getFormFields' | 'getProcessedTags'; type ValidationArray = ValidationMessage< Fields, keyof ValidationMessages >[]; export class SubmissionValidator { protected readonly warnings: ValidationArray = []; protected readonly errors: ValidationArray = []; /** * Adds error to the validation result * * @param id - Error localization message id. {@link ValidationMessages} * @param values - Values to fill in the message * @param field - Associates the error to a input field */ error( id: T, values: ValidationMessages[T], field?: keyof Omit, ) { this.errors.push({ id, values, field }); } /** * Adds warning to the validation result * * @param id - Warning localization message id. {@link ValidationMessages} * @param values - Values to fill in the message * @param field - Associates the warning to a input field */ warning( id: T, values: ValidationMessages[T], field?: keyof Omit, ) { this.warnings.push({ id, values, field }); } /** * Returns validation result. Should be used in the onValidateFileSubmission or onValidateMessageSubmission */ get result(): SimpleValidationResult { return { errors: this.errors, warnings: this.warnings }; } } ================================================ FILE: apps/client-server/src/app/websites/decorators/disable-ads.decorator.ts ================================================ import { injectWebsiteDecoratorProps } from './website-decorator-props'; export function DisableAds() { return function website(constructor) { injectWebsiteDecoratorProps(constructor, { allowAd: false, }); }; } ================================================ FILE: apps/client-server/src/app/websites/decorators/login-flow.decorator.ts ================================================ import { CustomLoginType, UserLoginType } from '@postybirb/types'; import { Class } from 'type-fest'; import { UnknownWebsite } from '../website'; import { injectWebsiteDecoratorProps } from './website-decorator-props'; /** * Identifies the website as having a user login flow. * Meaning that they will login to the website using the website url provided. * @param {string} url */ export function UserLoginFlow(url: string) { return function website(constructor: Class) { const loginFlow: UserLoginType = { type: 'user', url, }; injectWebsiteDecoratorProps(constructor, { loginFlow }); }; } /** * Identifies the website as having a custom login flow. * Meaning that they will login through a custom provided form / component. * Defaults the name of the class if no name is provided. * @param {string} loginComponentName */ export function CustomLoginFlow(loginComponentName?: string) { return function website(constructor: Class) { const loginFlow: CustomLoginType = { type: 'custom', loginComponentName: loginComponentName ?? constructor.name, }; injectWebsiteDecoratorProps(constructor, { loginFlow }); }; } ================================================ FILE: apps/client-server/src/app/websites/decorators/supports-files.decorator.ts ================================================ import { ISubmissionFile, WebsiteFileOptions } from '@postybirb/types'; import { getFileType, getFileTypeFromMimeType, } from '@postybirb/utils/file-type'; import { parse } from 'path'; import { Class } from 'type-fest'; import { getDynamicFileSizeLimits } from '../models/website-modifiers/with-dynamic-file-size-limits'; import { UnknownWebsite } from '../website'; import { injectWebsiteDecoratorProps } from './website-decorator-props'; export function SupportsFiles( websiteFileOptions: Omit, ); export function SupportsFiles(acceptedMimeTypes: string[]); export function SupportsFiles( websiteFileOptionsOrMimeTypes: | Omit | string[], ) { return function website(constructor: Class) { let websiteFileOptions: WebsiteFileOptions = Array.isArray( websiteFileOptionsOrMimeTypes, ) ? { acceptedMimeTypes: websiteFileOptionsOrMimeTypes, supportedFileTypes: [], } : { ...websiteFileOptionsOrMimeTypes, supportedFileTypes: [] }; websiteFileOptions = { acceptedFileSizes: {}, acceptedMimeTypes: [], acceptsExternalSourceUrls: false, fileBatchSize: 1, ...websiteFileOptions, }; websiteFileOptions.acceptedMimeTypes.forEach((mimeType) => { const fileType = getFileTypeFromMimeType(mimeType); if (!websiteFileOptions.supportedFileTypes.includes(fileType)) { websiteFileOptions.supportedFileTypes.push(fileType); } }); injectWebsiteDecoratorProps(constructor, { fileOptions: websiteFileOptions, }); }; } export function getSupportedFileSize( instance: UnknownWebsite, file: ISubmissionFile, ) { const acceptedFileSizes = instance.decoratedProps.fileOptions?.acceptedFileSizes; const dynamicFileSizeLimits = getDynamicFileSizeLimits(instance); if (!acceptedFileSizes && !dynamicFileSizeLimits) { return undefined; } const limits = { ...acceptedFileSizes, ...dynamicFileSizeLimits }; return ( limits[file.mimeType] ?? limits[`${file.mimeType.split('/')[0]}/*`] ?? limits[parse(file.fileName).ext] ?? limits[getFileType(file.fileName)] ?? limits['*'] ?? Number.MAX_SAFE_INTEGER ); } ================================================ FILE: apps/client-server/src/app/websites/decorators/supports-username-shortcut.decorator.ts ================================================ import { UsernameShortcut } from '@postybirb/types'; import { Class } from 'type-fest'; import { UnknownWebsite } from '../website'; import { injectWebsiteDecoratorProps } from './website-decorator-props'; /** * Sets a username shortcut for a website. * @param {UsernameShortcut} usernameShortcut */ export function SupportsUsernameShortcut(usernameShortcut: UsernameShortcut) { return function website(constructor: Class) { injectWebsiteDecoratorProps(constructor, { usernameShortcut }); }; } ================================================ FILE: apps/client-server/src/app/websites/decorators/website-decorator-props.ts ================================================ import { CustomLoginType, IWebsiteMetadata, UserLoginType, UsernameShortcut, WebsiteFileOptions, } from '@postybirb/types'; import { LogLayer } from 'loglayer'; import { Class } from 'type-fest'; import { UnknownWebsite } from '../website'; export type WebsiteDecoratorProps = { /** * Set by {@link SupportsFiles} * * Defines the file options for a website. * @type {WebsiteFileOptions} */ fileOptions?: WebsiteFileOptions; /** * Set by {@link UserLoginFlow} or {@link CustomLoginFlow} * * Defines login flow properties for a website. * This is used to determine how a user will login to a website. * @type {UserLoginType} - User will login through a webview using the provided url. * @type {CustomLoginType} - User will login through a custom login flow created by the implementer. * @type {(UserLoginType | CustomLoginType)} */ loginFlow: UserLoginType | CustomLoginType; /** * Set by {@link WebsiteMetadata} * * Defines the metadata for a website. * This is usually for display or internal Ids. * @type {IWebsiteMetadata} */ metadata: IWebsiteMetadata; /** * Set by {@SupportsUsernameShortcut} * * Defines the username shortcut for a website. * This is used to modify links to users for websites that support it. * @type {UsernameShortcut} */ usernameShortcut?: UsernameShortcut; /** * Disable Ads in description by using {@link DisableAdSupport} * * @type {boolean} */ allowAd: boolean; }; export function defaultWebsiteDecoratorProps(): WebsiteDecoratorProps { return { fileOptions: undefined, loginFlow: undefined, metadata: undefined, allowAd: true, }; } /** * Injects basic website decorator properties into a website instance. * * @param {Class} constructor * @param {WebsiteDecoratorProps} props */ export function injectWebsiteDecoratorProps( constructor: Class, props: Partial, ): void { if (!constructor.prototype.decoratedProps) { Object.assign(constructor.prototype, { decoratedProps: defaultWebsiteDecoratorProps(), }); } Object.entries(props).forEach(([key, value]) => { if (value !== undefined) { // eslint-disable-next-line no-param-reassign constructor.prototype.decoratedProps[key] = value; } }); } export function validateWebsiteDecoratorProps( logger: LogLayer, websiteName: string, props: WebsiteDecoratorProps, ): boolean { if (!props.loginFlow) { logger .withContext({ websiteName }) .error( 'Website is missing login flow. Please set a login flow using UserLoginFlow or CustomLoginFlow decorators.', ); return false; } if (!props.metadata) { logger .withContext({ websiteName }) .error( 'Website is missing metadata. Please set metadata using WebsiteMetadata decorator.', ); return false; } if (!props.metadata.name) { logger.withContext({ websiteName }).error(`Missing metadata field 'name'`); } return true; } ================================================ FILE: apps/client-server/src/app/websites/decorators/website-metadata.decorator.ts ================================================ import { IWebsiteMetadata } from '@postybirb/types'; import { Class } from 'type-fest'; import { UnknownWebsite } from '../website'; import { injectWebsiteDecoratorProps } from './website-decorator-props'; export function WebsiteMetadata(metadata: IWebsiteMetadata) { return function website(constructor: Class) { const m = { ...metadata }; // Determine default login refresh if (!metadata.refreshInterval) { // Default (1 hour) m.refreshInterval = 60 * 60_000; } injectWebsiteDecoratorProps(constructor, { metadata: m }); }; } ================================================ FILE: apps/client-server/src/app/websites/dtos/oauth-website-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { DynamicObject, IOAuthWebsiteRequestDto } from '@postybirb/types'; import { IsObject, IsString } from 'class-validator'; export class OAuthWebsiteRequestDto implements IOAuthWebsiteRequestDto { @ApiProperty() @IsString() id: string; @ApiProperty({ type: Object, }) @IsObject() data: T; @ApiProperty() @IsString() route: string; } ================================================ FILE: apps/client-server/src/app/websites/implementations/artconomy/artconomy.website.ts ================================================ import { Http } from '@postybirb/http'; import { ILoginState, ImageResizeProps, ISubmissionFile, PostData, PostResponse, SubmissionRating, } from '@postybirb/types'; import { CancellableToken } from '../../../post/models/cancellable-token'; import { PostingFile } from '../../../post/models/posting-file'; import FileSize from '../../../utils/filesize.util'; import { PostBuilder } from '../../commons/post-builder'; import { validatorPassthru } from '../../commons/validator-passthru'; import { UserLoginFlow } from '../../decorators/login-flow.decorator'; import { SupportsFiles } from '../../decorators/supports-files.decorator'; import { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator'; import { WebsiteMetadata } from '../../decorators/website-metadata.decorator'; import { DataPropertyAccessibility } from '../../models/data-property-accessibility'; import { FileWebsite } from '../../models/website-modifiers/file-website'; import { MessageWebsite } from '../../models/website-modifiers/message-website'; import { Website } from '../../website'; import { ArtconomyAccountData } from './models/artconomy-account-data'; import { ArtconomyFileSubmission } from './models/artconomy-file-submission'; import { ArtconomyMessageSubmission } from './models/artconomy-message-submission'; @WebsiteMetadata({ name: 'artconomy', displayName: 'Artconomy', }) @UserLoginFlow('https://artconomy.com/auth/login') @SupportsUsernameShortcut({ id: 'artconomy', url: 'https://artconomy.com/profile/$1/about', }) @SupportsFiles({ acceptedMimeTypes: [ 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'video/mp4', 'application/msword', 'application/rtf', 'text/plain', 'audio/mp3', ], acceptedFileSizes: { '*': FileSize.megabytes(49), }, }) export default class Artconomy extends Website implements FileWebsite, MessageWebsite { protected BASE_URL = 'https://artconomy.com'; public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility = {}; public async onLogin(): Promise { try { const authCheck = await Http.get<{ username: string; id: number }>( `${this.BASE_URL}/api/profiles/data/requester/`, { partition: this.accountId, headers: { 'Content-Type': 'application/json', }, }, ); if (authCheck.statusCode === 200 && authCheck.body.username !== '_') { // Get CSRF token from cookies const cookies = await Http.getWebsiteCookies( this.accountId, this.BASE_URL, ); const csrfCookie = cookies.find((c) => c.name === 'csrftoken'); await this.setWebsiteData({ id: authCheck.body.id, username: authCheck.body.username, csrfToken: csrfCookie?.value || '', }); return this.loginState.setLogin(true, authCheck.body.username); } return this.loginState.setLogin(false, null); } catch (error) { return this.loginState.setLogin(false, null); } } createFileModel(): ArtconomyFileSubmission { return new ArtconomyFileSubmission(); } calculateImageResize(file: ISubmissionFile): ImageResizeProps { return undefined; } async onPostFileSubmission( postData: PostData, files: PostingFile[], cancellationToken: CancellableToken, ): Promise { const { id, username, csrfToken } = this.getWebsiteData(); if (!id || !username || !csrfToken) { return PostResponse.fromWebsite(this).withException( new Error('Not properly logged in to Artconomy'), ); } // Upload primary asset using PostBuilder const primaryUpload = await new PostBuilder(this, cancellationToken) .asMultipart() .addFile('files[]', files[0]) .withHeader('X-CSRFTOKEN', csrfToken) .withHeader('Referer', this.BASE_URL) .send<{ id: string }>(`${this.BASE_URL}/api/lib/asset/`); if (!primaryUpload.body?.id) { return PostResponse.fromWebsite(this) .withException( new Error( `Asset upload failed: ${primaryUpload.statusMessage || 'Unknown error'}`, ), ) .withAdditionalInfo(primaryUpload.body); } const primaryAsset = primaryUpload.body.id; let thumbnailAsset: string | null = null; // Upload thumbnail if available if (files[0].thumbnail) { const thumbnailUpload = await new PostBuilder(this, cancellationToken) .asMultipart() .addThumbnail('files[]', files[0]) .withHeader('X-CSRFTOKEN', csrfToken) .withHeader('Referer', this.BASE_URL) .send<{ id: string }>(`${this.BASE_URL}/api/lib/asset/`); if (!thumbnailUpload.body?.id) { return PostResponse.fromWebsite(this) .withException( new Error( `Thumbnail upload failed: ${thumbnailUpload.statusMessage || 'Unknown error'}`, ), ) .withAdditionalInfo(thumbnailUpload.body); } thumbnailAsset = thumbnailUpload.body.id; } cancellationToken.throwIfCancelled(); // Create submission using PostBuilder const postResponse = await new PostBuilder(this, cancellationToken) .asJson() .setField('file', primaryAsset) .setField('preview', thumbnailAsset) .setField('title', postData.options.title) .setField('caption', postData.options.description) .setField('tags', postData.options.tags) .setField('rating', this.getRating(postData.options.rating)) .setField('private', postData.options.isPrivate) .setField('comments_disabled', postData.options.commentsDisabled) .setConditional('artists', postData.options.isArtist, [id], []) .withHeader('X-CSRFTOKEN', csrfToken) .withHeader('Referer', this.BASE_URL) .send<{ id: string; }>(`${this.BASE_URL}/api/profiles/account/${username}/submissions/`); if (!postResponse.body?.id) { return PostResponse.fromWebsite(this) .withException( new Error( `Submission creation failed: ${postResponse.statusMessage || 'Unknown error'}`, ), ) .withAdditionalInfo(postResponse.body); } return PostResponse.fromWebsite(this) .withSourceUrl(`${this.BASE_URL}/submissions/${postResponse.body.id}`) .withMessage('File posted successfully'); } onValidateFileSubmission = validatorPassthru; createMessageModel(): ArtconomyMessageSubmission { return new ArtconomyMessageSubmission(); } async onPostMessageSubmission( postData: PostData, cancellationToken: CancellableToken, ): Promise { cancellationToken.throwIfCancelled(); const { username, csrfToken } = this.getWebsiteData(); if (!username || !csrfToken) { return PostResponse.fromWebsite(this).withException( new Error('Not properly logged in to Artconomy'), ); } const postResponse = await new PostBuilder(this, cancellationToken) .asJson() .setField('subject', postData.options.title) .setField('body', postData.options.description) .withHeader('X-CSRFTOKEN', csrfToken) .withHeader('Referer', this.BASE_URL) .send<{ id: number; }>(`${this.BASE_URL}/api/profiles/account/${username}/journals/`); if (!postResponse.body?.id) { return PostResponse.fromWebsite(this) .withException( new Error( `Journal creation failed: ${postResponse.statusMessage || 'Unknown error'}`, ), ) .withAdditionalInfo(postResponse.body); } return PostResponse.fromWebsite(this) .withSourceUrl( `${this.BASE_URL}/profile/${username}/journals/${postResponse.body.id}`, ) .withMessage('Journal posted successfully'); } onValidateMessageSubmission = validatorPassthru; private getRating(rating: SubmissionRating): number { switch (rating) { case SubmissionRating.GENERAL: return 0; case SubmissionRating.MATURE: return 1; case SubmissionRating.ADULT: return 2; case SubmissionRating.EXTREME: return 3; default: // Safest assumption return 2; } } } ================================================ FILE: apps/client-server/src/app/websites/implementations/artconomy/models/artconomy-account-data.ts ================================================ import { SelectOption } from '@postybirb/form-builder'; export type ArtconomyAccountData = { id?: number; username?: string; csrfToken?: string; } ================================================ FILE: apps/client-server/src/app/websites/implementations/artconomy/models/artconomy-file-submission.ts ================================================ import { BooleanField, DescriptionField, RatingField, TagField, } from '@postybirb/form-builder'; import { DescriptionType, DescriptionValue, SubmissionRating, TagValue, } from '@postybirb/types'; import { BaseWebsiteOptions } from '../../../models/base-website-options'; export class ArtconomyFileSubmission extends BaseWebsiteOptions { @DescriptionField({ descriptionType: DescriptionType.MARKDOWN, maxDescriptionLength: 2000, }) description: DescriptionValue; @TagField({ minTags: 5, }) tags: TagValue; @RatingField({ options: [ { value: SubmissionRating.GENERAL, label: 'Clean/Safe' }, { value: SubmissionRating.MATURE, label: 'Risque' }, { value: SubmissionRating.ADULT, label: 'Adult' }, { value: SubmissionRating.EXTREME, label: 'Offensive/Disturbing' }, ], }) rating: SubmissionRating; @BooleanField({ label: 'private', section: 'website', span: 6, defaultValue: false, }) isPrivate: boolean; @BooleanField({ label: 'disableComments', section: 'website', span: 6, defaultValue: false, }) commentsDisabled: boolean; @BooleanField({ label: 'originalWork', section: 'website', span: 6, defaultValue: true, }) isArtist: boolean; } ================================================ FILE: apps/client-server/src/app/websites/implementations/artconomy/models/artconomy-message-submission.ts ================================================ import { DescriptionField } from '@postybirb/form-builder'; import { DescriptionType, DescriptionValue } from '@postybirb/types'; import { BaseWebsiteOptions } from '../../../models/base-website-options'; export class ArtconomyMessageSubmission extends BaseWebsiteOptions { @DescriptionField({ descriptionType: DescriptionType.HTML }) description: DescriptionValue; } ================================================ FILE: apps/client-server/src/app/websites/implementations/aryion/aryion.website.ts ================================================ import { SelectOption } from '@postybirb/form-builder'; import { Http } from '@postybirb/http'; import { FileType, ILoginState, ImageResizeProps, PostData, PostResponse, SimpleValidationResult, } from '@postybirb/types'; import { HTMLElement, parse } from 'node-html-parser'; import { CancellableToken } from '../../../post/models/cancellable-token'; import { PostingFile } from '../../../post/models/posting-file'; import FileSize from '../../../utils/filesize.util'; import { SelectOptionUtil } from '../../../utils/select-option.util'; import { PostBuilder } from '../../commons/post-builder'; import { UserLoginFlow } from '../../decorators/login-flow.decorator'; import { SupportsFiles } from '../../decorators/supports-files.decorator'; import { WebsiteMetadata } from '../../decorators/website-metadata.decorator'; import { DataPropertyAccessibility } from '../../models/data-property-accessibility'; import { FileWebsite } from '../../models/website-modifiers/file-website'; import { Website } from '../../website'; import { AryionAccountData } from './models/aryion-account-data'; import { AryionFileSubmission } from './models/aryion-file-submission'; @WebsiteMetadata({ name: 'aryion', displayName: 'Aryion', }) @UserLoginFlow('https://aryion.com/forum/ucp.php?mode=login') @SupportsFiles({ acceptedMimeTypes: [ 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/x-shockwave-flash', 'application/vnd.visio', 'text/plain', 'application/rtf', 'video/x-msvideo', 'video/mpeg', 'video/x-flv', 'video/mp4', 'application/pdf', ], acceptedFileSizes: { [FileType.IMAGE]: FileSize.megabytes(20), [FileType.VIDEO]: FileSize.megabytes(100), [FileType.TEXT]: FileSize.megabytes(100), 'application/pdf': FileSize.megabytes(100), }, }) export default class Aryion extends Website implements FileWebsite { protected BASE_URL = 'https://aryion.com'; public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility = { folders: true, }; public async onLogin(): Promise { const res = await Http.get(`${this.BASE_URL}/g4/treeview.php`, { partition: this.accountId, }); if ( res.body.includes('user-link') && !res.body.includes('Login to read messages') ) { const $ = parse(res.body); const userLink = $.querySelector('.user-link'); const username = userLink ? userLink.text : 'Unknown User'; this.loginState.setLogin(true, username); await this.getFolders($); } else { this.loginState.logout(); } return this.loginState.getState(); } private async getFolders($: HTMLElement): Promise { const folders: SelectOption[] = []; const treeviews = $.querySelectorAll('.treeview'); treeviews.forEach((treeview) => { // Process each top-level
    3. element const topLevelItems = treeview.querySelectorAll(':scope > li'); topLevelItems.forEach((li) => { this.parseFolderItem(li, folders); }); }); this.websiteDataStore.setData({ ...this.websiteDataStore.getData(), folders, }); } private parseFolderItem(li: HTMLElement, parent: SelectOption[]): void { // Find the span element that contains the folder info const folderSpan = li.querySelector(':scope > span'); if (!folderSpan) return; const dataTid = folderSpan.getAttribute('data-tid'); const folderName = folderSpan.text.trim(); if (!dataTid || !folderName) return; // Check if this folder has children (look for a
        sibling) const childrenUl = li.querySelector(':scope > ul'); if (childrenUl) { // This is a parent folder with children const childItems: SelectOption[] = []; // Process each child
      • element const childLis = childrenUl.querySelectorAll(':scope > li'); childLis.forEach((childLi) => { this.parseFolderItem(childLi, childItems); }); // Create a group entry for this folder parent.push({ label: folderName, items: childItems, value: dataTid, }); } else { // This is a leaf folder (no children) parent.push({ value: dataTid, label: folderName, }); } } createFileModel(): AryionFileSubmission { return new AryionFileSubmission(); } calculateImageResize(): ImageResizeProps { return undefined; } async onPostFileSubmission( postData: PostData, files: PostingFile[], cancellationToken: CancellableToken, ): Promise { cancellationToken.throwIfCancelled(); const { options } = postData; const file = files[0]; // Filter out 'vore' and 'non-vore' tags from the tags list const filteredTags = options.tags .filter((tag) => !tag.toLowerCase().match(/^vore$/i)) .filter((tag) => !tag.toLowerCase().match(/^non-vore$/i)); const builder = new PostBuilder(this, cancellationToken) .asMultipart() .addFile('file', file) .addFile('thumb', file) .setField('desc', options.description) .setField('title', options.title) .setField('tags', filteredTags.join('\n')) .setField('reqtag[]', options.requiredTag === '1' ? 'Non-Vore' : '') .setField('view_perm', options.viewPermissions) .setField('comment_perm', options.commentPermissions) .setField('tag_perm', options.tagPermissions) .setField('scrap', options.scraps ? 'on' : '') .setField('parentid', options.folder) .setField('action', 'new-item') .setField('MAX_FILE_SIZE', '104857600'); const result = await builder.send( `${this.BASE_URL}/g4/itemaction.php`, ); try { // Split errors/warnings if they exist and handle them separately const responses = result.body .trim() .split('\n') .map((r) => r?.trim()); if (responses.length > 1 && responses[0].indexOf('Warning:') === -1) { return PostResponse.fromWebsite(this) .withAdditionalInfo(result.body) .withException(new Error('Server returned warnings or errors')); } // Parse the JSON response const jsonResponse = responses[responses.length - 1].replace( /(