├── .env.sample ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── README.md ├── app.vue ├── assets ├── css │ └── main.css └── scss │ └── main.scss ├── components ├── MessageEditor.vue ├── ThreadEditor.vue ├── ThreadSummaryList.vue └── UserSettings.vue ├── docker-compose.yml ├── docs ├── bullmq_console.png ├── minio_bucket_access_policy.png ├── minio_configure_anonymous_access_rule.png ├── minio_configure_region.png ├── minio_create_bucket.png ├── minio_login.png └── threadr-v0.5.0.png ├── models └── models.ts ├── nuxt.config.ts ├── package-lock.json ├── package.json ├── pages └── index.vue ├── plugins └── primevue.js ├── prisma ├── db.ts ├── migrations │ ├── 20230906200241_init │ │ └── migration.sql │ └── 20230929155742_add_settings_table │ │ ├── down.sql │ │ └── migration.sql └── schema.prisma ├── public └── favicon.ico ├── scripts └── test_prepare.sh ├── server ├── api │ ├── media.post.ts │ ├── settings.get.ts │ ├── settings.patch.ts │ ├── threads.get.ts │ ├── threads.post.ts │ └── threads │ │ ├── [id].delete.ts │ │ ├── [id].get.ts │ │ ├── [id].post.ts │ │ └── [id] │ │ ├── publication.post.ts │ │ ├── schedule.delete.ts │ │ └── schedule.post.ts ├── settings.get.spec.ts ├── settings.patch.spec.ts └── tsconfig.json ├── services ├── bluesky-url-facets-extractor.spec.ts ├── bluesky-url-facets-extractor.ts └── publication-service.ts ├── threadr.png ├── tsconfig.json └── utils ├── queue.ts ├── test-utils.ts └── utils.ts /.env.sample: -------------------------------------------------------------------------------- 1 | # ---------- 2 | # S3 Storage 3 | # ---------- 4 | 5 | # MinIO config for docker-compose 6 | MINIO_ROOT_USER=minio-admin 7 | MINIO_ROOT_PASSWORD=my-secured-password 8 | 9 | MINIO_ENDPOINT=http://localhost:9000 10 | MINIO_REGION=eu-fr-1 11 | MINIO_BUCKET_NAME=threadr-app 12 | MINIO_MEDIA_PATH=media 13 | MINIO_ACCESS_KEY="${MINIO_ROOT_USER}" 14 | MINIO_SECRET_KEY="${MINIO_ROOT_PASSWORD}" 15 | 16 | # ---------------- 17 | # Data persistence 18 | # ---------------- 19 | POSTGRES_USER=postgres-admin 20 | POSTGRES_PASSWORD=postgres-password 21 | POSTGRES_DB=threadr 22 | DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?schema=public" 23 | 24 | # --------------- 25 | # Scheduling jobs 26 | # --------------- 27 | REDIS_URL=redis://localhost:6379 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Nuxt 133 | .output -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "firefox", 9 | "request": "launch", 10 | "name": "client: firefox", 11 | "url": "http://localhost:3000", 12 | "webRoot": "${workspaceFolder}" 13 | }, 14 | { 15 | "type": "node", 16 | "request": "launch", 17 | "name": "server: nuxt", 18 | "outputCapture": "std", 19 | "program": "${workspaceFolder}/node_modules/nuxi/bin/nuxi.mjs", 20 | "args": [ 21 | "dev" 22 | ], 23 | } 24 | ], 25 | "compounds": [ 26 | { 27 | "name": "fullstack: nuxt", 28 | "configurations": [ 29 | "server: nuxt", 30 | "client: firefox" 31 | ] 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "nuxt.isNuxtApp": false, 4 | "sqltools.connections": [ 5 | { 6 | "previewLimit": 50, 7 | "server": "localhost", 8 | "driver": "PostgreSQL", 9 | "name": "Localhost", 10 | "connectString": "postgresql://postgres-admin:postgres-password@localhost:5432/threadr?schema=public" 11 | }, 12 | { 13 | "previewLimit": 50, 14 | "server": "localhost", 15 | "driver": "PostgreSQL", 16 | "name": "Test", 17 | "connectString": "postgresql://threadr_test:threadr_test@localhost:5433/threadr_test?schema=public" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=20-alpine3.17 2 | 3 | FROM node:${NODE_VERSION} as base 4 | 5 | ARG PORT=3000 6 | 7 | ENV NODE_ENV=production 8 | 9 | WORKDIR /app 10 | 11 | # Build 12 | FROM base as build 13 | 14 | COPY package.json package-lock.json ./ 15 | RUN npm install --production=false 16 | 17 | COPY . . 18 | 19 | RUN npm run build 20 | RUN npm prune 21 | 22 | # Run 23 | FROM base 24 | 25 | ENV PORT=$PORT 26 | 27 | COPY --from=build /app/.output /app/.output 28 | # Optional, only needed if you rely on unbundled dependencies 29 | # COPY --from=build /src/node_modules /src/node_modules 30 | 31 | CMD [ "node", ".output/server/index.mjs" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Threadr 2 | 3 | Threadr is a small (#workinprogress) web application that helps users of micro-blogging platforms to write great threads and allows them to crosspost in one click their content to Bluesky, Mastodon and Twitter/X. 4 | 5 | ![Threadr-v0.5.0](./docs/threadr-v0.5.0.png) 6 | 7 | ## Core features 8 | 9 | * Write threads 10 | * Add, edit, remove messages 11 | * Add, describe, remove message images (up to 4 by message) 12 | * Save a thread 13 | * List all threads (by status, e.g. `draft`, `scheduled`, `published`) 14 | * Publish a thread 15 | * Schedule (and cancel) a thread publication 16 | * Configure platforms in a settings manager 17 | * display name 18 | * avatar 19 | * Bluesky activation and configuration 20 | * Mastodon activation and configuration 21 | * Twitter activation and configuration 22 | 23 | ## Installation 24 | 25 | For now, Threadr only works in localhost. 26 | 27 | **1/** Get the project sources 28 | 29 | ```shell 30 | $ git clone git@github.com:jbuget/threadr-app.git && cd threadr-app 31 | ``` 32 | 33 | **2/** Copy the `.env.sample` file into a new `.env` file 34 | 35 | You previously must generate, get and report your access keys for Twitter/X (new developer project), Mastodon (new app) and Bluesky (your user credentials). 36 | 37 | **3/** Run the Docker compose service `minio` (required to upload media files into platforms) 38 | 39 | ```shell 40 | $ docker compose up -d 41 | ``` 42 | 43 | **4/** Configure your MinIO instance 44 | 45 | MinIO console is accessible on [localhost:9001](http://localhost:9001). Credentials are defined in `.env`file, cf. `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD`. By default, values are "minio-admin" / "my-secured-password". 46 | 47 | ![MinIO login screen](./docs/minio_login.png) 48 | 49 | Create a bucket (ex: "threadr-app" in `.env.sample`). 50 | 51 | ![Create a MinIO bucket](./docs/minio_create_bucket.png) 52 | 53 | In the MinIO settings, configure MinIO region (ex: "eu-fr-1" in `.env.sample`). 54 | 55 | ![Region Configuration in MinIO](./docs/minio_configure_region.png) 56 | 57 | > ⚠️ It is recommanded to declare a custom policy with dedicacted path in `readonly` acces for anonymous visitors 58 | 59 | ![Add anonymous access rule](./docs/minio_configure_anonymous_access_rule.png) 60 | 61 | ![Anonymous access rule result](./docs/minio_bucket_access_policy.png) 62 | 63 | **5/** Run Threadr locally 64 | 65 | ```shell 66 | $ npm install 67 | $ npm run dev -- -o 68 | ``` 69 | 70 | **6/** 🚀 Enjoy Threadr at [localhost:3000](http://localhost:3000)! 71 | 72 |

73 | 74 |

75 | 76 | **7/** (bonus) You can follow your scheduled threads in BullMQ console 77 | 78 | Run the following command : 79 | 80 | ```shell 81 | $ npx bullmq-dashboard-runnable thread-schedules -P 3001 82 | ``` 83 | 84 | Then access [localhost:3001](http://localhost:3001). 85 | 86 | ![BullMQ console](./docs/bullmq_console.png) 87 | 88 | ## Configuration 89 | 90 | All configuration option are set in the `.env` file. 91 | 92 |
93 | 94 | Uploading media files 95 | 96 | **`MINIO_ENDPOINT`: URL** 97 | 98 | The endpoint URL of MinIO/S3 server on which temporarily upload media files. 99 | 100 | **`MINIO_REGION`: string** 101 | 102 | The region of the MinIO/S3 server. 103 | 104 | **`MINIO_BUCKET_NAME`: string** 105 | 106 | The bucket where the media files will be upload before being sent to platforms. 107 | 108 | **`MINIO_MEDIA_PATH`: string** 109 | 110 | The folder path inside the bucket. 111 | 112 | **`MINIO_ACCESS_KEY`: string** 113 | 114 | The MinIO access key to access the bucket in order to deposit media files. 115 | 116 | **`MINIO_SECRET_KEY`: string** 117 | 118 | The MinIO access secret key to access the bucket in order to deposit media files. 119 | 120 | **`MINIO_ROOT_USER`: string** 121 | 122 | The MinIO administration account username (used for docker-compose MinIO container). 123 | 124 | **`MINIO_ROOT_PASSWORD`: string** 125 | 126 | The MinIO administration account password (used for docker-compose MinIO container). 127 | 128 |
129 | 130 |
131 | Persisting data 132 | 133 | **`DATABASE_URL`: (PostgreSQL) URL** 134 | 135 | The PostgreSQL database URL 136 | 137 | **`POSTGRES_USER`: string** 138 | 139 | The PostgreSQL administration account username (used for docker-compose postgres container). 140 | 141 | **`POSTGRES_PASSWORD`: string** 142 | 143 | The PostgreSQL administration account password (used for docker-compose postgres container). 144 | 145 | **`POSTGRES_DB`: string** 146 | 147 | The PostgreSQL database. 148 | 149 |
150 | 151 |
152 | 153 | Scheduling thread publication 154 | 155 | **`REDIS_URL`: (Redis) URL** 156 | 157 | The Redis database URL, used by BullMQ. 158 | 159 |
-------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /assets/css/main.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: 'Mulish', Arial, Helvetica, sans-serif; 3 | padding: 0; 4 | margin: 0; 5 | font-size: 16px; 6 | } 7 | 8 | h1, h2, h3, h4, h5, h6 { 9 | font-family: 'Lato', Arial, Helvetica, sans-serif; 10 | } 11 | 12 | textarea { 13 | font-family: 'Mulish', Arial, Helvetica, sans-serif; 14 | font-size: 1em; 15 | } 16 | -------------------------------------------------------------------------------- /assets/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "primeicons/primeicons.css"; 2 | 3 | .p-datepicker .p-datepicker-header { 4 | padding: 0 !important; 5 | } 6 | 7 | .p-datepicker table td { 8 | padding: 0.25rem !important; 9 | } 10 | .p-datepicker table td > span { 11 | width: 2rem !important; 12 | height: 2rem !important; 13 | } 14 | 15 | .p-datepicker .p-datepicker-buttonbar { 16 | padding: 0 !important; 17 | } 18 | 19 | .p-dialog .p-dialog-header { 20 | border-bottom: 1px solid lightgray!important; 21 | } 22 | .p-dialog .p-dialog-footer { 23 | border-top: 1px solid lightgray!important; 24 | padding: 1.5rem!important; 25 | } 26 | -------------------------------------------------------------------------------- /components/MessageEditor.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 145 | 146 | -------------------------------------------------------------------------------- /components/ThreadEditor.vue: -------------------------------------------------------------------------------- 1 | 219 | 220 | 283 | 284 | -------------------------------------------------------------------------------- /components/ThreadSummaryList.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 106 | 107 | -------------------------------------------------------------------------------- /components/UserSettings.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 109 | 110 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | minio: 6 | image: minio/minio 7 | command: 'server --console-address ":9001" /data' 8 | env_file: 9 | - .env 10 | ports: 11 | - "9000:9000" 12 | - "9001:9001" 13 | volumes: 14 | - minio_storage:/data 15 | 16 | postgres: 17 | image: postgres:15.4-alpine 18 | env_file: 19 | - .env 20 | ports: 21 | - "5432:5432" 22 | volumes: 23 | - pgdata:/var/lib/postgresql/data 24 | 25 | postgres_test: 26 | image: postgres:15.4-alpine 27 | ports: 28 | - "5433:5432" 29 | 30 | redis: 31 | image: redis:7.2-alpine 32 | ports: 33 | - "6379:6379" 34 | command: redis-server --save 60 1 --loglevel warning 35 | volumes: 36 | - redis:/data 37 | 38 | volumes: 39 | pgdata: 40 | redis: 41 | minio_storage: 42 | -------------------------------------------------------------------------------- /docs/bullmq_console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/bullmq_console.png -------------------------------------------------------------------------------- /docs/minio_bucket_access_policy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_bucket_access_policy.png -------------------------------------------------------------------------------- /docs/minio_configure_anonymous_access_rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_configure_anonymous_access_rule.png -------------------------------------------------------------------------------- /docs/minio_configure_region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_configure_region.png -------------------------------------------------------------------------------- /docs/minio_create_bucket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_create_bucket.png -------------------------------------------------------------------------------- /docs/minio_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_login.png -------------------------------------------------------------------------------- /docs/threadr-v0.5.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/threadr-v0.5.0.png -------------------------------------------------------------------------------- /models/models.ts: -------------------------------------------------------------------------------- 1 | export interface Attachment { 2 | location: string 3 | filename: string 4 | size: number 5 | mimetype: string 6 | alt?: string 7 | } 8 | 9 | export interface Message { 10 | text: string; 11 | attachments: Attachment[]; 12 | } 13 | 14 | 15 | export interface Thread { 16 | id?: number 17 | messages?: Message[] 18 | createdAt?: Date 19 | updatedAt?: Date 20 | scheduledAt?: Date | undefined 21 | publishedAt?: Date | undefined 22 | } 23 | export interface ThreadSummary { 24 | id?: number 25 | title: string 26 | createdAt?: Date 27 | updatedAt?: Date 28 | scheduledAt?: Date 29 | publishedAt?: Date 30 | nbMesssages: number 31 | } 32 | 33 | export interface Settings { 34 | id?: number 35 | createdAt?: Date 36 | updatedAt?: Date 37 | displayName?: string 38 | avatarUrl?: string 39 | blueskyEnabled: boolean 40 | blueskyUrl?: string 41 | blueskyIdentifier?: string 42 | blueskyPassword?: string 43 | mastodonEnabled: boolean 44 | mastodonUrl?: string 45 | mastodonAccessToken?: string 46 | twitterEnabled: boolean 47 | twitterConsumerKey?: string 48 | twitterConsumerSecret?: string 49 | twitterAccessToken?: string 50 | twitterAccessSecret?: string 51 | } 52 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | const modules = (() => { 2 | if (process.env.NODE_ENV === 'test') { 3 | return [] 4 | } 5 | return ['@nuxtjs/google-fonts'] 6 | })() 7 | 8 | // @ts-nocheck 9 | // https://nuxt.com/docs/api/configuration/nuxt-config 10 | export default defineNuxtConfig({ 11 | devtools: { enabled: true }, 12 | typescript: { 13 | strict: true 14 | }, 15 | css: [ 16 | '@/assets/css/main.css', 17 | '@/assets/scss/main.scss', 18 | 'primevue/resources/themes/lara-light-blue/theme.css' 19 | ], 20 | build: { 21 | transpile: ["primevue"] 22 | }, 23 | modules: modules, 24 | googleFonts: { 25 | families: { 26 | Lato: true, 27 | Roboto: true, 28 | Mulish: true 29 | } 30 | }, 31 | runtimeConfig: { 32 | public: { 33 | displayingName: process.env.DISPLAYING_NAME, 34 | avatarUrl: process.env.AVATAR_URL, 35 | } 36 | }, 37 | test: process.env.NODE_ENV === 'test', 38 | }) 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threader", 3 | "private": true, 4 | "scripts": { 5 | "db:migrate": "npx prisma migrate deploy", 6 | "build": "nuxt build", 7 | "dev": "npm run db:migrate && nuxt dev", 8 | "test": "npm run test:prepare && npm run test:run", 9 | "test:prepare": "./scripts/test_prepare.sh", 10 | "test:run": "NODE_ENV=test vitest --run --no-threads", 11 | "generate": "nuxt generate", 12 | "preview": "nuxt preview", 13 | "postinstall": "nuxt prepare" 14 | }, 15 | "devDependencies": { 16 | "@atproto/api": "^0.6.6", 17 | "@aws-sdk/client-s3": "^3.400.0", 18 | "@aws-sdk/lib-storage": "^3.400.0", 19 | "@nuxt/devtools": "latest", 20 | "@nuxt/test-utils": "^3.7.4", 21 | "@nuxtjs/google-fonts": "^3.0.2", 22 | "@prisma/client": "^5.3.1", 23 | "@types/formidable": "^3.4.3", 24 | "@types/uuid": "^9.0.4", 25 | "bullmq": "^4.12.0", 26 | "formidable": "^3.5.1", 27 | "ioredis": "^5.3.2", 28 | "masto": "^6.3.1", 29 | "minio": "^7.1.2", 30 | "nuxt": "^3.7.4", 31 | "pg": "^8.11.3", 32 | "primeicons": "^6.0.1", 33 | "primevue": "^3.35.0", 34 | "prisma": "^5.3.1", 35 | "sass": "^1.66.1", 36 | "twitter-api-v2": "^1.15.1", 37 | "vitest": "^0.33.0" 38 | }, 39 | "dependencies": { 40 | "@types/pg": "^8.10.3" 41 | } 42 | } -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 87 | 88 | 128 | 129 | -------------------------------------------------------------------------------- /plugins/primevue.js: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from "#app"; 2 | import PrimeVue from "primevue/config"; 3 | import Avatar from "primevue/avatar"; 4 | import Badge from "primevue/badge"; 5 | import Button from "primevue/button"; 6 | import Chip from "primevue/chip"; 7 | import Dialog from 'primevue/dialog'; 8 | import Divider from "primevue/divider"; 9 | import FileUpload from "primevue/fileupload"; 10 | import Image from "primevue/image"; 11 | import InlineMessage from "primevue/inlinemessage"; 12 | import InputSwitch from "primevue/inputswitch"; 13 | import InputText from 'primevue/inputtext'; 14 | import Message from 'primevue/message'; 15 | import Textarea from "primevue/textarea"; 16 | import Toast from "primevue/toast"; 17 | import ToastService from 'primevue/toastservice'; 18 | 19 | export default defineNuxtPlugin((nuxtApp) => { 20 | nuxtApp.vueApp.use(PrimeVue, { ripple: true }); 21 | nuxtApp.vueApp.use(ToastService) 22 | nuxtApp.vueApp.component("Avatar", Avatar); 23 | nuxtApp.vueApp.component("Badge", Badge); 24 | nuxtApp.vueApp.component("Button", Button); 25 | nuxtApp.vueApp.component("Chip", Chip); 26 | nuxtApp.vueApp.component("Dialog", Dialog); 27 | nuxtApp.vueApp.component("Divider", Divider); 28 | nuxtApp.vueApp.component("FileUpload", FileUpload); 29 | nuxtApp.vueApp.component("Image", Image); 30 | nuxtApp.vueApp.component("InlineMessage", InlineMessage); 31 | nuxtApp.vueApp.component("InputSwitch", InputSwitch); 32 | nuxtApp.vueApp.component("InputText", InputText); 33 | nuxtApp.vueApp.component("Message", Message); 34 | nuxtApp.vueApp.component("Textarea", Textarea); 35 | nuxtApp.vueApp.component("Toast", Toast); 36 | //other components that you need 37 | }); 38 | 39 | -------------------------------------------------------------------------------- /prisma/db.ts: -------------------------------------------------------------------------------- 1 | // https://www.prisma.io/docs/guides/database/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices 2 | import { PrismaClient } from '@prisma/client' 3 | 4 | if (process.env.NODE_ENV === 'test') { 5 | process.env['DATABASE_URL'] = process.env['TEST_DATABASE_URL']; 6 | } 7 | 8 | const prismaClientSingleton = () => { 9 | return new PrismaClient() 10 | } 11 | 12 | type PrismaClientSingleton = ReturnType 13 | 14 | const globalForPrisma = globalThis as unknown as { 15 | prisma: PrismaClientSingleton | undefined 16 | } 17 | 18 | export const prisma = globalForPrisma.prisma ?? prismaClientSingleton() 19 | 20 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma -------------------------------------------------------------------------------- /prisma/migrations/20230906200241_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Thread" ( 3 | "id" SERIAL NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NULL, 6 | "scheduledAt" TIMESTAMP(3) NULL, 7 | "publishedAt" TIMESTAMP(3) NULL, 8 | "published" BOOLEAN NOT NULL DEFAULT false, 9 | 10 | CONSTRAINT "Thread_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "Version" ( 15 | "id" SERIAL NOT NULL, 16 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | "data" JSONB NOT NULL, 18 | "threadId" INTEGER NOT NULL, 19 | 20 | CONSTRAINT "Version_pkey" PRIMARY KEY ("id") 21 | ); 22 | 23 | -- AddForeignKey 24 | ALTER TABLE "Version" ADD CONSTRAINT "Version_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "Thread"("id") ON DELETE CASCADE ON UPDATE CASCADE; 25 | -------------------------------------------------------------------------------- /prisma/migrations/20230929155742_add_settings_table/down.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM _prisma_migrations 2 | WHERE id='484592f3-8e79-4de3-8f2e-214347cd6b39'; -------------------------------------------------------------------------------- /prisma/migrations/20230929155742_add_settings_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Settings" ( 3 | "id" SERIAL NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3), 6 | "displayName" TEXT, 7 | "avatarUrl" TEXT, 8 | "blueskyEnabled" BOOLEAN NOT NULL DEFAULT false, 9 | "blueskyUrl" TEXT, 10 | "blueskyIdentifier" TEXT, 11 | "blueskyPassword" TEXT, 12 | "mastodonEnabled" BOOLEAN NOT NULL DEFAULT false, 13 | "mastodonUrl" TEXT, 14 | "mastodonAccessToken" TEXT, 15 | "twitterEnabled" BOOLEAN NOT NULL DEFAULT false, 16 | "twitterConsumerKey" TEXT, 17 | "twitterConsumerSecret" TEXT, 18 | "twitterAccessToken" TEXT, 19 | "twitterAccessSecret" TEXT, 20 | 21 | CONSTRAINT "Settings_pkey" PRIMARY KEY ("id") 22 | ); 23 | 24 | INSERT INTO "Settings" (id) 25 | SELECT 1 26 | WHERE NOT EXISTS (SELECT 1 FROM "Settings"); -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Thread { 14 | id Int @id @default(autoincrement()) 15 | createdAt DateTime @default(now()) 16 | updatedAt DateTime? @updatedAt 17 | scheduledAt DateTime? 18 | publishedAt DateTime? 19 | published Boolean @default(false) 20 | versions Version[] 21 | } 22 | 23 | model Version { 24 | id Int @id @default(autoincrement()) 25 | createdAt DateTime @default(now()) 26 | data Json 27 | thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade) 28 | threadId Int 29 | } 30 | 31 | model Settings { 32 | id Int @id @default(autoincrement()) 33 | createdAt DateTime @default(now()) 34 | updatedAt DateTime? 35 | displayName String? 36 | avatarUrl String? 37 | blueskyEnabled Boolean @default(false) 38 | blueskyUrl String? 39 | blueskyIdentifier String? 40 | blueskyPassword String? 41 | mastodonEnabled Boolean @default(false) 42 | mastodonUrl String? 43 | mastodonAccessToken String? 44 | twitterEnabled Boolean @default(false) 45 | twitterConsumerKey String? 46 | twitterConsumerSecret String? 47 | twitterAccessToken String? 48 | twitterAccessSecret String? 49 | } 50 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/public/favicon.ico -------------------------------------------------------------------------------- /scripts/test_prepare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Définition des variables pour la connexion à la base de données 4 | POSTGRES_USER="threadr_test" 5 | POSTGRES_PASSWORD="threadr_test" 6 | POSTGRES_DB="threadr_test" 7 | POSTGRES_HOST="localhost" 8 | POSTGRES_PORT="5433" 9 | POSTGRES_SCHEMA="public" 10 | 11 | # Construction de l'URL de la base de données 12 | DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?schema=${POSTGRES_SCHEMA}" 13 | 14 | # Exporter DATABASE_URL comme une variable d'environnement 15 | export DATABASE_URL 16 | 17 | # Vérifiez si npx est installé 18 | command -v npx >/dev/null 2>&1 || { 19 | echo >&2 "npx n'est pas installé. Installez-le en utilisant npm install -g npx et réessayez."; 20 | exit 1; 21 | } 22 | 23 | # Déployez les migrations avec Prisma 24 | npx prisma migrate deploy 25 | 26 | # Vérifiez le succès de la migration 27 | if [ $? -eq 0 ]; then 28 | echo "Migration effectuée avec succès." 29 | else 30 | echo "Erreur lors de la migration. Vérifiez les logs ci-dessus pour plus de détails." 31 | exit 1; 32 | fi -------------------------------------------------------------------------------- /server/api/media.post.ts: -------------------------------------------------------------------------------- 1 | import formidable, { File } from 'formidable' 2 | import { v4 as uuid } from 'uuid' 3 | import { PassThrough, Writable } from 'node:stream' 4 | import { S3Client } from '@aws-sdk/client-s3' 5 | import { Upload } from '@aws-sdk/lib-storage' 6 | import { IncomingMessage } from 'http' 7 | 8 | const s3Client = new S3Client({ 9 | endpoint: process.env.MINIO_ENDPOINT as string, 10 | region: process.env.MINIO_REGION as string, 11 | credentials: { 12 | accessKeyId: process.env.MINIO_ACCESS_KEY as string, 13 | secretAccessKey: process.env.MINIO_SECRET_KEY as string 14 | }, 15 | /* 16 | Required with MinIO in order to allow URL such as `minio.example.org/bucket_name` 17 | instead of default generated URL `bucket_name.mino.example.org` 18 | */ 19 | forcePathStyle: true 20 | }) 21 | 22 | function parseMultipartNodeRequest(req: IncomingMessage) { 23 | return new Promise((resolve, reject) => { 24 | const s3Uploads: Promise[] = []; 25 | 26 | function fileWriteStreamHandler(file?: any) : Writable { 27 | const body = new PassThrough(); 28 | if (!file) { 29 | return body 30 | } 31 | 32 | /* 33 | On préfixe le nom de fichier par uuid() : 34 | - par mesure de sécurité afin d'éviter le scrapping ou le listing par brute force 35 | - afin de pouvoir uploader plusieurs fichiers différents portant le même nom 36 | */ 37 | const fileObjectKey = `${process.env.MINIO_MEDIA_PATH as string}/${uuid()}-${file.originalFilename}` 38 | 39 | const upload = new Upload({ 40 | client: s3Client, 41 | params: { 42 | Bucket: process.env.MINIO_BUCKET_NAME as string, 43 | Key: fileObjectKey, 44 | ContentType: file.mimetype, 45 | Body: body, 46 | }, 47 | }); 48 | const uploadRequest = upload.done().then((response:any) => { 49 | file.objectKey = fileObjectKey 50 | file.location = process.env.MINIO_ENDPOINT + '/' + process.env.MINIO_BUCKET_NAME + '/' + response.Key 51 | }); 52 | s3Uploads.push(uploadRequest); 53 | return body; 54 | } 55 | const form = formidable({ 56 | multiples: true, 57 | fileWriteStreamHandler: fileWriteStreamHandler, 58 | }); 59 | form.parse(req, (error, fields, files) => { 60 | if (error) { 61 | reject(error); 62 | return; 63 | } 64 | Promise.all(s3Uploads) 65 | .then(() => { 66 | const response = { ...fields, ...files } 67 | resolve(response); 68 | }) 69 | .catch(reject); 70 | }); 71 | }); 72 | } 73 | 74 | export default defineEventHandler(async (event: any) => { 75 | let body; 76 | const headers = getRequestHeaders(event); 77 | 78 | if (headers['content-type']?.includes('multipart/form-data')) { 79 | body = await parseMultipartNodeRequest(event.node.req); 80 | } else { 81 | body = await readBody(event); 82 | } 83 | 84 | /* 85 | cf. ~/node_modules/@types/formidable/index.d.ts 86 | default serialized fields: "size" | "filepath" | "originalFilename" | "mimetype" | "hash" | "newFilename" 87 | */ 88 | const responseData = body.attachments.map((file:any) => { 89 | return { 90 | location: file.location, 91 | size: file.size, 92 | mimetype: file.mimetype, 93 | newFilename: file.fileObjectKey, 94 | originalFilename: file.originalFilename, 95 | description: '' 96 | } 97 | }) 98 | 99 | return { files: responseData }; 100 | }); 101 | -------------------------------------------------------------------------------- /server/api/settings.get.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../../prisma/db' 2 | 3 | export default defineEventHandler(async () => { 4 | console.log(`GET /api/settings`) 5 | 6 | try { 7 | const settings: any = await prisma.settings.findFirst() 8 | 9 | if (settings) { 10 | 11 | return { 12 | display_name: settings.displayName, 13 | avatar_url: settings.avatarUrl, 14 | bluesky_enabled: settings.blueskyEnabled, 15 | bluesky_url: settings.blueskyUrl, 16 | bluesky_identifier: settings.blueskyIdentifier, 17 | bluesky_password: settings.blueskyPassword, 18 | mastodon_enabled: settings.mastodonEnabled, 19 | mastodon_url: settings.mastodonUrl, 20 | mastodon_access_token: settings.mastodonAccessToken, 21 | twitter_enabled: settings.twitterEnabled, 22 | twitter_consumer_key: settings.twitterConsumerKey, 23 | twitter_consumer_secret: settings.twitterConsumerSecret, 24 | twitter_access_token: settings.twitterAccessToken, 25 | twitter_access_secret: settings.twitterAccessSecret, 26 | } 27 | } 28 | return null 29 | } catch (error: any) { 30 | console.error(error) 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /server/api/settings.patch.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../../prisma/db' 2 | 3 | export default defineEventHandler(async (event: any) => { 4 | console.log(`PATCH /api/settings`) 5 | try { 6 | const requestData: any = await readBody(event) 7 | const now = new Date() 8 | 9 | const existingRecord = await prisma.settings.findFirst() 10 | 11 | if (!existingRecord) { 12 | throw new Error('There is no settings defined in the system') 13 | } 14 | 15 | const data: any = { 16 | updatedAt: now, 17 | } 18 | if (typeof requestData.avatar_url !== 'undefined') data.avatarUrl = requestData.avatar_url 19 | if (typeof requestData.display_name !== 'undefined') data.displayName = requestData.display_name 20 | if (typeof requestData.bluesky_enabled !== 'undefined') data.blueskyEnabled = requestData.bluesky_enabled 21 | if (typeof requestData.bluesky_url !== 'undefined') data.blueskyUrl = requestData.bluesky_url 22 | if (typeof requestData.bluesky_identifier !== 'undefined') data.blueskyIdentifier = requestData.bluesky_identifier 23 | if (typeof requestData.bluesky_password !== 'undefined') data.blueskyPassword = requestData.bluesky_password 24 | if (typeof requestData.mastodon_enabled !== 'undefined') data.mastodonEnabled = requestData.mastodon_enabled 25 | if (typeof requestData.mastodon_url !== 'undefined') data.mastodonUrl = requestData.mastodon_url 26 | if (typeof requestData.mastodon_access_token !== 'undefined') data.mastodonAccessToken = requestData.mastodon_access_token 27 | if (typeof requestData.twitter_enabled !== 'undefined') data.twitterEnabled = requestData.twitter_enabled 28 | if (typeof requestData.twitter_consumer_key !== 'undefined') data.twitterConsumerKey = requestData.twitter_consumer_key 29 | if (typeof requestData.twitter_consumer_secret !== 'undefined') data.twitterConsumerSecret = requestData.twitter_consumer_secret 30 | if (typeof requestData.twitter_access_token !== 'undefined') data.twitterAccessToken = requestData.twitter_access_token 31 | if (typeof requestData.twitter_access_secret !== 'undefined') data.twitterAccessSecret = requestData.twitter_access_secret 32 | 33 | const [settings] = await prisma.$transaction([ 34 | prisma.settings.update({ 35 | where: { 36 | id: existingRecord.id 37 | }, 38 | data, 39 | }) 40 | ]) 41 | 42 | const result: any = { 43 | id: settings.id, 44 | avatar_url: settings.avatarUrl, 45 | display_name: settings.displayName, 46 | bluesky_enabled: settings.blueskyEnabled, 47 | bluesky_url: settings.blueskyUrl, 48 | bluesky_identifier: settings.blueskyIdentifier, 49 | bluesky_password: settings.blueskyPassword, 50 | mastodon_enabled: settings.mastodonEnabled, 51 | mastodon_url: settings.mastodonUrl, 52 | mastodon_access_token: settings.mastodonAccessToken, 53 | twitter_enabled: settings.twitterEnabled, 54 | twitter_consumer_key: settings.twitterConsumerKey, 55 | twitter_consumer_secret: settings.twitterConsumerSecret, 56 | twitter_access_token: settings.twitterAccessToken, 57 | twitter_access_secret: settings.twitterAccessSecret, 58 | } 59 | 60 | return result; 61 | 62 | } catch (error: any) { 63 | console.error(error) 64 | } 65 | }) 66 | -------------------------------------------------------------------------------- /server/api/threads.get.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../../prisma/db' 2 | 3 | export default defineEventHandler(async () => { 4 | console.log(`GET /api/threads`) 5 | 6 | const threads = await prisma.thread.findMany({ 7 | orderBy: [{ 8 | updatedAt: 'desc' 9 | }, { 10 | createdAt: 'desc' 11 | }], 12 | include: { 13 | versions: true 14 | } 15 | }) 16 | const result = threads.map(thread => { 17 | const [latest] = thread.versions.slice(-1) 18 | const latestThreadData: any = latest.data 19 | 20 | let title = `Thread #${thread.id}` 21 | if (latestThreadData.messages.length > 0) { 22 | title = latestThreadData.messages[0].text.slice(0, 80) 23 | } 24 | 25 | return { 26 | id: thread.id, 27 | title, 28 | createdAt: thread.createdAt, 29 | updatedAt: thread.updatedAt, 30 | scheduledAt: thread.scheduledAt, 31 | publishedAt: thread.publishedAt, 32 | nbMessages: latestThreadData.messages.length 33 | } 34 | }) 35 | return result; 36 | }) 37 | -------------------------------------------------------------------------------- /server/api/threads.post.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../../prisma/db' 2 | 3 | type CreateThreadRequest = { 4 | messages: [{ 5 | text: string, 6 | attachments: [{ 7 | createdAt: Date 8 | updatedAt: Date 9 | location: string 10 | alt?: string 11 | }] 12 | }] 13 | } 14 | 15 | export default defineEventHandler(async (event: any) => { 16 | console.log(`POST /api/threads`) 17 | 18 | const threadData: CreateThreadRequest = await readBody(event) 19 | const now = new Date() 20 | 21 | const [thread] = await prisma.$transaction([ 22 | prisma.thread.create({ 23 | data: { 24 | createdAt: now, 25 | versions: { 26 | create: [{ 27 | createdAt: now, 28 | data: threadData 29 | }] 30 | } 31 | }, 32 | include: { 33 | versions: true 34 | } 35 | })]) 36 | const result: any = { ...thread } 37 | if (thread && thread.versions) { 38 | const [latest] = thread.versions.slice(-1) 39 | result.latest = latest 40 | } 41 | return result; 42 | }) -------------------------------------------------------------------------------- /server/api/threads/[id].delete.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../../../prisma/db' 2 | 3 | export default defineEventHandler(async (event: any) => { 4 | const threadId = parseInt(event.context.params.id) as number 5 | 6 | console.log(`DELETE /api/threads/${threadId}`) 7 | 8 | await prisma.thread.delete({ 9 | where: { 10 | id: threadId, 11 | }, 12 | }) 13 | 14 | return {} 15 | }) -------------------------------------------------------------------------------- /server/api/threads/[id].get.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../../../prisma/db' 2 | 3 | export default defineEventHandler(async (event: any) => { 4 | const threadId = parseInt(event.context.params.id) as number 5 | 6 | console.log(`GET /api/threads/${threadId}`) 7 | 8 | const thread = await prisma.thread.findFirst({ 9 | where: { 10 | id: threadId 11 | }, 12 | include: { 13 | versions: true 14 | } 15 | }) 16 | const result: any = { ...thread } 17 | if (thread && thread.versions) { 18 | const [latest] = thread.versions.slice(-1) 19 | result.latest = latest 20 | } 21 | return result; 22 | }) -------------------------------------------------------------------------------- /server/api/threads/[id].post.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../../../prisma/db' 2 | 3 | type UpdateThreadRequest = { 4 | messages: [{ 5 | text: string, 6 | attachments: [{ 7 | createdAt: Date 8 | updatedAt: Date 9 | location: string 10 | alt?: string 11 | }] 12 | }] 13 | } 14 | 15 | export default defineEventHandler(async (event: any) => { 16 | const threadId = parseInt(event.context.params.id) as number 17 | 18 | console.log(`POST /api/threads/${threadId}`) 19 | 20 | const threadData: UpdateThreadRequest = await readBody(event) 21 | const now = new Date() 22 | 23 | const [thread] = await prisma.$transaction([ 24 | prisma.thread.update({ 25 | where: { 26 | id: threadId 27 | }, 28 | data: { 29 | updatedAt: now, 30 | versions: { 31 | create: [{ 32 | createdAt: now, 33 | data: threadData 34 | }] 35 | } 36 | }, 37 | include: { 38 | versions: true 39 | } 40 | })]) 41 | const result: any = { ...thread } 42 | if (thread && thread.versions) { 43 | const [latest] = thread.versions.slice(-1) 44 | result.latest = latest 45 | } 46 | return result; 47 | }) -------------------------------------------------------------------------------- /server/api/threads/[id]/publication.post.ts: -------------------------------------------------------------------------------- 1 | import { publish } from '~/services/publication-service'; 2 | import { Thread } from '~/models/models'; 3 | import { prisma } from '~/prisma/db'; 4 | 5 | export default defineEventHandler(async (event: any) => { 6 | const threadId = parseInt(event.context.params.id) as number 7 | 8 | console.log(`POST /api/threads/${threadId}/publication`) 9 | 10 | try { 11 | const thread: Thread = await publish(threadId) 12 | return thread 13 | } catch (error) { 14 | return { 15 | status: 'error', 16 | data: error 17 | } 18 | } 19 | }) -------------------------------------------------------------------------------- /server/api/threads/[id]/schedule.delete.ts: -------------------------------------------------------------------------------- 1 | import { queue } from '~/utils/queue' 2 | import { prisma } from '~/prisma/db' 3 | 4 | export default defineEventHandler(async (event: any) => { 5 | const threadId = parseInt(event.context.params.id) as number 6 | 7 | console.log(`DELETE /api/threads/${threadId}/schedule`) 8 | 9 | const threadData = await prisma.thread.findFirst({ where: { id: threadId } }) 10 | if (!threadData) { 11 | throw new Error(`Could not publish thread with ID ${threadId} because it does not exist.`) 12 | } 13 | await prisma.thread.update({ 14 | where: { id: threadId }, 15 | data: { scheduledAt: null } 16 | }) 17 | 18 | const jobName = `thread-${threadId}` 19 | const jobs = await queue.getDelayed() 20 | const threadScheduleJob = jobs.find((job) => job.name === jobName) 21 | if (threadScheduleJob && threadScheduleJob.id) { 22 | await queue.remove(threadScheduleJob.id) 23 | } 24 | 25 | return 26 | }) -------------------------------------------------------------------------------- /server/api/threads/[id]/schedule.post.ts: -------------------------------------------------------------------------------- 1 | import { Thread } from '~/models/models' 2 | import { Worker } from 'bullmq' 3 | import { publish } from '~/services/publication-service' 4 | import { connection, queue } from '~/utils/queue' 5 | import { prisma } from '~/prisma/db' 6 | 7 | export default defineEventHandler(async (event: any) => { 8 | const threadId = parseInt(event.context.params.id) as number 9 | const data: any = await readBody(event) 10 | 11 | console.log(`POST /api/threads/${threadId}/schedule`) 12 | 13 | console.log("data:", data) 14 | 15 | console.log('data.scheduledAt', data.scheduledAt) 16 | const scheduledAt = Date.parse(data.scheduledAt) 17 | const delay = scheduledAt - Date.now() 18 | 19 | const threadData = await prisma.thread.findFirst({ where: { id: threadId } }) 20 | if (!threadData) { 21 | throw new Error(`Could not publish thread with ID ${threadId} because it does not exist.`) 22 | } 23 | await prisma.thread.update({ 24 | where: { id: threadId }, 25 | data: { scheduledAt: data.scheduledAt } 26 | }) 27 | 28 | await queue.add(`thread-${threadId}`, { threadId: threadId }, { delay }); 29 | 30 | const worker = new Worker('thread-schedules', async (job) => { 31 | try { 32 | const thread: Thread = await publish(threadId) 33 | return thread 34 | } catch (error) { 35 | return { 36 | status: 'error', 37 | data: error 38 | } 39 | } 40 | }, { connection }) 41 | worker.on('completed', job => { 42 | console.log(`Job ${job.id} has completed!`); 43 | }); 44 | 45 | worker.on('failed', (job, err) => { 46 | console.log(`Job ${job?.id} has failed with ${err.message}`); 47 | }); 48 | }) -------------------------------------------------------------------------------- /server/settings.get.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll } from "vitest" 2 | import { setup, fetch } from '@nuxt/test-utils' 3 | import { prisma } from '../prisma/db' 4 | 5 | async function insertIntoSettings(data: any): Promise { 6 | try { 7 | await prisma.settings.create({ data }) 8 | console.log('Data inserted successfully'); 9 | } catch (error) { 10 | console.error('Error inserting data into Settings:', error); 11 | } 12 | } 13 | 14 | describe("GET /api/settings", async () => { 15 | 16 | await setup({ browser: false }) 17 | 18 | describe('when there is no data in database', () => { 19 | 20 | beforeAll(async () => { 21 | await prisma.settings.deleteMany() 22 | }); 23 | 24 | it("should respond with status code 204", async () => { 25 | // when 26 | const response: Response = await fetch('/api/settings'); 27 | 28 | // then 29 | expect(response.status).toBe(204) 30 | }); 31 | 32 | it("should return nothing", async () => { 33 | // when 34 | const response: Response = await fetch('/api/settings'); 35 | 36 | // then 37 | expect(response.body).toBe(null) 38 | }) 39 | 40 | }) 41 | 42 | describe('when there is data in database', () => { 43 | 44 | const data = { 45 | displayName: 'jbuget@threadr.app', 46 | avatarUrl: 'https://example.com/avatar.png', 47 | blueskyEnabled: true, 48 | blueskyUrl: 'https://bsky.social', 49 | blueskyIdentifier: 'my_bluesky_identifier', 50 | blueskyPassword: 'my_bluesky_password', 51 | mastodonEnabled: true, 52 | mastodonUrl: 'https://piaille.fr', 53 | mastodonAccessToken: 'my_mastodon_access_token', 54 | twitterEnabled: true, 55 | twitterConsumerKey: 'my_mastodon_access_token', 56 | twitterConsumerSecret: 'my_twitter_consumer_secret', 57 | twitterAccessToken: 'my_twitter_access_token', 58 | twitterAccessSecret: 'my_twitter_access_secret', 59 | } 60 | 61 | beforeAll(async () => { 62 | await prisma.settings.deleteMany() 63 | await insertIntoSettings(data) 64 | }); 65 | 66 | it("should respond with status code 200", async () => { 67 | // when 68 | const response: Response = await fetch('/api/settings'); 69 | 70 | // then 71 | expect(response.status).toBe(200) 72 | }); 73 | 74 | it("should return a JSON object", async () => { 75 | // when 76 | const response: Response = await fetch('/api/settings'); 77 | 78 | /// then 79 | const body = await response.json(); 80 | expect(body).toEqual({ 81 | display_name: data.displayName, 82 | avatar_url: data.avatarUrl, 83 | bluesky_enabled: data.blueskyEnabled, 84 | bluesky_url: data.blueskyUrl, 85 | bluesky_identifier: data.blueskyIdentifier, 86 | bluesky_password: data.blueskyPassword, 87 | mastodon_enabled: data.mastodonEnabled, 88 | mastodon_url: data.mastodonUrl, 89 | mastodon_access_token: data.mastodonAccessToken, 90 | twitter_enabled: data.twitterEnabled, 91 | twitter_consumer_key: data.twitterConsumerKey, 92 | twitter_consumer_secret: data.twitterConsumerSecret, 93 | twitter_access_token: data.twitterAccessToken, 94 | twitter_access_secret: data.twitterAccessSecret, 95 | }) 96 | }); 97 | }) 98 | 99 | }); 100 | -------------------------------------------------------------------------------- /server/settings.patch.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from "vitest" 2 | import { $fetch, setup } from '@nuxt/test-utils' 3 | import { prisma } from '../prisma/db' 4 | 5 | async function insertIntoSettings(data: any): Promise { 6 | try { 7 | const settings = await prisma.settings.create({ data }) 8 | console.log('Data inserted successfully'); 9 | return settings 10 | } catch (error) { 11 | console.error('Error inserting data into Settings:', error); 12 | } 13 | } 14 | 15 | describe("PATCH /api/settings", async () => { 16 | 17 | await setup({ browser: false }) 18 | 19 | describe('when there is data in database', () => { 20 | 21 | const beforeData = { 22 | displayName: 'before_display_name', 23 | avatarUrl: 'https://before.avatar.url', 24 | blueskyEnabled: false, 25 | blueskyUrl: 'https://before.bluesky.url', 26 | blueskyIdentifier: 'before_bluesky_identifier', 27 | blueskyPassword: 'before_bluesky_password', 28 | mastodonEnabled: false, 29 | mastodonUrl: 'https://before.mastodon.url', 30 | mastodonAccessToken: 'before_mastodon_access_token', 31 | twitterEnabled: false, 32 | twitterConsumerKey: 'before_mastodon_access_token', 33 | twitterConsumerSecret: 'before_twitter_consumer_secret', 34 | twitterAccessToken: 'before_twitter_access_token', 35 | twitterAccessSecret: 'before_twitter_access_secret', 36 | } 37 | 38 | let existingSettings: any 39 | 40 | const afterData = { 41 | displayName: 'after_display_name', 42 | avatarUrl: 'https://after.avatar.url', 43 | blueskyEnabled: true, 44 | blueskyUrl: 'https://after.bluesky.url', 45 | blueskyIdentifier: 'after_bluesky_identifier', 46 | blueskyPassword: 'after_bluesky_password', 47 | mastodonEnabled: true, 48 | mastodonUrl: 'https://after.mastodon.url', 49 | mastodonAccessToken: 'after_mastodon_access_token', 50 | twitterEnabled: true, 51 | twitterConsumerKey: 'after_mastodon_access_token', 52 | twitterConsumerSecret: 'after_twitter_consumer_secret', 53 | twitterAccessToken: 'after_twitter_access_token', 54 | twitterAccessSecret: 'after_twitter_access_secret', 55 | } 56 | 57 | const newBody = { 58 | display_name: afterData.displayName, 59 | avatar_url: afterData.avatarUrl, 60 | bluesky_enabled: afterData.blueskyEnabled, 61 | bluesky_url: afterData.blueskyUrl, 62 | bluesky_identifier: afterData.blueskyIdentifier, 63 | bluesky_password: afterData.blueskyPassword, 64 | mastodon_enabled: afterData.mastodonEnabled, 65 | mastodon_url: afterData.mastodonUrl, 66 | mastodon_access_token: afterData.mastodonAccessToken, 67 | twitter_enabled: afterData.twitterEnabled, 68 | twitter_consumer_key: afterData.twitterConsumerKey, 69 | twitter_consumer_secret: afterData.twitterConsumerSecret, 70 | twitter_access_token: afterData.twitterAccessToken, 71 | twitter_access_secret: afterData.twitterAccessSecret, 72 | } 73 | 74 | beforeEach(async () => { 75 | await prisma.settings.deleteMany() 76 | existingSettings = await insertIntoSettings(beforeData) 77 | }); 78 | 79 | it.skip("should respond with status code 200", async () => { 80 | // when 81 | const response: Response = await $fetch('/api/settings', { method: 'PATCH', body: newBody }); 82 | 83 | // then 84 | expect(response.status).toBe(200) 85 | }) 86 | 87 | it.skip("should return a JSON object", async () => { 88 | // when 89 | const response: Response = await $fetch('/api/settings', { method: 'PATCH', body: newBody }); 90 | 91 | // then 92 | const body = await response.json(); 93 | expect(body).toEqual(newBody) 94 | }); 95 | 96 | it("should update data in DB", async () => { 97 | // when 98 | await $fetch('/api/settings', { method: 'PATCH', body: { ...newBody } }); 99 | 100 | // then 101 | const persistedSettings = await prisma.settings.findFirst() 102 | expect(persistedSettings).toMatchObject({ 103 | id: existingSettings.id, 104 | createdAt: existingSettings.createdAt, 105 | ...afterData 106 | }) 107 | }); 108 | 109 | }) 110 | 111 | }); 112 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /services/bluesky-url-facets-extractor.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import buildUrlFacets from "./bluesky-url-facets-extractor"; 3 | 4 | describe("Bluesky build url facets from text", () => { 5 | it("should return empty array if no url in text", () => { 6 | const text = "This is a text without url"; 7 | const urls = buildUrlFacets(text); 8 | expect(urls).toEqual([]); 9 | }); 10 | 11 | it("should find urls in text", () => { 12 | const text = 13 | "\u2728 example mentioning @atproto.com to share the URL \ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68 https://en.wikipedia.org/wiki/CBOR."; 14 | const urls = buildUrlFacets(text); 15 | expect(urls).toEqual([ 16 | { 17 | index: { 18 | byteStart: 74, 19 | byteEnd: 108, 20 | }, 21 | features: [ 22 | { 23 | $type: "app.bsky.richtext.facet#link", 24 | uri: "https://en.wikipedia.org/wiki/CBOR", 25 | }, 26 | ], 27 | }, 28 | ]); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /services/bluesky-url-facets-extractor.ts: -------------------------------------------------------------------------------- 1 | const buildUrlFacets = (text: string) => { 2 | const urlRegex = 3 | /http(s)?:\/\/.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+~#?&//=]*)/g; 4 | const urlFacets = []; 5 | 6 | const encoder = new TextEncoder(); 7 | 8 | let match; 9 | while ((match = urlRegex.exec(text)) !== null) { 10 | const startIndex = encoder.encode(text.substring(0, match.index)).length; 11 | const url = match[0]; 12 | const endIndex = startIndex + encoder.encode(url).length; 13 | 14 | urlFacets.push({ 15 | index: { 16 | byteStart: startIndex, 17 | byteEnd: endIndex, 18 | }, 19 | features: [ 20 | { 21 | $type: "app.bsky.richtext.facet#link", 22 | uri: url, 23 | }, 24 | ], 25 | }); 26 | } 27 | 28 | return urlFacets; 29 | }; 30 | 31 | export default buildUrlFacets; 32 | -------------------------------------------------------------------------------- /services/publication-service.ts: -------------------------------------------------------------------------------- 1 | import { Thread, Message } from "~/models/models"; 2 | import { createRestAPIClient, mastodon } from "masto"; 3 | import AtProtocole from "@atproto/api"; 4 | import { ReplyRef } from "@atproto/api/dist/client/types/app/bsky/feed/post"; 5 | import { 6 | SendTweetV2Params, 7 | TweetV2PostTweetResult, 8 | TwitterApi, 9 | } from "twitter-api-v2"; 10 | import { prisma } from "~/prisma/db"; 11 | import buildUrlFacets from "./bluesky-url-facets-extractor"; 12 | import { Settings } from "@prisma/client"; 13 | 14 | // Publication on Bluesky 15 | 16 | interface RecordRef { 17 | uri: string; 18 | cid: string; 19 | } 20 | 21 | async function getBlueskyClient(settings: Settings): Promise { 22 | console.log("Connect to Bluesky…") 23 | let blueskyClient: AtProtocole.BskyAgent 24 | blueskyClient = new AtProtocole.BskyAgent({ 25 | service: settings.blueskyUrl, 26 | }) 27 | await blueskyClient.login({ 28 | identifier: settings.blueskyIdentifier, 29 | password: settings.blueskyPassword, 30 | }) 31 | console.log("Connection to Bluesky acquired.") 32 | return blueskyClient 33 | } 34 | 35 | async function postMessageOnBluesky( 36 | blueskyClient: AtProtocole.BskyAgent, 37 | message: Message, 38 | reply: ReplyRef | null 39 | ): Promise { 40 | try { 41 | const record: any = {}; 42 | record.text = message.text; 43 | record.facets = buildUrlFacets(message.text); 44 | 45 | if (message.attachments && message.attachments.length > 0) { 46 | let embed: any; 47 | embed = { 48 | $type: "app.bsky.embed.images", 49 | images: [], 50 | }; 51 | for (const file of message.attachments) { 52 | const mediaFile = await fetch(file.location); 53 | const mediaData = await mediaFile.arrayBuffer(); 54 | const mediaResponse = await blueskyClient.uploadBlob( 55 | Buffer.from(mediaData), 56 | { encoding: file.mimetype } 57 | ); 58 | embed.images.push({ image: mediaResponse.data.blob, alt: file.alt }); 59 | } 60 | record.embed = embed; 61 | } 62 | 63 | if (reply) { 64 | record.reply = reply; 65 | } 66 | 67 | return blueskyClient.post(record); 68 | } catch (error) { 69 | console.error(error); 70 | throw error; 71 | } 72 | } 73 | 74 | async function postMessagesOnBluesky(blueskyClient: AtProtocole.BskyAgent, messages: Message[]): Promise { 75 | try { 76 | let reply: ReplyRef | null = null; 77 | 78 | for (const message of messages) { 79 | console.log("Publish message on Bluesky"); 80 | const recordRef: RecordRef = await postMessageOnBluesky(blueskyClient, message, reply); 81 | reply = { 82 | parent: { 83 | cid: recordRef.cid, 84 | uri: recordRef.uri, 85 | }, 86 | root: { 87 | cid: recordRef.cid, 88 | uri: recordRef.uri, 89 | }, 90 | }; 91 | console.log("Message published on Bluesky."); 92 | } 93 | } catch (error) { 94 | console.error(error); 95 | throw error; 96 | } 97 | } 98 | 99 | // Publication on Mastodon 100 | 101 | async function getMastodonClient(settings: Settings): Promise { 102 | console.log("Connect to Mastodon…") 103 | let mastodonClient: mastodon.rest.Client 104 | mastodonClient = createRestAPIClient({ 105 | url: settings.mastodonUrl || 'undefined_url', 106 | accessToken: settings.mastodonAccessToken || 'undefined_access_token', 107 | }) 108 | console.log("Connection to Mastodon acquired.") 109 | return mastodonClient 110 | } 111 | 112 | async function postMessageOnMastodon( 113 | mastodonClient: mastodon.rest.Client, 114 | message: Message, 115 | inReplyToId: string | null 116 | ): Promise { 117 | const mediaIds: string[] = []; 118 | if (message.attachments) { 119 | for (const attachment of message.attachments) { 120 | const remoteFile = await fetch(attachment.location); 121 | const media = await mastodonClient.v2.media.create({ 122 | file: await remoteFile.blob(), 123 | description: attachment.alt, 124 | }); 125 | mediaIds.push(media.id); 126 | } 127 | } 128 | 129 | return mastodonClient.v1.statuses.create({ 130 | status: message.text, 131 | visibility: "public", 132 | mediaIds, 133 | inReplyToId, 134 | }); 135 | } 136 | 137 | async function postMessagesOnMastodon(mastodonClient: mastodon.rest.Client, messages: Message[]): Promise { 138 | let inReplyToId: string | null = null; 139 | 140 | for (const message of messages) { 141 | console.log("Publish message on Mastodon…"); 142 | const status: mastodon.v1.Status = await postMessageOnMastodon( 143 | mastodonClient, 144 | message, 145 | inReplyToId 146 | ); 147 | inReplyToId = status.id; 148 | console.log("Message published on Mastodon."); 149 | } 150 | } 151 | 152 | // Publication on Twitter 153 | 154 | async function getTwitterClient(settings: Settings): Promise { 155 | console.log("Connect to Twitter…") 156 | let twitterClient: TwitterApi 157 | twitterClient = new TwitterApi({ 158 | appKey: settings.twitterConsumerKey || 'twitter_consumer_key', 159 | appSecret: settings.twitterConsumerSecret || 'twitter_consumer_secret', 160 | accessToken: settings.twitterAccessToken || 'twitter_access_token', 161 | accessSecret: settings.twitterAccessSecret || 'twitter_access_secret', 162 | }); 163 | console.log("Connection to Twitter acquired.") 164 | return twitterClient 165 | } 166 | 167 | async function postMessageOnTwitter( 168 | twitterClient: TwitterApi, 169 | message: Message, 170 | reply: TweetV2PostTweetResult | null 171 | ): Promise { 172 | try { 173 | const tweet: SendTweetV2Params = {}; 174 | if (message.text) { 175 | tweet.text = message.text; 176 | } 177 | if (message.attachments && message.attachments.length > 0) { 178 | const mediaIds = []; 179 | for (const file of message.attachments) { 180 | const mediaResponse = await fetch(file.location); 181 | const mediaData = await mediaResponse.arrayBuffer(); 182 | const mediaId = await twitterClient.v1.uploadMedia( 183 | Buffer.from(mediaData), 184 | { mimeType: file.mimetype } 185 | ); 186 | mediaIds.push(mediaId); 187 | } 188 | tweet.media = { media_ids: mediaIds }; 189 | } 190 | if (reply && reply.data) { 191 | tweet.reply = { in_reply_to_tweet_id: reply.data.id }; 192 | } 193 | return twitterClient.v2.tweet(tweet); 194 | } catch (error) { 195 | console.error(error); 196 | throw error; 197 | } 198 | } 199 | 200 | async function postMessagesOnTwitter(twitterClient: TwitterApi, messages: Message[]): Promise { 201 | let reply: TweetV2PostTweetResult | null = null; 202 | 203 | for (const message of messages) { 204 | console.log("Publish message on Twitter"); 205 | reply = await postMessageOnTwitter(twitterClient, message, reply); 206 | console.log("Message published on twitter."); 207 | } 208 | } 209 | 210 | async function postMessages(messages: Message[]): Promise { 211 | const platforms: string[] = []; 212 | 213 | const settings = await prisma.settings.findFirst() 214 | 215 | if (settings) { 216 | if (settings.blueskyEnabled) { 217 | const blueskyClient = await getBlueskyClient(settings) 218 | console.log("Publish messages on Bluesky"); 219 | await postMessagesOnBluesky(blueskyClient, messages); 220 | platforms.push("Bluesky"); 221 | console.log("Messages published on Bluesky."); 222 | } 223 | 224 | if (settings.mastodonEnabled) { 225 | const mastodonClient = await getMastodonClient(settings) 226 | console.log("Publish messages on Mastodon…"); 227 | await postMessagesOnMastodon(mastodonClient, messages); 228 | platforms.push("Mastodon"); 229 | console.log("Messages published on Mastodon."); 230 | } 231 | 232 | if (settings.twitterEnabled) { 233 | const twitterClient = await getTwitterClient(settings) 234 | console.log("Publish messages on Twitter…"); 235 | await postMessagesOnTwitter(twitterClient, messages); 236 | platforms.push("Twitter"); 237 | console.log("Messages published on Twitter."); 238 | } 239 | 240 | } 241 | return platforms; 242 | } 243 | 244 | export async function publish(threadId: number): Promise { 245 | const threadData = await prisma.thread.findFirst({ 246 | where: { 247 | id: threadId, 248 | }, 249 | include: { 250 | versions: true, 251 | }, 252 | }); 253 | 254 | if (!threadData) { 255 | throw new Error( 256 | `Could not publish thread with ID ${threadId} because it does not exist.` 257 | ); 258 | } 259 | 260 | const [latestVersion] = threadData.versions.slice(-1); 261 | const latestVersionData: any = latestVersion.data; 262 | 263 | console.log("Publish thread…"); 264 | const platforms: string[] = await postMessages(latestVersionData.messages); 265 | 266 | await prisma.thread.update({ 267 | where: { 268 | id: threadId, 269 | }, 270 | data: { 271 | publishedAt: new Date(), 272 | }, 273 | }); 274 | 275 | let report = "Thread published"; 276 | if (platforms.length === 1) { 277 | report += " on " + platforms[0]; 278 | } 279 | if (platforms.length > 1) { 280 | const last = platforms.pop(); 281 | report += " on " + platforms.join(", ") + " and " + last; 282 | } 283 | console.log(`${report} 🎉 !`); 284 | return { 285 | id: threadData.id, 286 | messages: latestVersionData.messages, 287 | }; 288 | } 289 | -------------------------------------------------------------------------------- /threadr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/threadr.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /utils/queue.ts: -------------------------------------------------------------------------------- 1 | import IORedis from 'ioredis'; 2 | import { Queue } from 'bullmq'; 3 | 4 | const connection = new IORedis(process.env.REDIS_URL as string, { maxRetriesPerRequest: null }); 5 | 6 | const queue = new Queue('thread-schedules', { connection }) 7 | 8 | export { connection, queue } -------------------------------------------------------------------------------- /utils/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../prisma/db' 2 | 3 | async function clearData() { 4 | try { 5 | await prisma.thread.deleteMany(); 6 | await prisma.version.deleteMany(); 7 | await prisma.settings.deleteMany(); 8 | } catch (error) { 9 | console.error("Erreur lors de l'exécution du script SQL:", error); 10 | } 11 | } 12 | 13 | export { clearData }; -------------------------------------------------------------------------------- /utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function generateUniqueKey(prefix: string, suffix?: string | number) { 2 | return `${prefix}_${Date.now()}_${generateRandomString()}` 3 | } 4 | 5 | export function generateRandomString() { 6 | return Math.random().toString(36).slice(2, 7); 7 | } --------------------------------------------------------------------------------