├── !Dockerfile
├── .eslintrc.json
├── .example.env.local
├── .gitignore
├── Dockerfile
├── Dockerfile.dev
├── LICENSE
├── README.md
├── docker-compose.dev.yml
├── docker-compose.yml
├── fluent-bit
└── fluent-bit.conf
├── kong.yml
├── messages
├── ar.json
├── ca.json
├── cs.json
├── de.json
├── el.json
├── en.json
├── es.json
├── fr.json
├── gl.json
├── it.json
├── ja.json
├── kab.json
├── pt.json
└── sv.json
├── next-auth.d.ts
├── next.config.js
├── nginx
└── nginx.conf
├── package.json
├── postcss.config.js
├── public
├── boats
│ ├── boat-1.svg
│ ├── boat-2.svg
│ ├── boat-3.svg
│ ├── boat-4.svg
│ ├── boat-5.svg
│ ├── boat-6.svg
│ ├── boat-7.svg
│ ├── boat-8.svg
│ └── boat-9.svg
├── client-metadata.json
├── compass
│ ├── loader-compass.svg
│ └── needle.svg
├── file.svg
├── globe.svg
├── logo-cnrs-blanc.svg
├── logo-cnrs-bleu.svg
├── logo.png
├── logo
│ ├── logo-openport-blanc.svg
│ └── logo-openport-rose.svg
├── logos
│ ├── logo-openport-blanc.svg
│ └── logo-openport-rose.svg
├── logoxHQX
│ ├── HQX-blanc-FR.svg
│ ├── HQX-pink-UK.svg
│ ├── HQX-rose-FR.svg
│ └── HQX-white-UK.svg
├── newSVG
│ ├── BS.svg
│ ├── HQX-badge.svg
│ ├── X.svg
│ ├── chainon.svg
│ ├── close.svg
│ ├── masto.svg
│ ├── notif.svg
│ ├── steps-Init.svg
│ ├── steps-OK.svg
│ ├── steps-partiel.svg
│ ├── success-badge.svg
│ ├── uil_arrow-down.svg
│ ├── uil_arrow-growth.svg
│ ├── upload.svg
│ └── video.svg
├── next.svg
├── progress
│ ├── progress-0.svg
│ ├── progress-100.svg
│ ├── progress-25.svg
│ ├── progress-33.svg
│ ├── progress-50.svg
│ ├── progress-66.svg
│ └── progress-75.svg
├── sea-wave.svg
├── sea.svg
├── v2
│ ├── HQX-badge.svg
│ ├── Pause.svg
│ ├── badge-success-1.png
│ ├── badge-success-1.svg
│ ├── badge-success-2-OFF.svg
│ ├── badge-success-2.svg
│ ├── chainon.svg
│ ├── check.svg
│ ├── close.svg
│ ├── play.svg
│ ├── statut=BS-defaut.svg
│ ├── statut=BS-hover.svg
│ ├── statut=Masto-Defaut.svg
│ ├── statut=Masto-hover.svg
│ ├── success-badge.svg
│ ├── uil_arrow-down.svg
│ └── uil_arrow-growth.svg
├── vercel.svg
└── window.svg
├── python_worker
├── Dockerfile
├── dist
│ ├── PythonProcessor.js
│ └── index.js
├── messages
│ ├── de.json
│ ├── en.json
│ ├── es.json
│ ├── fr.json
│ ├── it.json
│ ├── pt.json
│ └── sv.json
├── package-lock.json
├── package.json
├── requirements.txt
├── sendRecoNewsletter.py
├── src
│ ├── PythonProcessor.ts
│ ├── index.ts
│ └── log_utils.ts
├── start-py-workers.sh
├── testDm.py
├── testDm_bluesky.py
├── testDm_mastodon.py
└── tsconfig.json
├── src
├── app
│ ├── [locale]
│ │ ├── auth
│ │ │ ├── error
│ │ │ │ └── page.tsx
│ │ │ └── signin
│ │ │ │ └── page.tsx
│ │ ├── dashboard
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── privacy_policy
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ ├── reconnect
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ ├── settings
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ └── upload
│ │ │ ├── large-files
│ │ │ └── page.tsx
│ │ │ └── page.tsx
│ ├── _components
│ │ ├── AccountToMigrate.tsx
│ │ ├── AppLayout.tsx
│ │ ├── AutomaticReconnexion.tsx
│ │ ├── BlueSkyLogin.tsx
│ │ ├── BlueSkyLoginButton.tsx
│ │ ├── BlueSkyPreviewModal.tsx
│ │ ├── Boat.tsx
│ │ ├── ConnectedAccounts.tsx
│ │ ├── ConnectedServicesInfo.tsx
│ │ ├── ConsentModal.tsx
│ │ ├── DashboardLoginButtons.tsx
│ │ ├── DashboardSea.tsx
│ │ ├── ErrorModal.tsx
│ │ ├── Footer.tsx
│ │ ├── Header.tsx
│ │ ├── LaunchReconnection.tsx
│ │ ├── LoadingIndicator.tsx
│ │ ├── LoginButtons.tsx
│ │ ├── LoginSea.tsx
│ │ ├── ManualReconnexion.tsx
│ │ ├── MastodonLoginButton.tsx
│ │ ├── MatchedBlueSkyProfiles.tsx
│ │ ├── MigrateSea.tsx
│ │ ├── MigrateStats.tsx
│ │ ├── MigrationComplete.tsx
│ │ ├── MotionWrapper.tsx
│ │ ├── NewsLetterConsentsUpdate.tsx
│ │ ├── NewsLetterFirstSeen.tsx
│ │ ├── NewsletterRequest.tsx
│ │ ├── PartageButton.tsx
│ │ ├── ProfileCard.tsx
│ │ ├── ProgressSteps.tsx
│ │ ├── ReconnexionModaleResults.tsx
│ │ ├── ReconnexionOptions.tsx
│ │ ├── RefreshTokenModale.tsx
│ │ ├── RequestNewsLetterDM.tsx
│ │ ├── SettingsOptions.tsx
│ │ ├── StatsReconnexion.tsx
│ │ ├── SuccessAutomaticReconnexion.tsx
│ │ ├── SuccessModal.tsx
│ │ ├── SupportModale.tsx
│ │ ├── TwitterLoginButton.tsx
│ │ ├── TwitterRateLimit.tsx
│ │ ├── UploadButton.tsx
│ │ ├── UploadResults.tsx
│ │ ├── dashboard
│ │ │ ├── NewsletterSection.tsx
│ │ │ ├── OnboardingSection.tsx
│ │ │ └── TutorialSection.tsx
│ │ ├── reconnect
│ │ │ ├── ReconnectContainer.tsx
│ │ │ ├── ReconnectOptionsSections.tsx
│ │ │ ├── ReconnectStatusSection.tsx
│ │ │ └── states
│ │ │ │ ├── AutomaticReconnectionState.tsx
│ │ │ │ ├── LoadingState.tsx
│ │ │ │ ├── ManualReconnectionState.tsx
│ │ │ │ ├── MissingTokenState.tsx
│ │ │ │ ├── NoConnectedServicesState.tsx
│ │ │ │ ├── PartialConnectedServicesState.tsx
│ │ │ │ ├── ReconnectionCompleteState.tsx
│ │ │ │ └── ShowOptionsState.tsx
│ │ ├── settings
│ │ │ ├── PersonalizedSupportFlowSection.tsx
│ │ │ └── SwitchSettingsSection.tsx
│ │ └── ui
│ │ │ └── sheet.tsx
│ ├── api
│ │ ├── auth
│ │ │ ├── [...nextauth]
│ │ │ │ └── route.ts
│ │ │ ├── bluesky
│ │ │ │ └── route.ts
│ │ │ ├── mastodon
│ │ │ │ └── route.ts
│ │ │ ├── refresh
│ │ │ │ └── route.ts
│ │ │ └── unlink
│ │ │ │ └── route.ts
│ │ ├── delete
│ │ │ └── route.ts
│ │ ├── import-status
│ │ │ └── [jobId]
│ │ │ │ └── route.ts
│ │ ├── migrate
│ │ │ ├── matching_found
│ │ │ │ └── route.ts
│ │ │ └── send_follow
│ │ │ │ └── route.ts
│ │ ├── newsletter
│ │ │ ├── follow_bot
│ │ │ │ └── route.ts
│ │ │ ├── request
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── share
│ │ │ ├── bluesky
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── stats
│ │ │ ├── route.ts
│ │ │ └── total
│ │ │ │ └── route.ts
│ │ ├── support
│ │ │ └── route.ts
│ │ ├── tasks
│ │ │ └── [taskId]
│ │ │ │ └── route.ts
│ │ ├── update
│ │ │ └── user_stats
│ │ │ │ └── route.ts
│ │ ├── upload
│ │ │ ├── large-files
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ └── users
│ │ │ ├── automatic-reconnect
│ │ │ └── route.ts
│ │ │ ├── bot-newsletter
│ │ │ └── route.ts
│ │ │ └── language
│ │ │ └── route.ts
│ ├── auth.config.ts
│ ├── auth.ts
│ ├── favicon.ico
│ ├── fonts
│ │ ├── GeistMonoVF.woff
│ │ ├── GeistVF.woff
│ │ ├── SyneTactile-Regular.ttf
│ │ └── plex.ts
│ ├── globals.css
│ ├── layout.tsx
│ └── providers.tsx
├── hooks
│ ├── useAuthTokens.ts
│ ├── useBotNewsletterState.ts
│ ├── useDashboardState.ts
│ ├── useMastodonInstances.ts
│ ├── useNewsLetter.ts
│ ├── useReconnectState.ts
│ └── useStats.ts
├── i18n
│ ├── navigation.ts
│ └── request.ts
├── lib
│ ├── bluesky.ts
│ ├── dataFormating.ts
│ ├── encryption.ts
│ ├── log_utils.ts
│ ├── repositories
│ │ ├── accountRepository.ts
│ │ ├── blueskyRepository.ts
│ │ ├── matchingRepository.ts
│ │ ├── statsRepository.ts
│ │ └── userRepository.ts
│ ├── services
│ │ ├── accountService.ts
│ │ ├── blueskyServices.ts
│ │ ├── mastodonService.ts
│ │ ├── matchingService.ts
│ │ ├── newsletterService.ts
│ │ ├── statsServices.ts
│ │ └── userServices.ts
│ ├── states
│ │ └── reconnectStates.ts
│ ├── supabase-adapter.ts
│ ├── supabase.ts
│ ├── types
│ │ ├── account.ts
│ │ ├── bluesky.ts
│ │ ├── common.ts
│ │ ├── mastodon.ts
│ │ ├── matching.ts
│ │ ├── stats.ts
│ │ └── user.ts
│ ├── upload_utils.ts
│ └── utils.ts
├── middleware.ts
└── services
│ └── api.ts
├── supabase
├── .gitignore
├── config.toml
├── functions
│ └── process-imports
│ │ └── index.ts
├── migrations
│ ├── 001_create_schemas.sql
│ ├── 002_create_users_table.sql
│ ├── 003_create_accounts_table.sql
│ ├── 004_create_sessions_table.sql
│ ├── 005_create_verification_tokens_table.sql
│ ├── 006_create_user_data_table.sql
│ ├── 007_create_row_level_security.sql
│ ├── 008_update_rls_for_nextauth.sql
│ ├── 009_create_table_public_profiles.sql
│ ├── 010_create_bluesky_table.sql
│ ├── 011_share_events.sql
│ ├── 012_create_import_jobs.sql
│ ├── 013_create_mastodon_tables.sql
│ ├── 014_create_bluesky_mapping.sql
│ ├── 015_create_bluesky_twitter.sql
│ ├── 017_update_sources.sql
│ ├── 018_create_view_migration_bluesky.sql
│ ├── 019_create_mastodon_twitter_users.sql
│ ├── 020_update_targets_with_mastodon.sql
│ ├── 021_create_reconnect_queue.sql
│ ├── 022_create_trigger_to_update_sources_targets.sql
│ └── 023_create_functions_for_stats.sql
├── migrations_backup
│ ├── 001_create_schemas.sql
│ ├── 20241217191141_create_schemas.sql
│ ├── 20241217191212_create_users_table.sql
│ ├── 20241217191213_create_accounts_table.sql
│ ├── 20241217191214_create_sessions_table.sql
│ ├── 20241217191215_create_verification_tokens_table.sql
│ ├── 20241217191216_create_user_data_table.sql
│ ├── 20241217191217_create_row_level_security.sql
│ ├── 20241217191218_update_rls_for_nextauth.sql
│ ├── 20241217191219_create_table_public_profiles.sql
│ ├── 20241217191220_create_bluesky_table.sql
│ └── 20241217191221_share_events.sql
└── seed.sql
├── tailwind.config.js
├── tailwind.config.ts
├── tsconfig.json
├── watcher
├── Dockerfile
├── monitor.log
├── package.json
├── src
│ └── monitor.ts
└── tsconfig.json
├── worker
├── Dockerfile
├── Dockerfile.dev
├── package.json
├── src
│ ├── index.ts
│ ├── jobProcessor.ts
│ ├── log_utils.ts
│ └── utils.ts
├── start-workers.sh
└── tsconfig.json
└── worker_refreshtoken
├── Dockerfile
├── package-lock.json
├── package.json
├── src
└── index.ts
└── tsconfig.json
/!Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18-alpine
2 | WORKDIR /app
3 |
4 | # Copier tous les fichiers du projet
5 | COPY . .
6 |
7 | # Installer les dépendances
8 | RUN npm install
9 |
10 | # Set environment variables
11 | ENV NODE_ENV=development
12 | ENV NEXT_TELEMETRY_DISABLED=1
13 | ENV PORT=3000
14 | ENV HOSTNAME="0.0.0.0"
15 |
16 | # Exposer le port
17 | EXPOSE 3000
18 |
19 | # Utiliser npm run dev pour le hot reload
20 | CMD ["npm", "run", "dev"]
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals"],
3 | "rules": {
4 | "react/no-unescaped-entities": "off",
5 | "react-hooks/exhaustive-deps": "off"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.example.env.local:
--------------------------------------------------------------------------------
1 | TWITTER_CLIENT_ID=
2 | TWITTER_CLIENT_SECRET=
3 |
4 | NEXTAUTH_URL=
5 | NEXTAUTH_SECRET=6FOTLhAxDomRh49ordHKPbuyo7RUYt2GZ3moNdTZvOQ
6 |
7 | NEXT_PUBLIC_SUPABASE_URL=
8 | SUPABASE_SERVICE_ROLE_KEY=
9 | NEXT_PUBLIC_SUPABASE_ANON_KEY=
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /.pnp
4 | .pnp.*
5 | .yarn/*
6 | !.yarn/patches
7 | !.yarn/plugins
8 | !.yarn/releases
9 | !.yarn/versions
10 |
11 | # testing
12 | /coverage
13 |
14 | # next.js
15 | /.next/
16 | /out/
17 |
18 | # production et tests
19 | /build
20 | /tmp
21 | /logs
22 | /volumes
23 | /worker/node_modules
24 |
25 |
26 | # misc
27 | .DS_Store
28 | *.pem
29 |
30 | # debug
31 | npm-debug.log*
32 | yarn-debug.log*
33 | yarn-error.log*
34 |
35 | # env files (can opt-in for committing if needed)
36 | .env*
37 |
38 | # vercel
39 | .vercel
40 |
41 | # typescript
42 | *.tsbuildinfo
43 | next-env.d.ts
44 |
45 | # certifs
46 | *.key
47 | *.crt
48 | *.pem
49 | *.csr
50 | backup.sql
51 |
52 | /tests
53 | /volumes
54 | /cleanup
55 | /worker_refreshtoken/node_modules
56 |
57 |
58 | /cerbots
59 | /coverage
60 | /scripts
61 | docker-compose.migration.yml
62 | Dockerfile.migration
63 | Dockerfile
64 | reload.docker-compose.yml
65 |
66 | /nginx
67 | nginx.conf
68 | /nginx/certs
69 | !Dockerfile
70 | /worker/Dockerfile
71 | /watcher/Dockerfile
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build stage
2 | FROM node:20-alpine AS builder
3 |
4 | # Create non-root user
5 | RUN addgroup -g 1001 appgroup && \
6 | adduser -u 1001 -G appgroup -s /bin/sh -D appuser
7 |
8 | RUN npm install -g npm@11.2.0
9 |
10 |
11 | WORKDIR /app
12 |
13 | # Set ownership of the working directory
14 | RUN chown -R appuser:appgroup /app
15 |
16 | # Copy dependency files with correct ownership
17 | COPY --chown=appuser:appgroup package*.json ./
18 |
19 | # Create node_modules with correct permissions
20 | RUN mkdir -p node_modules && chown -R appuser:appgroup node_modules
21 |
22 | # Switch to non-root user
23 | USER appuser
24 |
25 |
26 | RUN npm install
27 |
28 | # Copy source with correct ownership
29 | COPY --chown=appuser:appgroup . .
30 |
31 | RUN npm run build
32 |
33 | # Production stage
34 | FROM node:20-alpine AS production
35 |
36 | # Create the same non-root user in production stage
37 | RUN addgroup -g 1001 appgroup && \
38 | adduser -u 1001 -G appgroup -s /bin/sh -D appuser
39 |
40 | RUN npm install -g npm@11.2.0
41 |
42 |
43 | WORKDIR /app
44 |
45 |
46 | # Set ownership of the working directory
47 | RUN chown -R appuser:appgroup /app
48 |
49 | ENV NODE_ENV production
50 | ENV PORT 3000
51 | ENV HOSTNAME "0.0.0.0"
52 |
53 | # Copy dependency files with correct ownership
54 | COPY --chown=appuser:appgroup package*.json ./
55 |
56 | # Create node_modules with correct permissions
57 | RUN mkdir -p node_modules && chown -R appuser:appgroup node_modules
58 |
59 | # Switch to non-root user
60 | USER appuser
61 |
62 |
63 | RUN npm install --production
64 |
65 | # Copy built files from builder stage with correct ownership
66 | COPY --chown=appuser:appgroup --from=builder /app/.next ./.next
67 | COPY --chown=appuser:appgroup --from=builder /app/public ./public
68 | COPY --chown=appuser:appgroup --from=builder /app/next.config.js ./
69 |
70 | EXPOSE 3000
71 |
72 | CMD ["npm", "run", "start"]
73 |
74 | # # Build stage
75 | # FROM node:20-alpine
76 | # WORKDIR /app
77 |
78 | # # Update npm to latest version
79 | # RUN npm install -g npm@11.1.0
80 |
81 | # # Copier uniquement les fichiers nécessaires pour npm install
82 | # COPY package*.json ./
83 | # COPY tsconfig*.json ./
84 | # COPY next.config.js ./
85 |
86 | # # Installer les dépendances avec cache
87 | # RUN npm install
88 |
89 | # # Set environment variables
90 | # ENV NODE_ENV=development
91 | # ENV NEXT_TELEMETRY_DISABLED=1
92 | # ENV PORT=3000
93 | # ENV HOSTNAME="0.0.0.0"
94 |
95 | # # Exposer le port
96 | # EXPOSE 3000
97 |
98 | # # Utiliser npm run dev pour le hot reload
99 | # CMD ["npm", "run", "dev"]
--------------------------------------------------------------------------------
/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | FROM node:18-alpine
2 | WORKDIR /app
3 |
4 | # Copier d'abord les fichiers de dépendances
5 | COPY package*.json ./
6 |
7 | # Installer les dépendances
8 | RUN npm install
9 |
10 | # Set environment variables
11 | ENV NODE_ENV=development
12 | ENV NEXT_TELEMETRY_DISABLED=1
13 | ENV PORT=3000
14 | ENV HOSTNAME="0.0.0.0"
15 |
16 | # Ne pas copier le code source ici car il sera monté comme volume
17 | # COPY . .
18 |
19 | # Exposer le port
20 | EXPOSE 3000
21 |
22 | # Utiliser npm run dev pour être cohérent avec docker-compose.dev.yml
23 | CMD ["npm", "run", "dev"]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### 1. Install Supabase & Run Locally
2 |
3 | #### 1. Install Docker
4 |
5 | You will need to install Docker to run Supabase locally. You can download it [here](https://docs.docker.com/get-docker) for free.
6 |
7 | #### 2. Install Supabase CLI
8 |
9 | **MacOS/Linux**
10 |
11 | ```bash
12 | brew install supabase/tap/supabase
13 | ```
14 |
15 | **Windows**
16 |
17 | ```bash
18 | scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
19 | scoop install supabase
20 | ```
21 |
22 | #### 3. Start Supabase
23 |
24 | Dans le terminal du repo Git cloné, run les commandes suivantes :
25 |
26 | ```bash
27 | supabase start
28 | ```
29 |
30 | Appliquer la migration lors du premier lancement et pour redemarer la base de donnees en appliquant les modifications des migrations
31 |
32 | ```bash
33 | supabase db reset
34 | ```
35 |
36 | #### 4. Environment Variables
37 |
38 | In your terminal at the root of your local Chatbot UI repository, run:
39 |
40 | ```bash
41 | cp .example.env.local .env.local
42 | ```
43 |
44 | Get the required values by running:
45 |
46 | ```bash
47 | supabase status
48 | ```
49 |
50 | On doit recuperer les valeurs suivants :
51 | - API URL = NEXT_PUBLIC_SUPABASE_URL
52 | - anon key = NEXT_PUBLIC_SUPABASE_ANON_KEY
53 | - service_role key = SUPABASE_SERVICE_ROLE_KEY
54 |
55 |
56 | ### 2. Getting Started
57 |
58 | ### 1. Creer une application Twitter sur :
59 | ``` https://developer.twitter.com/en/portal/dashboard ```
60 |
61 | - Selectionner la version de l'API Twitter : OAuth 2.0
62 | - Remplir les champs obligatoires
63 | - Dans la rubrique Keys and Tokens : en bas de la page, il y a la "OAuth 2.0 Client ID and Client Secret", on veut recuperer les valeurs suivantes :
64 | - TWITTER_CLIENT_ID = CLIENT_ID
65 | - TWITTER_CLIENT_SECRET = CLIENT_SECRET
66 |
67 |
68 | Pour les autres variables d'environnement :
69 |
70 | NEXTAUTH_URL= la URL de l'application qui doit etre externe pour permettre la connexion avec l'API Twitter
71 |
72 | ### 2. Lancer le tunnel pour obtenir l'URL de l'application
73 |
74 | Ensuite creer le tunnel pour obtenir l'URL de l'application :
75 |
76 | ```bash
77 | cloudflared tunnel --url http://localhost:3000
78 | ```
79 |
80 | Recuperer l'URL obtenue et l'ajouter a l'env variable `NEXTAUTH_URL` dans `.env.local`
81 |
82 | ### 3. Se connecter au developper Portal de Twitter et copier l'url de l'application dans les champs suivants :
83 |
84 | User Authentification Settings :
85 | - Callback URI/ Redirect URL :
86 | ```
87 | [URL TUNNEL]/api/auth/callback/twitter
88 |
89 | ```
90 |
91 | - Website URL :
92 | ```
93 | [URL TUNNEL]
94 | ```
95 |
96 | ### 4. L'application devrait etre accessible !
97 |
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | app:
5 | build:
6 | context: .
7 | dockerfile: Dockerfile.dev
8 | env_file:
9 | - .env.local
10 | environment:
11 | - NODE_ENV=development
12 | - WATCHPACK_POLLING=true
13 | - CHOKIDAR_USEPOLLING=true
14 | - NEXT_WEBPACK_USEPOLLING=1
15 | - NEXT_PUBLIC_FORCE_REBUILD=true
16 | ports:
17 | - "3000:3000"
18 | volumes:
19 | - ./:/app:delegated
20 | - /app/node_modules
21 | - /app/.next
22 | - shared-tmp:/app/tmp
23 | networks:
24 | - app_network
25 | extra_hosts:
26 | - "host.docker.internal:host-gateway"
27 | command: npm run dev
28 | stdin_open: true
29 | tty: true
30 |
31 | worker:
32 | build:
33 | context: ./worker
34 | dockerfile: Dockerfile
35 | volumes:
36 | - ./worker:/app
37 | - /app/node_modules
38 | - shared-tmp:/app/tmp
39 | env_file:
40 | - ./worker/.env
41 | environment:
42 | - NODE_ENV=development
43 | networks:
44 | - app_network
45 | extra_hosts:
46 | - "host.docker.internal:host-gateway"
47 | depends_on:
48 | - app
49 | command: npm run dev
50 |
51 | nginx:
52 | image: nginx:alpine
53 | ports:
54 | - "80:80"
55 | - "443:443"
56 | volumes:
57 | - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
58 | - ./nginx/certs:/etc/nginx/certs:ro
59 | depends_on:
60 | - app
61 | networks:
62 | - app_network
63 | command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
64 |
65 |
66 | watcher:
67 | build:
68 | context: ./watcher
69 | dockerfile: Dockerfile
70 | environment:
71 | - NODE_ENV=development
72 | env_file:
73 | - ./watcher/.env
74 | volumes:
75 | - ./watcher:/app
76 | - /app/node_modules
77 | networks:
78 | - app_network
79 | extra_hosts:
80 | - "host.docker.internal:host-gateway"
81 | healthcheck:
82 | test: ["CMD", "wget", "--spider", "-q", "host.docker.internal:54321"]
83 | interval: 30s
84 | timeout: 10s
85 | retries: 3
86 | start_period: 30s
87 | command: npm run dev
88 |
89 | networks:
90 | app_network:
91 | driver: bridge
92 |
93 | volumes:
94 | shared-tmp:
--------------------------------------------------------------------------------
/fluent-bit/fluent-bit.conf:
--------------------------------------------------------------------------------
1 | [SERVICE]
2 | Flush 5
3 | Daemon Off
4 | Log_Level info
5 |
6 | [INPUT]
7 | Name forward
8 | Listen 0.0.0.0
9 | Port 24224
10 |
11 | [OUTPUT]
12 | Name es
13 | Match *
14 | Host ${ELASTICSEARCH_HOST}
15 | Port ${ELASTICSEARCH_PORT}
16 | HTTP_User ${ELASTICSEARCH_USER}
17 | HTTP_Passwd ${ELASTICSEARCH_PASSWORD}
18 | Logstash_Format On
19 | Logstash_Prefix logs
20 | Type _doc
21 | Include_Tag_Key On
22 | Tag_Key tag
23 | tls On
24 | tls.verify Off # Changé car Elasticsearch 8.x utilise un certificat auto-signé par défaut
25 | Suppress_Type_Name On
26 | Replace_Dots On
27 | # Ajout des paramètres pour Elasticsearch 8.x
28 | HTTP_Compression On
29 | Net_Protocol http
--------------------------------------------------------------------------------
/kong.yml:
--------------------------------------------------------------------------------
1 | _format_version: "2.1"
2 | _transform: true
3 |
4 | services:
5 | - name: meta
6 | url: http://meta:8080
7 | routes:
8 | - name: meta-api
9 | paths:
10 | - /api/
11 | strip_path: false
12 | - name: meta-root
13 | paths:
14 | - /pg-meta/
15 | strip_path: true
16 | plugins:
17 | - name: cors
18 |
19 | - name: studio
20 | url: http://studio:3000
21 | routes:
22 | - name: studio-root
23 | paths:
24 | - /
25 | - /project/
26 | - /css/
27 | - /monaco-editor/
28 | - /favicon/
29 | - /img/
30 | - /js/
31 | strip_path: false
32 | - name: studio-api
33 | paths:
34 | - /api/
35 | strip_path: false
36 | plugins:
37 | - name: cors
38 |
39 | - name: rest
40 | url: http://rest:3000
41 | routes:
42 | - name: rest-all
43 | strip_path: true
44 | paths:
45 | - /rest/v1/
46 | plugins:
47 | - name: cors
48 | - name: key-auth
49 | config:
50 | key_names:
51 | - apikey
52 | - name: acl
53 | config:
54 | hide_groups_header: true
55 | allow:
56 | - anon
57 | - service_role
58 |
59 | consumers:
60 | - username: anon
61 | keyauth_credentials:
62 | - key: ${ANON_KEY}
63 | acls:
64 | - group: anon
65 |
66 | - username: service_role
67 | keyauth_credentials:
68 | - key: ${SERVICE_ROLE_KEY}
69 | acls:
70 | - group: service_role
--------------------------------------------------------------------------------
/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import "next-auth"
2 | import { DefaultSession, DefaultUser } from "next-auth"
3 | import { JWT } from "next-auth/jwt"
4 |
5 | declare module "next-auth" {
6 | interface Session {
7 | user: {
8 | id: string
9 | has_onboarded: boolean
10 | hqx_newsletter: boolean
11 | oep_accepted: boolean
12 | research_accepted : boolean
13 | have_seen_newsletter: boolean
14 | automatic_reconnect: boolean
15 | have_seen_bot_newsletter: boolean
16 | twitter_id?: string | null
17 | twitter_username?: string | null
18 | twitter_image?: string | null
19 | mastodon_id?: string | null
20 | mastodon_username?: string | null
21 | mastodon_image?: string | null
22 | mastodon_instance?: string | null
23 | bluesky_id?: string | null
24 | bluesky_username?: string | null
25 | bluesky_image?: string | null
26 | } & DefaultSession["user"]
27 | }
28 |
29 | interface User extends DefaultUser {
30 | id: string
31 | has_onboarded: boolean
32 | hqx_newsletter: boolean
33 | oep_accepted: boolean
34 | research_accepted : boolean
35 | have_seen_newsletter: boolean
36 | have_seen_bot_newsletter : boolean
37 | automatic_reconnect: boolean
38 | twitter_id?: string | null
39 | twitter_username?: string | null
40 | twitter_image?: string | null
41 | mastodon_id?: string | null
42 | mastodon_username?: string | null
43 | mastodon_image?: string | null
44 | mastodon_instance?: string | null
45 | bluesky_id?: string | null
46 | bluesky_username?: string | null
47 | bluesky_image?: string | null
48 | }
49 | }
50 |
51 | declare module "next-auth/jwt" {
52 | interface JWT {
53 | id: string
54 | has_onboarded: boolean
55 | hqx_newsletter: boolean
56 | oep_accepted: boolean
57 | research_accepted : boolean
58 | have_seen_newsletter: boolean
59 | have_seen_bot_newsletter : boolean
60 | automatic_reconnect: boolean
61 | twitter_id?: string
62 | twitter_username?: string
63 | twitter_image?: string
64 | mastodon_id?: string
65 | mastodon_username?: string
66 | mastodon_image?: string
67 | mastodon_instance?: string | null
68 | bluesky_id?: string
69 | bluesky_username?: string
70 | bluesky_image?: string
71 | }
72 | }
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const withNextIntl = require('next-intl/plugin')('./src/i18n/request.ts');
2 |
3 | /** @type {import('next').NextConfig} */
4 | const nextConfig = {
5 | typescript: {
6 | // ⚠️ Dangereux: Ignore les erreurs TypeScript pendant la production build
7 | ignoreBuildErrors: true,
8 | },
9 | images: {
10 | domains: ['pbs.twimg.com', 'abs.twimg.com', 'cdn.bsky.app']
11 | },
12 | headers: async () => [
13 | {
14 | source: '/:path*',
15 | headers: [
16 | {
17 | key: 'Strict-Transport-Security',
18 | value: 'max-age=63072000; includeSubDomains; preload'
19 | },
20 | {
21 | key: 'X-DNS-Prefetch-Control',
22 | value: 'on'
23 | },
24 | {
25 | key: 'X-XSS-Protection',
26 | value: '1; mode=block'
27 | },
28 | {
29 | key: 'X-Frame-Options',
30 | value: 'DENY'
31 | },
32 | {
33 | key: 'X-Content-Type-Options',
34 | value: 'nosniff'
35 | },
36 | {
37 | key: 'Referrer-Policy',
38 | value: 'origin-when-cross-origin'
39 | },
40 | {
41 | key: 'Permissions-Policy',
42 | value: 'camera=(), microphone=(), geolocation=()'
43 | }
44 | ]
45 | }
46 | ],
47 | output: 'standalone',
48 | };
49 |
50 | module.exports = withNextIntl(nextConfig);
--------------------------------------------------------------------------------
/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | events {
2 | worker_connections 1024;
3 | }
4 |
5 | http {
6 | include mime.types;
7 | default_type application/octet-stream;
8 |
9 | # Augmenter la taille maximale des uploads (50MB)
10 | client_max_body_size 900M;
11 | large_client_header_buffers 4 256k;
12 | proxy_buffer_size 512k;
13 | proxy_buffers 16 512k;
14 | proxy_busy_buffers_size 512k;
15 |
16 | upstream nextjs_upstream {
17 | server app:3000;
18 | }
19 |
20 | server {
21 | listen 80;
22 | server_name app.beta.v2.helloquitx.com;
23 |
24 | location /.well-known/acme-challenge/ {
25 | root /var/www/certbot;
26 | }
27 |
28 | location / {
29 | return 301 https://$host$request_uri;
30 | }
31 | }
32 |
33 | server {
34 | listen 443 ssl;
35 | server_name app.beta.v2.helloquitx.com;
36 |
37 | ssl_certificate /etc/nginx/certs/app.beta.v2.helloquitx.com/fullchain.pem;
38 | ssl_certificate_key /etc/nginx/certs/app.beta.v2.helloquitx.com/privkey.pem;
39 |
40 | # Amélioration de la sécurité SSL
41 | ssl_session_timeout 1d;
42 | ssl_session_cache shared:SSL:50m;
43 | ssl_session_tickets off;
44 |
45 | ssl_protocols TLSv1.2 TLSv1.3;
46 | ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
47 | ssl_prefer_server_ciphers off;
48 |
49 | # HSTS (uncomment if you're sure)
50 | # add_header Strict-Transport-Security "max-age=63072000" always;
51 |
52 | auth_basic "Restricted Area";
53 | auth_basic_user_file /etc/nginx/auth/.htpasswd;
54 |
55 | location / {
56 | proxy_pass http://nextjs_upstream;
57 | proxy_http_version 1.1;
58 | proxy_set_header Upgrade $http_upgrade;
59 | proxy_set_header Connection 'upgrade';
60 | proxy_set_header Host $host;
61 | proxy_cache_bypass $http_upgrade;
62 | proxy_set_header X-Real-IP $remote_addr;
63 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
64 | proxy_set_header X-Forwarded-Proto $scheme;
65 | proxy_max_temp_file_size 0;
66 | proxy_headers_hash_max_size 2048;
67 | proxy_headers_hash_bucket_size 512;
68 | }
69 | }
70 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "openportability",
3 | "version": "0.1.0",
4 | "private": true,
5 | "engines": {
6 | "node": ">=18.x"
7 | },
8 | "scripts": {
9 | "dev": "next dev",
10 | "build": "next build",
11 | "start": "next start",
12 | "lint": "next lint"
13 | },
14 | "dependencies": {
15 | "@atproto/api": "^0.14.9",
16 | "@auth/supabase-adapter": "^1.8.0",
17 | "@emotion/is-prop-valid": "^1.3.1",
18 | "@headlessui/react": "^2.2.0",
19 | "@radix-ui/react-dialog": "^1.1.6",
20 | "@supabase/supabase-js": "^2.49.1",
21 | "@tailwindcss/postcss": "^4.0.0",
22 | "@zip.js/zip.js": "^2.7.57",
23 | "class-variance-authority": "^0.7.1",
24 | "clsx": "^2.1.1",
25 | "crypto-js": "^4.2.0",
26 | "date-fns": "^4.1.0",
27 | "fflate": "^0.8.2",
28 | "framer-motion": "^12.4.7",
29 | "jszip": "^3.10.1",
30 | "lucide-react": "^0.475.0",
31 | "next": "15.3.0",
32 | "next-auth": "^5.0.0-beta.25",
33 | "next-intl": "^3.26.5",
34 | "nodemailer": "^6.10.0",
35 | "react": "^19.0.0",
36 | "react-dom": "^19.0.0",
37 | "react-icons": "^5.5.0",
38 | "sonner": "^2.0.3",
39 | "tailwind-merge": "^3.0.1"
40 | },
41 | "devDependencies": {
42 | "@types/crypto-js": "^4.2.2",
43 | "@types/node": "^22",
44 | "@types/react": "^19",
45 | "@types/react-dom": "^19",
46 | "eslint": "^9.20.1",
47 | "eslint-config-next": "15.3.0",
48 | "postcss": "^8.5.1",
49 | "tailwindcss": "^4.0.0",
50 | "typescript": "^5"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | module.exports = {
3 | plugins: {
4 | '@tailwindcss/postcss': {},
5 | },
6 | };
--------------------------------------------------------------------------------
/public/boats/boat-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
22 |
--------------------------------------------------------------------------------
/public/boats/boat-2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
26 |
--------------------------------------------------------------------------------
/public/boats/boat-3.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
27 |
--------------------------------------------------------------------------------
/public/boats/boat-4.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/boats/boat-5.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/boats/boat-6.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/boats/boat-8.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
34 |
--------------------------------------------------------------------------------
/public/boats/boat-9.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/client-metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "client_id": "https://2f66bda139ff0c.lhr.life/client-metadata.json",
3 | "application_type": "web",
4 | "client_name": "GoodbyeX",
5 | "client_uri": "https://2f66bda139ff0c.lhr.life",
6 | "logo_uri": "https://2f66bda139ff0c.lhr.life/logo.png",
7 | "tos_uri": "https://2f66bda139ff0c.lhr.life/terms",
8 | "policy_uri": "https://2f66bda139ff0c.lhr.life/privacy",
9 | "dpop_bound_access_tokens": true,
10 | "grant_types": [
11 | "authorization_code",
12 | "refresh_token"
13 | ],
14 | "redirect_uris": [
15 | "https://2f66bda139ff0c.lhr.life/api/auth/callback/bluesky"
16 | ],
17 | "response_types": [
18 | "code"
19 | ],
20 | "scope": "atproto",
21 | "token_endpoint_auth_method": "none",
22 | "authorization_endpoint": "https://bsky.social/oauth/authorize",
23 | "token_endpoint": "https://bsky.social/oauth/token",
24 | "userinfo_endpoint": "https://bsky.social/xrpc/com.atproto.server.getSession",
25 | "pushed_authorization_request_endpoint": "https://bsky.social/oauth/par",
26 | "require_pushed_authorization_requests": true,
27 | "client_secret_expires_at": 0
28 | }
--------------------------------------------------------------------------------
/public/compass/needle.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ISCPIF/OpenPortability/e2ab74e39c25954a6387d06252854863e89ff19b/public/logo.png
--------------------------------------------------------------------------------
/public/newSVG/BS.svg:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/public/newSVG/X.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/newSVG/close.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/newSVG/masto.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/public/newSVG/notif.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/public/newSVG/steps-Init.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/newSVG/steps-OK.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/newSVG/steps-partiel.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/newSVG/uil_arrow-down.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/newSVG/uil_arrow-growth.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/newSVG/upload.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/newSVG/video.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/sea-wave.svg:
--------------------------------------------------------------------------------
1 |
2 |
26 |
--------------------------------------------------------------------------------
/public/sea.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/v2/Pause.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/v2/badge-success-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ISCPIF/OpenPortability/e2ab74e39c25954a6387d06252854863e89ff19b/public/v2/badge-success-1.png
--------------------------------------------------------------------------------
/public/v2/check.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/v2/close.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/v2/play.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/v2/statut=BS-defaut.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/public/v2/statut=BS-hover.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/public/v2/statut=Masto-Defaut.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/v2/statut=Masto-hover.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/v2/uil_arrow-down.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/v2/uil_arrow-growth.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/python_worker/Dockerfile:
--------------------------------------------------------------------------------
1 | # python_worker/Dockerfile
2 | FROM node:20-alpine
3 |
4 | RUN addgroup -g 1001 appgroup && \
5 | adduser -u 1001 -G appgroup -s /bin/sh -D appuser
6 |
7 | # Install Python and pip
8 | RUN apk add --no-cache python3 py3-pip bash
9 |
10 | # Create Python virtual environment
11 | ENV VIRTUAL_ENV=/opt/venv
12 | RUN python3 -m venv $VIRTUAL_ENV
13 | ENV PATH="$VIRTUAL_ENV/bin:$PATH"
14 |
15 | WORKDIR /app
16 |
17 | # First, copy only package files to leverage Docker cache
18 | COPY --chown=appuser:appgroup package*.json ./
19 | COPY --chown=appuser:appgroup tsconfig.json ./
20 |
21 | # Install Node.js dependencies
22 | RUN npm install
23 |
24 | # Then copy the rest of the application
25 | COPY --chown=appuser:appgroup . .
26 |
27 | # Install Python dependencies
28 | RUN pip install --no-cache-dir -r requirements.txt
29 |
30 | RUN chown -R appuser:appgroup /app
31 |
32 | USER appuser
33 |
34 | # Build TypeScript
35 | RUN npm run build
36 |
37 | # Make Python scripts executable
38 | RUN chmod +x *.py
39 | RUN chmod +x start-py-workers.sh
40 |
41 | # Environment variables
42 | ENV PYTHON_WORKER_ID="python-worker-01"
43 | ENV PYTHON_WORKER_POLLING_INTERVAL=5000
44 | ENV PYTHON_WORKER_STALLED_TASK_TIMEOUT=300000
45 | ENV NODE_ENV=production
46 |
47 | CMD ["/bin/bash", "./start-py-workers.sh"]
--------------------------------------------------------------------------------
/python_worker/messages/de.json:
--------------------------------------------------------------------------------
1 | {
2 | "testDm": "Dies ist eine automatische Testnachricht von OpenPortability, um zu überprüfen, ob wir Sie per DM erreichen können. Es ist keine Aktion erforderlich.",
3 | "recoNewsletter": {
4 | "singular": "Hallo! Es gibt ${count} Person, der Sie auf Twitter gefolgt sind und die jetzt auf ${platformName} ist! Besuchen Sie openportability.org, um sie zu finden 🚀",
5 | "plural": "Hallo! Es gibt ${count} Personen, denen Sie auf Twitter gefolgt sind und die jetzt auf ${platformName} sind! Besuchen Sie openportability.org, um sie zu finden 🚀"
6 | }
7 | }
--------------------------------------------------------------------------------
/python_worker/messages/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "testDm": "This is an automated test message from OpenPortability to verify we can reach you via DM. No action is required.",
3 | "recoNewsletter": {
4 | "singular": "Hello! There is ${count} person you followed on Twitter who is now on ${platformName}! Visit openportability.org to find them 🚀",
5 | "plural": "Hello! There are ${count} people you followed on Twitter who are now on ${platformName}! Visit openportability.org to find them 🚀"
6 | }
7 | }
--------------------------------------------------------------------------------
/python_worker/messages/es.json:
--------------------------------------------------------------------------------
1 | {
2 | "testDm": "Este es un mensaje de prueba automático de OpenPortability para verificar que podemos contactarte por MD. No se requiere ninguna acción.",
3 | "recoNewsletter": {
4 | "singular": "¡Hola! Hay ${count} persona que seguías en Twitter y que ahora está en ${platformName}. ¡Visita openportability.org para encontrarla 🚀",
5 | "plural": "¡Hola! Hay ${count} personas que seguías en Twitter y que ahora están en ${platformName}. ¡Visita openportability.org para encontrarlas 🚀"
6 | }
7 | }
--------------------------------------------------------------------------------
/python_worker/messages/fr.json:
--------------------------------------------------------------------------------
1 | {
2 | "testDm": "Ceci est un message de test automatique d'OpenPortability pour vérifier que nous pouvons vous contacter via DM. Aucune action n'est requise.",
3 | "recoNewsletter": {
4 | "singular": "Bonjour ! Il y a ${count} personne que vous suiviez sur Twitter et qui est maintenant sur ${platformName} ! Rendez-vous sur openportability.org pour la retrouver 🚀",
5 | "plural": "Bonjour ! Il y a ${count} personnes que vous suiviez sur Twitter et qui sont maintenant sur ${platformName} ! Rendez-vous sur openportability.org pour les retrouver 🚀"
6 | }
7 | }
--------------------------------------------------------------------------------
/python_worker/messages/it.json:
--------------------------------------------------------------------------------
1 | {
2 | "testDm": "Questo è un messaggio di prova automatico da OpenPortability per verificare che possiamo raggiungerti tramite DM. Non è richiesta alcuna azione.",
3 | "recoNewsletter": {
4 | "singular": "Ciao! C'è ${count} persona che seguivi su Twitter e che ora è su ${platformName}! Visita openportability.org per trovarla 🚀",
5 | "plural": "Ciao! Ci sono ${count} persone che seguivi su Twitter e che ora sono su ${platformName}! Visita openportability.org per trovarle 🚀"
6 | }
7 | }
--------------------------------------------------------------------------------
/python_worker/messages/pt.json:
--------------------------------------------------------------------------------
1 | {
2 | "testDm": "Esta é uma mensagem de teste automática da OpenPortability para verificar se podemos contatá-lo(a) via DM. Nenhuma ação é necessária.",
3 | "recoNewsletter": {
4 | "singular": "Olá! Há ${count} pessoa que você seguia no Twitter e que agora está no ${platformName}! Visite openportability.org para encontrá-la 🚀",
5 | "plural": "Olá! Há ${count} pessoas que você seguia no Twitter e que agora estão no ${platformName}! Visite openportability.org para encontrá-las 🚀"
6 | }
7 | }
--------------------------------------------------------------------------------
/python_worker/messages/sv.json:
--------------------------------------------------------------------------------
1 | {
2 | "testDm": "Detta är ett automatiskt testmeddelande från OpenPortability för att verifiera att vi kan nå dig via DM. Ingen åtgärd krävs.",
3 | "recoNewsletter": {
4 | "singular": "Hej! Det finns ${count} person som du följde på Twitter och som nu finns på ${platformName}! Besök openportability.org för att hitta hen 🚀",
5 | "plural": "Hej! Det finns ${count} personer som du följde på Twitter och som nu finns på ${platformName}! Besök openportability.org för att hitta dem 🚀"
6 | }
7 | }
--------------------------------------------------------------------------------
/python_worker/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "python-worker",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "scripts": {
6 | "build": "tsc",
7 | "start": "ts-node src/index.ts",
8 | "start:workers": "./start-py-workers.sh",
9 | "dev": "ts-node src/index.ts"
10 | },
11 | "dependencies": {
12 | "@supabase/supabase-js": "^2.0.0",
13 | "dotenv": "^16.0.0",
14 | "date-fns": "^2.30.0"
15 |
16 | },
17 | "devDependencies": {
18 | "@types/node": "^18.19.68",
19 | "@types/dotenv": "^8.2.0",
20 | "ts-node": "^10.9.2",
21 | "typescript": "^4.9.5"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/python_worker/requirements.txt:
--------------------------------------------------------------------------------
1 | atproto==0.0.59
2 | requests==2.32.3
3 | httpx==0.26.0
4 | python-dotenv==1.0.1
5 | supabase==2.13.0
6 | Mastodon.py==1.8.1
7 |
--------------------------------------------------------------------------------
/python_worker/start-py-workers.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Nombre de workers Python à lancer
4 | NUM_WORKERS=2 # Ajustez ce nombre selon vos besoins
5 |
6 | # Array pour stocker les PIDs
7 | declare -a PY_WORKER_PIDS=()
8 |
9 | echo "Starting ${NUM_WORKERS} Python workers..."
10 |
11 | for i in $(seq 1 $NUM_WORKERS); do
12 | # Définir un ID unique pour chaque worker Python
13 | export PYTHON_WORKER_ID="pyworker_${i}"
14 |
15 | # Lancer le worker avec npx ts-node au lieu de node
16 | npx ts-node src/index.ts &
17 |
18 | # Stocker le PID
19 | PY_WORKER_PIDS+=($!)
20 | echo "Started Python worker ${i} with ID ${PYTHON_WORKER_ID} and PID ${PY_WORKER_PIDS[-1]}"
21 |
22 | # Petite pause pour éviter de surcharger au démarrage (optionnel)
23 | sleep 0.5
24 | done
25 |
26 | # Fonction pour arrêter proprement les workers
27 | cleanup() {
28 | echo "Received termination signal. Stopping Python workers..."
29 | for pid in "${PY_WORKER_PIDS[@]}"; do
30 | echo "Sending SIGTERM to PID $pid..."
31 | kill -SIGTERM $pid # Envoyer SIGTERM pour un arrêt gracieux
32 | done
33 | # Attendre un peu que les processus se terminent
34 | sleep 5
35 | # Forcer l'arrêt si nécessaire (optionnel)
36 | # for pid in "${PY_WORKER_PIDS[@]}"; do
37 | # if kill -0 $pid 2>/dev/null; then
38 | # echo "Forcing shutdown for PID $pid..."
39 | # kill -SIGKILL $pid
40 | # fi
41 | # done
42 | echo "Cleanup finished."
43 | exit 0
44 | }
45 |
46 | # Intercepter les signaux d'arrêt (Ctrl+C, etc.)
47 | trap cleanup SIGINT SIGTERM
48 |
49 | echo "Waiting for Python workers to complete (Press Ctrl+C to stop)..."
50 | # Attendre que tous les workers se terminent (ou que le script soit interrompu)
51 | for pid in "${PY_WORKER_PIDS[@]}"; do
52 | wait $pid
53 | done
54 |
55 | echo "All Python workers completed naturally."
56 |
--------------------------------------------------------------------------------
/python_worker/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "commonjs",
5 | "lib": ["es2020"],
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "outDir": "dist",
11 | "rootDir": "src",
12 | "types": ["node"]
13 | },
14 | "include": ["src/**/*"],
15 | "exclude": ["node_modules"]
16 | }
--------------------------------------------------------------------------------
/src/app/[locale]/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from "@/app/auth"
2 | import { redirect } from "next/navigation"
3 |
4 | export default async function DashboardLayout({
5 | children,
6 | }: {
7 | children: React.ReactNode
8 | }) {
9 | const session = await auth()
10 |
11 | if (!session?.user) {
12 | redirect("/auth/signin")
13 | }
14 |
15 | return <>{children}>
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/[locale]/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import localFont from "next/font/local";
3 | import { Inter, Space_Grotesk } from 'next/font/google'
4 | import "../globals.css";
5 | import { Providers } from "../providers";
6 | import { auth } from "@/app/auth";
7 | import { MotionWrapper } from "../_components/MotionWrapper";
8 | import { NextIntlClientProvider } from 'next-intl';
9 | import { getMessages } from 'next-intl/server';
10 | import { Toaster } from 'sonner';
11 |
12 | const geistSans = localFont({
13 | src: "../fonts/GeistVF.woff",
14 | variable: "--font-geist-sans",
15 | weight: "100 900",
16 | });
17 |
18 | const geistMono = localFont({
19 | src: "../fonts/GeistMonoVF.woff",
20 | variable: "--font-geist-mono",
21 | weight: "100 900",
22 | });
23 |
24 | const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
25 | const spaceGrotesk = Space_Grotesk({
26 | subsets: ['latin'],
27 | variable: '--font-space-grotesk'
28 | })
29 |
30 | export const metadata: Metadata = {
31 | title: "OpenPortability",
32 | description: "Libérez vos espaces numériques",
33 | };
34 |
35 | async function getSession() {
36 | return await auth();
37 | }
38 |
39 | type Props = {
40 | children: React.ReactNode;
41 | params: Promise<{ locale: string }>;
42 | };
43 |
44 | export default async function RootLayout({ children, params }: Props) {
45 | const messages = await getMessages();
46 | const session = await getSession();
47 | const { locale } = await params;
48 |
49 | return (
50 |
51 |
52 |
53 |
54 |
55 |
56 | {children}
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | );
65 | }
--------------------------------------------------------------------------------
/src/app/[locale]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import { auth } from "../auth";
3 | import { getLocale } from 'next-intl/server';
4 |
5 | // Indiquer à Next.js que cette page est dynamique
6 | export const dynamic = 'force-dynamic';
7 | export const fetchCache = 'force-no-store';
8 |
9 | export default async function Home() {
10 | // console.log("🏠 [Home] Starting home page render...");
11 |
12 | try {
13 | const session = await auth();
14 | // console.log("🔑 [Home] Session:", session ? "Found" : "Not found");
15 |
16 | const locale = await getLocale();
17 | // console.log("🌍 [Home] Current locale:", locale);
18 |
19 | if (!session) {
20 | // console.log("➡️ [Home] Redirecting to signin page...");
21 | redirect(`/${locale}/auth/signin`);
22 | }
23 |
24 | if (!session.user.has_onboarded){
25 | // console.log("➡️ [Home] Redirecting to dashboard...");
26 | redirect(`/${locale}/dashboard`);
27 | }
28 | else
29 | {
30 | redirect(`/${locale}/reconnect`);
31 | }
32 | } catch (error) {
33 | console.error("❌ [Home] Error:", error);
34 | throw error;
35 | }
36 | }
--------------------------------------------------------------------------------
/src/app/[locale]/privacy_policy/layout.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from "@/app/auth"
2 | import { redirect } from "next/navigation"
3 |
4 | export default async function PrivaryPolicyLayout({
5 | children,
6 | }: {
7 | children: React.ReactNode
8 | }) {
9 |
10 | return <>{children}>
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/[locale]/reconnect/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import { plex } from '../../fonts/plex';
3 |
4 | export default function MigrateLayout({
5 | children,
6 | }: {
7 | children: ReactNode;
8 | }) {
9 | return (
10 |
11 | {children}
12 |
13 | );
14 | }
--------------------------------------------------------------------------------
/src/app/[locale]/settings/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import { plex } from '../../fonts/plex';
3 |
4 | export default function MigrateLayout({
5 | children,
6 | }: {
7 | children: ReactNode;
8 | }) {
9 | return (
10 |
11 | {children}
12 |
13 | );
14 | }
--------------------------------------------------------------------------------
/src/app/_components/AppLayout.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import React from 'react';
3 | import Header from "./Header";
4 | import Footer from "./Footer";
5 | import LoadingIndicator from "./LoadingIndicator";
6 |
7 | interface AppLayoutProps {
8 | children: React.ReactNode;
9 | isLoading?: boolean;
10 | loadingMessage?: string;
11 | }
12 |
13 | export default function AppLayout({ children, isLoading, loadingMessage = 'Loading...' }: AppLayoutProps) {
14 | if (isLoading) {
15 | return (
16 |
25 | );
26 | }
27 |
28 | return (
29 |
30 |
31 |
32 |
33 | {children}
34 |
35 |
36 | );
37 | }
--------------------------------------------------------------------------------
/src/app/_components/BlueSkyLoginButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { motion } from "framer-motion"
4 | import { SiBluesky } from 'react-icons/si'
5 | import { plex } from "@/app/fonts/plex"
6 | import { useTranslations } from 'next-intl'
7 |
8 | interface BlueSkyLoginButtonProps {
9 | onLoadingChange?: (isLoading: boolean) => void;
10 | isConnected?: boolean;
11 | isSelected?: boolean;
12 | className?: string;
13 | onClick?: () => void;
14 | }
15 |
16 | const itemVariants = {
17 | hidden: { opacity: 0, y: -8, scale: 0.95 },
18 | visible: {
19 | opacity: 1,
20 | y: 0,
21 | scale: 1,
22 | transition: {
23 | type: "spring",
24 | stiffness: 400,
25 | damping: 30
26 | }
27 | }
28 | }
29 |
30 | export default function BlueSkyLoginButton({
31 | onLoadingChange = () => { },
32 | isConnected = false,
33 | isSelected = false,
34 | className = "",
35 | onClick = () => {}
36 | }: BlueSkyLoginButtonProps) {
37 | const t = useTranslations('dashboardLoginButtons')
38 |
39 | return (
40 |
53 |
54 |
55 | {isConnected ? t('connected') : t('services.bluesky')}
56 |
57 |
58 | )
59 | }
--------------------------------------------------------------------------------
/src/app/_components/ConnectedServicesInfo.tsx:
--------------------------------------------------------------------------------
1 | // interface ConnectedServicesInfoProps {
2 | // session: any;
3 | // }
4 |
5 | // export default function ConnectedServicesInfo({ session }: ConnectedServicesInfoProps) {
6 | // const hasMastodon = session?.user?.mastodon_id;
7 | // const hasBluesky = session?.user?.bluesky_id;
8 | // const hasTwitter = session?.user?.twitter_id;
9 | // const hasOnboarded = session?.user?.has_onboarded;
10 |
11 | // const connectedServicesCount = [hasMastodon, hasBluesky, hasTwitter].filter(Boolean).length;
12 |
13 | // return { hasMastodon, hasBluesky, hasTwitter, hasOnboarded, connectedServicesCount };
14 | // }
--------------------------------------------------------------------------------
/src/app/_components/ConsentModal.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useTranslations } from 'next-intl';
4 |
5 | interface ConsentModalProps {
6 | isOpen: boolean;
7 | onAccept: () => void;
8 | onDecline: () => void;
9 | }
10 |
11 | export default function ConsentModal({ isOpen, onAccept, onDecline }: ConsentModalProps) {
12 | const t = useTranslations('consentModal');
13 |
14 | if (!isOpen) return null;
15 |
16 | return (
17 |
18 |
19 |
20 |
29 |
30 | {t('title')}
31 |
32 |
33 |
34 |
35 |
36 | {t('description')}
37 |
38 |
39 | - {t('conditions.ownership')}
40 | - {t('conditions.cnrsUsage')}
41 | - {t('conditions.profileCreation')}
42 |
43 |
44 |
45 |
46 |
52 |
58 |
59 |
60 |
61 | );
62 | }
--------------------------------------------------------------------------------
/src/app/_components/LoadingIndicator.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from "react"
4 | import Image from 'next/image';
5 | import { motion, AnimatePresence } from "framer-motion"
6 |
7 | import { plex } from '@/app/fonts/plex';
8 |
9 | import compass from '../../../public/compass/loader-compass.svg';
10 | import needle from '../../../public/compass/needle.svg';
11 |
12 | function randomRange(min: number, max: number) {
13 | return min + Math.random() * (max - min);
14 | }
15 |
16 | interface LoadingIndicatorProps {
17 | msg: string;
18 | textSize?: 'sm' | 'base';
19 | }
20 |
21 | export default function LoadingIndicator({ msg, textSize = 'sm' }: LoadingIndicatorProps) {
22 | const [nextAngle, setNextAngle] = useState(0);
23 | return (
24 |
25 |
26 |
33 | {
52 | setNextAngle(Math.random() * 360);
53 | }}
54 | >
55 |
63 |
64 |
65 |
66 | {msg}
67 |
68 |
69 | );
70 | }
--------------------------------------------------------------------------------
/src/app/_components/MatchedBlueSkyProfiles.tsx:
--------------------------------------------------------------------------------
1 | // 'use client'
2 |
3 | // import { motion } from 'framer-motion'
4 | // import { SiBluesky } from "react-icons/si"
5 |
6 | // type MatchedProfile = {
7 | // bluesky_handle: string
8 | // }
9 |
10 | // export default function MatchedBlueSkyProfiles({
11 | // profiles
12 | // }: {
13 | // profiles: MatchedProfile[]
14 | // }) {
15 | // if (!profiles.length) return (
16 | //
17 | // Aucune correspondance BlueSky trouvée
18 | //
19 | // )
20 |
21 | // return (
22 | //
23 |
24 | //
25 | //
26 |
27 | //
28 | // {profiles.map((profile, index) => (
29 | //
36 | //
37 | //
38 | //
39 | //
40 |
41 | //
42 | //
43 | // {profile.bluesky_handle}
44 | //
45 | //
46 | //
47 | //
48 | // ))}
49 | //
50 | //
51 | // )
52 | // }
--------------------------------------------------------------------------------
/src/app/_components/MotionWrapper.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { MotionConfig } from 'framer-motion';
4 | import { ReactNode } from 'react';
5 |
6 | interface MotionWrapperProps {
7 | children: ReactNode;
8 | }
9 |
10 | export function MotionWrapper({ children }: MotionWrapperProps) {
11 | return (
12 |
13 | {children}
14 |
15 | );
16 | }
--------------------------------------------------------------------------------
/src/app/_components/RefreshTokenModale.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { useTranslations } from 'next-intl'
5 | import { motion, AnimatePresence } from 'framer-motion'
6 | import { plex } from '@/app/fonts/plex'
7 | import DashboardLoginButtons from './DashboardLoginButtons'
8 |
9 | type Provider = 'bluesky' | 'mastodon'
10 |
11 | interface RefreshTokenModaleProps {
12 | providers: Provider[]
13 | mastodonInstances: string[]
14 | onClose?: () => void
15 | }
16 |
17 | const containerVariants = {
18 | hidden: { opacity: 0, scale: 0.95 },
19 | visible: {
20 | opacity: 1,
21 | scale: 1,
22 | transition: {
23 | staggerChildren: 0.1,
24 | delayChildren: 0.2
25 | }
26 | }
27 | }
28 |
29 | export default function RefreshTokenModale({ providers, mastodonInstances, onClose }: RefreshTokenModaleProps) {
30 | const [isLoading, setIsLoading] = useState(false)
31 | const t = useTranslations('refreshToken')
32 |
33 | // Convert providers array to connectedServices object
34 | // Si un provider est dans le tableau providers, il n'est PAS connecté
35 | const connectedServices = {
36 | bluesky: true,
37 | mastodon: true,
38 | ...Object.fromEntries(providers.map(provider => [provider, false]))
39 | }
40 |
41 | return (
42 |
43 |
49 | {t('title')}
50 | {t('description')}
51 |
52 |
58 |
59 |
65 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/src/app/_components/TwitterRateLimit.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { motion } from "framer-motion"
4 | import { SiMastodon, SiBluesky } from 'react-icons/si'
5 | import { plex } from "@/app/fonts/plex"
6 |
7 | interface TwitterRateLimitProps {
8 | onShowAlternatives: () => void;
9 | }
10 |
11 | export default function TwitterRateLimit({ onShowAlternatives }: TwitterRateLimitProps) {
12 | return (
13 |
18 |
19 |
24 |
25 |
26 | Twitter est temporairement indisponible
27 |
28 |
29 |
En raison d'un grand nombre de requêtes, Twitter n'est pas accessible pour le moment.
30 |
Vous pouvez :
31 |
32 | - Réessayer dans quelques minutes
33 | - Utiliser une autre plateforme pour vous connecter
34 |
35 |
36 |
37 |
47 |
48 |
49 |
50 |
51 | )
52 | }
--------------------------------------------------------------------------------
/src/app/_components/dashboard/TutorialSection.tsx:
--------------------------------------------------------------------------------
1 | // src/app/_components/dashboard/TutorialSection.tsx
2 | import { motion } from 'framer-motion';
3 | import { Play } from 'lucide-react';
4 | import { plex } from '@/app/fonts/plex';
5 | import { useTranslations } from 'next-intl';
6 | import { useParams } from 'next/navigation';
7 |
8 | export default function TutorialSection() {
9 | const params = useParams();
10 | const t = useTranslations('dashboard');
11 |
12 | return (
13 |
14 |
15 | {t('tutorial.title')}
16 |
17 |
27 |
28 |
29 | {t('tutorial.watchVideo')}
30 |
31 |
32 |
33 | );
34 | }
--------------------------------------------------------------------------------
/src/app/_components/reconnect/ReconnectOptionsSections.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { useTranslations } from 'next-intl';
3 | import { GlobalStats } from '@/lib/types/stats';
4 |
5 | type ReconnectOptionsSectionProps = {
6 | onAutomatic: () => void;
7 | onManual: () => void;
8 | globalStats?: GlobalStats;
9 | has_onboarded?: boolean;
10 | };
11 |
12 | export default function ReconnectOptionsSection({
13 | onAutomatic,
14 | onManual,
15 | globalStats,
16 | has_onboarded
17 | }: ReconnectOptionsSectionProps) {
18 | const t = useTranslations('migrate');
19 |
20 | return (
21 |
22 |
23 | {t('choose_reconnection_mode')}
24 |
25 |
26 |
27 |
33 | {t('automatic_mode')}
34 | {t('automatic_description')}
35 |
36 |
37 |
43 | {t('manual_mode')}
44 | {t('manual_description')}
45 |
46 |
47 |
48 | {globalStats && (
49 |
50 |
51 | {t('total_users_reconnected', {
52 | count: globalStats.users?.total || 0
53 | })}
54 |
55 |
56 | )}
57 |
58 | );
59 | }
--------------------------------------------------------------------------------
/src/app/_components/reconnect/states/AutomaticReconnectionState.tsx:
--------------------------------------------------------------------------------
1 | // src/app/_components/reconnect/states/AutomaticReconnectionState.tsx
2 | import { motion } from 'framer-motion';
3 | import AutomaticReconnexion from '@/app/_components/AutomaticReconnexion';
4 |
5 | type AutomaticReconnectionStateProps = {
6 | session: any;
7 | stats: any;
8 | migrationResults: any;
9 | handleAutomaticReconnection: () => void;
10 | };
11 |
12 | export default function AutomaticReconnectionState({
13 | session,
14 | stats,
15 | migrationResults,
16 | handleAutomaticReconnection,
17 | }: AutomaticReconnectionStateProps) {
18 | return (
19 |
25 |
40 |
41 | );
42 | }
--------------------------------------------------------------------------------
/src/app/_components/reconnect/states/LoadingState.tsx:
--------------------------------------------------------------------------------
1 | // src/app/_components/reconnect/states/LoadingState.tsx
2 | import LoadingIndicator from '@/app/_components/LoadingIndicator';
3 | import { useTranslations } from 'next-intl';
4 |
5 | export default function LoadingState() {
6 | const t = useTranslations('migrate');
7 |
8 | return (
9 |
18 | );
19 | }
--------------------------------------------------------------------------------
/src/app/_components/reconnect/states/ManualReconnectionState.tsx:
--------------------------------------------------------------------------------
1 | // src/app/_components/reconnect/states/ManualReconnectionState.tsx
2 | import { motion } from 'framer-motion';
3 | import ManualReconnexion from '@/app/_components/ManualReconnexion';
4 |
5 | type ManualReconnectionStateProps = {
6 | session: any;
7 | accountsToProcess: any[];
8 | handleStartMigration: (accounts: string[]) => void;
9 | handleAutomaticReconnection: () => void;
10 | };
11 |
12 | export default function ManualReconnectionState({
13 | session,
14 | accountsToProcess,
15 | handleStartMigration,
16 | handleAutomaticReconnection,
17 | }: ManualReconnectionStateProps) {
18 | return (
19 |
25 |
36 |
37 | );
38 | }
--------------------------------------------------------------------------------
/src/app/_components/reconnect/states/MissingTokenState.tsx:
--------------------------------------------------------------------------------
1 | // src/app/_components/reconnect/states/MissingTokenState.tsx
2 | import { useTranslations } from 'next-intl';
3 | import DashboardLoginButtons from '@/app/_components/DashboardLoginButtons';
4 |
5 | type MissingTokenStateProps = {
6 | session: any;
7 | stats: any;
8 | mastodonInstances: string[];
9 | setIsLoading: (value: boolean) => void;
10 | missingProviders: string[];
11 | };
12 |
13 | export default function MissingTokenState({
14 | session,
15 | stats,
16 | mastodonInstances,
17 | setIsLoading,
18 | missingProviders,
19 | }: MissingTokenStateProps) {
20 | const t = useTranslations('refreshToken');
21 |
22 | return (
23 |
24 |
25 | {t('title')}
26 |
27 |
28 |
29 |
42 |
43 |
44 | );
45 | }
--------------------------------------------------------------------------------
/src/app/_components/reconnect/states/NoConnectedServicesState.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useTranslations } from 'next-intl';
3 | import DashboardLoginButtons from '@/app/_components/DashboardLoginButtons';
4 |
5 | type NoConnectedServicesStateProps = {
6 | session: any;
7 | stats: any;
8 | mastodonInstances: string[];
9 | setIsLoading: (value: boolean) => void;
10 | };
11 |
12 | export default function NoConnectedServicesState({
13 | session,
14 | stats,
15 | mastodonInstances,
16 | setIsLoading,
17 | }: NoConnectedServicesStateProps) {
18 | const t = useTranslations('migrate');
19 |
20 | // Calculer le nombre total de comptes non suivis
21 | const totalNotFollowedCount =
22 | (stats?.matches?.bluesky?.notFollowed || 0) +
23 | (stats?.matches?.mastodon?.notFollowed || 0);
24 |
25 | // Détermine quelle clé de traduction utiliser en fonction de has_onboarded
26 | const messageKey = session?.user?.has_onboarded ? 'needBothAccounts' : 'noUploadYet';
27 |
28 | return (
29 |
30 |
31 | {t.raw(messageKey).split('{count}').map((part, index, parts) => (
32 |
33 | {part}
34 | {index < parts.length - 1 && (
35 | {totalNotFollowedCount}
36 | )}
37 |
38 | ))}
39 |
40 |
41 |
42 |
55 |
56 |
57 | );
58 | }
--------------------------------------------------------------------------------
/src/app/_components/reconnect/states/PartialConnectedServicesState.tsx:
--------------------------------------------------------------------------------
1 | // src/app/_components/reconnect/states/PartialConnectedServicesState.tsx
2 | import { useTranslations } from 'next-intl';
3 | import DashboardLoginButtons from '@/app/_components/DashboardLoginButtons';
4 |
5 | type PartialConnectedServicesStateProps = {
6 | session: any;
7 | stats: any;
8 | mastodonInstances: string[];
9 | setIsLoading: (value: boolean) => void;
10 | };
11 |
12 | export default function PartialConnectedServicesState({
13 | session,
14 | stats,
15 | mastodonInstances,
16 | setIsLoading,
17 | }: PartialConnectedServicesStateProps) {
18 | const t = useTranslations('migrate');
19 |
20 | return (
21 |
22 |
23 |
24 | {t('connect_missing_service')}
25 |
26 |
27 |
28 |
41 |
42 |
43 |
44 | );
45 | }
--------------------------------------------------------------------------------
/src/app/_components/reconnect/states/ReconnectionCompleteState.tsx:
--------------------------------------------------------------------------------
1 | // src/app/_components/reconnect/states/ReconnectionCompleteState.tsx
2 | import { motion } from 'framer-motion';
3 | import SuccessAutomaticReconnexion from '@/app/_components/SuccessAutomaticReconnexion';
4 |
5 | type ReconnectionCompleteStateProps = {
6 | session: any;
7 | stats: any;
8 | globalStats: any;
9 | handleAutomaticReconnection: () => void;
10 | handleManualReconnection: () => void;
11 | refreshStats: () => void;
12 | };
13 |
14 | export default function ReconnectionCompleteState({
15 | session,
16 | stats,
17 | globalStats,
18 | handleAutomaticReconnection,
19 | handleManualReconnection,
20 | refreshStats,
21 | }: ReconnectionCompleteStateProps) {
22 | return (
23 |
29 |
42 |
43 | );
44 | }
--------------------------------------------------------------------------------
/src/app/_components/reconnect/states/ShowOptionsState.tsx:
--------------------------------------------------------------------------------
1 | // src/app/_components/reconnect/states/ShowOptionsState.tsx
2 | import { motion } from 'framer-motion';
3 | import ReconnexionOptions from '@/app/_components/ReconnexionOptions';
4 |
5 | type ShowOptionsStateProps = {
6 | session: any;
7 | globalStats: any;
8 | handleAutomaticReconnection: () => void;
9 | handleManualReconnection: () => void;
10 | };
11 |
12 | export default function ShowOptionsState({
13 | session,
14 | globalStats,
15 | handleAutomaticReconnection,
16 | handleManualReconnection,
17 | }: ShowOptionsStateProps) {
18 | return (
19 |
25 |
31 |
32 | );
33 | }
--------------------------------------------------------------------------------
/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import { authConfig } from "@/app/auth.config"
2 | import NextAuth from "next-auth"
3 | import { handlers } from "@/app/auth"
4 |
5 | export const { GET, POST } = handlers
--------------------------------------------------------------------------------
/src/app/api/auth/mastodon/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { supabase } from '@/lib/supabase'
3 | import logger, { withLogging } from '@/lib/log_utils'
4 |
5 | async function mastodonHandler() {
6 | try {
7 | const { data, error } = await supabase
8 | .from('mastodon_instances')
9 | .select('instance')
10 | .order('instance')
11 |
12 | if (error) {
13 | logger.logError('API', 'GET /api/auth/mastodon', error, undefined, { message: 'Failed to fetch Mastodon instances' })
14 | return NextResponse.json(
15 | { success: false, error: 'Failed to fetch Mastodon instances' },
16 | { status: 500 }
17 | )
18 | }
19 |
20 | // Transformation des données pour n'avoir que la liste des instances
21 | const instances = data.map(item => item.instance)
22 | return NextResponse.json({
23 | success: true,
24 | instances: instances
25 | })
26 |
27 | } catch (error) {
28 | logger.logError('API', 'GET /api/auth/mastodon', error, undefined, { message: 'An unexpected error occurred' })
29 | return NextResponse.json(
30 | { success: false, error: 'An unexpected error occurred' },
31 | { status: 500 }
32 | )
33 | }
34 | }
35 |
36 | // Exporter la fonction GET enveloppée par le middleware de logging
37 | export const GET = withLogging(mastodonHandler)
--------------------------------------------------------------------------------
/src/app/api/auth/refresh/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { auth } from "@/app/auth"
3 | import { AccountService } from "@/lib/services/accountService"
4 | import { unlinkAccount } from "@/lib/supabase-adapter"
5 | import logger, { withLogging } from '@/lib/log_utils'
6 |
7 | async function refreshHandler(request: Request) {
8 | try {
9 | const session = await auth()
10 | if (!session?.user?.id) {
11 | logger.logWarning('API', 'POST /api/auth/refresh', 'Unauthorized access attempt')
12 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
13 | }
14 |
15 | const accountService = new AccountService()
16 | const results: { bluesky?: any; mastodon?: any } = {}
17 | const invalidProviders: string[] = []
18 |
19 | // Vérifier Bluesky si l'utilisateur a un compte
20 | if (session.user.bluesky_username) {
21 | results.bluesky = await accountService.verifyAndRefreshBlueskyToken(session.user.id)
22 | if (results.bluesky.requiresReauth) {
23 | invalidProviders.push('bluesky')
24 | }
25 | }
26 |
27 | // Vérifier Mastodon si l'utilisateur a un compte
28 | if (session.user.mastodon_username) {
29 | results.mastodon = await accountService.verifyAndRefreshMastodonToken(session.user.id)
30 | if (results.mastodon.requiresReauth) {
31 | invalidProviders.push('mastodon')
32 | }
33 | }
34 |
35 | // Si aucun compte n'est configuré
36 | if (!session.user.bluesky_username && !session.user.mastodon_username) {
37 | return NextResponse.json({
38 | success: false,
39 | error: 'No social accounts configured'
40 | })
41 | }
42 |
43 | // Si des providers nécessitent une réauthentification
44 | if (invalidProviders.length > 0) {
45 | logger.logWarning('API', 'POST /api/auth/refresh', 'Reauth required for providers', session.user.id, { providers: invalidProviders })
46 | return NextResponse.json(
47 | {
48 | success: false,
49 | error: 'Token refresh failed',
50 | providers: invalidProviders,
51 | // ...results
52 | },
53 | // { status: 401 }
54 | )
55 | }
56 |
57 | return NextResponse.json({
58 | success: true,
59 | ...results
60 | })
61 | } catch (error) {
62 | logger.logError('API', 'POST /api/auth/refresh', error)
63 | return NextResponse.json(
64 | { error: 'Internal server error' },
65 | { status: 500 }
66 | )
67 | }
68 | }
69 |
70 | export const POST = withLogging(refreshHandler)
--------------------------------------------------------------------------------
/src/app/api/auth/unlink/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { auth } from "@/app/auth"
3 | import { supabaseAdapter, UnlinkError } from "@/lib/supabase-adapter"
4 | import logger, { withLogging } from '@/lib/log_utils'
5 |
6 | async function unlinkHandler(req: Request) {
7 | try {
8 | const session = await auth()
9 | if (!session?.user?.id) {
10 | logger.logWarning('API', 'POST /api/auth/unlink', 'Unauthorized access attempt')
11 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
12 | }
13 | const { provider } = await req.json()
14 | if (!['twitter', 'bluesky', 'mastodon', 'piaille'].includes(provider)) {
15 | logger.logWarning('API', 'POST /api/auth/unlink', 'Invalid provider requested', session.user.id, { provider })
16 | return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })
17 | }
18 | if (!supabaseAdapter.unlinkAccount || !supabaseAdapter.getAccountsByUserId) {
19 | logger.logError('API', 'POST /api/auth/unlink', new Error('Required adapter methods are not implemented'), session.user.id)
20 | throw new Error('Required adapter methods are not implemented')
21 | }
22 | const accounts = await supabaseAdapter.getAccountsByUserId(session.user.id)
23 |
24 | // Find the account to unlink
25 | const accountToUnlink = accounts.find(account => account.provider === provider)
26 | if (!accountToUnlink) {
27 | logger.logWarning('API', 'POST /api/auth/unlink', 'Account not found for provider', session.user.id, { provider })
28 | return NextResponse.json({
29 | error: 'Account not found',
30 | code: 'NOT_LINKED'
31 | }, { status: 400 })
32 | }
33 | await supabaseAdapter.unlinkAccount({
34 | provider: accountToUnlink.provider,
35 | providerAccountId: accountToUnlink.providerAccountId
36 | })
37 | return NextResponse.json({ success: true })
38 | } catch (error) {
39 | const userId = (await auth())?.user?.id || 'unknown'
40 | logger.logError('API', 'POST /api/auth/unlink', error, userId, {
41 | name: error.name,
42 | message: error.message
43 | })
44 |
45 | if (error instanceof UnlinkError) {
46 | return NextResponse.json(
47 | { error: error.message, code: error.code },
48 | { status: error.status }
49 | )
50 | }
51 |
52 | return NextResponse.json(
53 | { error: 'Internal server error', code: 'UNKNOWN_ERROR' },
54 | { status: 500 }
55 | )
56 | }
57 | }
58 |
59 | export const POST = withLogging(unlinkHandler)
--------------------------------------------------------------------------------
/src/app/api/import-status/[jobId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { supabase } from '@/lib/supabase'
3 | import { auth } from "@/app/auth";
4 | import logger, { withLogging } from '@/lib/log_utils';
5 |
6 | async function getImportStatus(request: NextRequest) {
7 | try {
8 | // Récupérer et attendre les paramètres
9 | const jobId = request.nextUrl.pathname.split('/').pop();
10 |
11 | const session = await auth();
12 |
13 | if (!session?.user?.id) {
14 | logger.logWarning('API', 'GET /api/import-status/[jobId]', 'Unauthorized access attempt');
15 | return NextResponse.json(
16 | { error: "Not authenticated" },
17 | { status: 401 }
18 | );
19 | }
20 |
21 | // Récupérer le statut du job
22 | const { data: job, error: jobError } = await supabase
23 | .from('import_jobs')
24 | .select('*')
25 | .eq('id', jobId)
26 | .eq('user_id', session.user.id)
27 | .single();
28 |
29 | if (jobError) {
30 | logger.logError('API', 'GET /api/import-status/[jobId]', new Error(jobError.message), session.user.id, {
31 | jobId,
32 | context: 'Database query error'
33 | });
34 | return NextResponse.json(
35 | { error: 'Failed to fetch job status' },
36 | { status: 500 }
37 | );
38 | }
39 |
40 | if (!job) {
41 | logger.logWarning('API', 'GET /api/import-status/[jobId]', 'Job not found', session.user.id, { jobId });
42 | return NextResponse.json(
43 | { error: 'Job not found' },
44 | { status: 404 }
45 | );
46 | }
47 |
48 | const stats = job.stats || {
49 | followers: 0,
50 | following: 0,
51 | total: job.total_items || 0,
52 | processed: 0
53 | };
54 |
55 | logger.logInfo('API', 'GET /api/import-status/[jobId]', 'Job status retrieved', session.user.id, {
56 | jobId: job.id,
57 | status: job.status,
58 | progress: `${stats.processed}/${stats.total} items`,
59 | totalItems: stats.total,
60 | stats
61 | });
62 |
63 | return NextResponse.json({
64 | jobId: job.id,
65 | status: job.status,
66 | progress: stats.processed,
67 | totalItems: stats.total,
68 | stats,
69 | error: job.error_log
70 | });
71 |
72 | } catch (error) {
73 | const userId = (await auth())?.user?.id || 'unknown';
74 | logger.logError('API', 'GET /api/import-status/[jobId]', error, userId, {
75 | context: 'Unexpected error in import status check'
76 | });
77 | return NextResponse.json(
78 | { error: 'Failed to check import status' },
79 | { status: 500 }
80 | );
81 | }
82 | }
83 |
84 | export const GET = withLogging(getImportStatus);
--------------------------------------------------------------------------------
/src/app/api/migrate/matching_found/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { auth } from '@/app/auth';
3 | import { MatchingService } from '@/lib/services/matchingService';
4 | import logger, { withLogging } from '@/lib/log_utils';
5 |
6 | async function matchingFoundHandler() {
7 | try {
8 | const session = await auth();
9 |
10 | if (!session?.user?.id) {
11 | logger.logWarning('API', 'GET /api/migrate/matching_found', 'Unauthorized access attempt');
12 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
13 | }
14 |
15 | const matchingService = new MatchingService();
16 | let result;
17 |
18 | if (!session.user?.has_onboarded) {
19 | if (!session?.user?.twitter_id) {
20 | logger.logWarning('API', 'GET /api/migrate/matching_found', 'Twitter ID not found in session', session.user.id);
21 | return NextResponse.json(
22 | { error: 'Twitter ID not found in session' },
23 | { status: 400 }
24 | );
25 | }
26 | result = await matchingService.getSourcesFromFollower(session.user.twitter_id);
27 | } else {
28 | result = await matchingService.getFollowableTargets(session.user.id);
29 | }
30 | return NextResponse.json({ matches: result });
31 |
32 | } catch (error) {
33 | const userId = (await auth())?.user?.id || 'unknown';
34 | logger.logError('API', 'GET /api/migrate/matching_found', error, userId, {
35 | context: 'Error in matching_found route'
36 | });
37 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
38 | }
39 | }
40 |
41 | export const GET = withLogging(matchingFoundHandler);
--------------------------------------------------------------------------------
/src/app/api/share/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { auth } from "@/app/auth";
3 | import { UserService } from '@/lib/services/userServices';
4 | import { UserRepository } from '@/lib/repositories/userRepository';
5 |
6 | export async function POST(request: Request) {
7 | const session = await auth();
8 | if (!session?.user?.id) {
9 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
10 | }
11 |
12 | try {
13 | const body = await request.json();
14 | const shareService = new UserService();
15 | await shareService.recordShareEvent(session.user.id, body.platform, body.success);
16 | return NextResponse.json({ success: true });
17 | } catch (error) {
18 | console.error('Share error:', error);
19 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
20 | }
21 | }
22 |
23 | export async function GET(request: Request) {
24 | const session = await auth();
25 | if (!session?.user?.id) {
26 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
27 | }
28 |
29 | try {
30 | const userRepo = new UserRepository();
31 | const hasShares = await userRepo.hasShareEvents(session.user.id);
32 |
33 | console.log(`User ${session.user.id} has shares: ${hasShares}`);
34 | return NextResponse.json({ hasShares });
35 | } catch (error) {
36 | console.error('Share check error:', error);
37 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
38 | }
39 | }
--------------------------------------------------------------------------------
/src/app/api/stats/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { auth } from '@/app/auth';
3 | import { StatsService } from '@/lib/services/statsServices';
4 | import { StatsRepository } from '@/lib/repositories/statsRepository';
5 | import logger, { withLogging } from '@/lib/log_utils';
6 |
7 | async function userStatsHandler() {
8 | try {
9 | const session = await auth();
10 | if (!session?.user?.id) {
11 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 | }
13 |
14 | if (!session?.user?.has_onboarded) {
15 | if (!session?.user?.twitter_id) {
16 | return NextResponse.json({
17 | connections: {
18 | followers: 0,
19 | following: 0
20 | },
21 | matches: {
22 | bluesky: { total: 0, hasFollowed: 0, notFollowed: 0 },
23 | mastodon: { total: 0, hasFollowed: 0, notFollowed: 0 }
24 | }
25 | });
26 | }
27 |
28 | }
29 |
30 | const repository = new StatsRepository();
31 | const statsService = new StatsService(repository);
32 |
33 | const stats = await statsService.getUserStats(session.user.id, session.user.has_onboarded);
34 | return NextResponse.json(stats);
35 | } catch (error) {
36 | const userId = (await auth())?.user?.id || 'unknown';
37 | logger.logError('API', 'GET /api/stats', error, userId, {
38 | context: 'Retrieving user stats'
39 | });
40 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
41 | }
42 | }
43 |
44 | export const GET = withLogging(userStatsHandler);
--------------------------------------------------------------------------------
/src/app/api/stats/total/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { auth } from '@/app/auth';
3 | import { StatsService } from '@/lib/services/statsServices';
4 | import { StatsRepository } from '@/lib/repositories/statsRepository';
5 | import logger, { withLogging } from '@/lib/log_utils';
6 |
7 | async function globalStatsHandler() {
8 | try {
9 | const session = await auth();
10 | if (!session) {
11 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 | }
13 |
14 | const repository = new StatsRepository();
15 | const statsService = new StatsService(repository);
16 |
17 | const stats = await statsService.getGlobalStats();
18 |
19 | return NextResponse.json(stats);
20 | } catch (error) {
21 | const userId = (await auth())?.user?.id || 'unknown';
22 | logger.logError('API', 'GET /api/stats/total', error, userId, {
23 | context: 'Retrieving global stats'
24 | });
25 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
26 | }
27 | }
28 |
29 | export const GET = withLogging(globalStatsHandler);
--------------------------------------------------------------------------------
/src/app/api/support/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import nodemailer from 'nodemailer';
3 | import { auth } from '@/app/auth';
4 | import logger, { withLogging } from '@/lib/log_utils';
5 |
6 | const transporter = nodemailer.createTransport({
7 | host: process.env.SMTP_HOST,
8 | port: parseInt(process.env.SMTP_PORT || "587"),
9 | secure: process.env.SMTP_PORT === "465",
10 | auth: {
11 | user: process.env.EMAIL_USER,
12 | pass: process.env.EMAIL_PASS
13 | }
14 | });
15 |
16 | async function supportHandler(request: Request) {
17 | try {
18 | const session = await auth();
19 | const { subject, message, email } = await request.json();
20 |
21 | // Construire le sujet avec le statut d'authentification
22 | const authStatus = session?.user
23 | ? `[Auth - ID: ${session.user.id}]`
24 | : '[Non Auth]';
25 |
26 | const mailOptions = {
27 | from: process.env.EMAIL_USER,
28 | to: process.env.EMAIL_USER,
29 | subject: `Support ${authStatus}: ${subject}`,
30 | replyTo: email, // Add reply-to field with client's email
31 | text: message,
32 | html: `
33 | Nouveau message de support
34 | ${session?.user ? `Utilisateur ID: ${session.user.id}
` : 'Utilisateur: Non authentifié
'}
35 | Email: ${email}
36 | Sujet: ${subject}
37 | Message:
38 | ${message.replace(/\n/g, '
')}
39 | `
40 | };
41 |
42 | await transporter.sendMail(mailOptions);
43 |
44 | return NextResponse.json({ success: true });
45 | } catch (error) {
46 | const userId = (await auth())?.user?.id || 'unknown';
47 | logger.logError('API', 'POST /api/support', error, userId, {
48 | context: 'Sending support email'
49 | });
50 | return NextResponse.json(
51 | { error: 'Failed to send email' },
52 | { status: 500 }
53 | );
54 | }
55 | }
56 |
57 | export const POST = withLogging(supportHandler);
--------------------------------------------------------------------------------
/src/app/api/tasks/[taskId]/route.ts:
--------------------------------------------------------------------------------
1 | // src/app/api/tasks/[taskId]/route.ts
2 | import { NextRequest, NextResponse } from 'next/server';
3 | import { auth } from '@/app/auth';
4 | import { createClient } from '@supabase/supabase-js';
5 |
6 | export async function GET(
7 | request: NextRequest,
8 | { params }: { params: { taskId: string } }
9 | ) {
10 | try {
11 | const session = await auth();
12 |
13 | if (!session?.user?.id) {
14 | return NextResponse.json(
15 | { success: false, error: 'Unauthorized' },
16 | { status: 401 }
17 | );
18 | }
19 |
20 | const paramsData = await params;
21 | const taskId = paramsData.taskId;
22 |
23 | if (!taskId) {
24 | return NextResponse.json(
25 | { success: false, error: 'Task ID is required' },
26 | { status: 400 }
27 | );
28 | }
29 |
30 | // Créer une connexion à Supabase
31 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
32 | const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
33 | const supabase = createClient(supabaseUrl, supabaseKey);
34 |
35 | // Récupérer la tâche par son ID
36 | const { data, error } = await supabase
37 | .from('python_tasks')
38 | .select('*')
39 | .eq('id', taskId)
40 | .single();
41 |
42 | if (error) {
43 | console.error('API', 'GET /api/tasks/[taskId]', error);
44 | return NextResponse.json(
45 | { success: false, error: 'Task not found' },
46 | { status: 404 }
47 | );
48 | }
49 |
50 | // Vérifier que l'utilisateur est autorisé à accéder à cette tâche
51 | if (data.user_id !== session.user.id) {
52 | return NextResponse.json(
53 | { success: false, error: 'Unauthorized: Task belongs to another user' },
54 | { status: 403 }
55 | );
56 | }
57 |
58 | return NextResponse.json({
59 | success: true,
60 | task: data
61 | });
62 |
63 | } catch (error) {
64 | console.error('API', 'GET /api/tasks/[taskId]', error);
65 | return NextResponse.json(
66 | {
67 | success: false,
68 | error: 'Internal server error',
69 | details: error instanceof Error ? error.message : String(error)
70 | },
71 | { status: 500 }
72 | );
73 | }
74 | }
--------------------------------------------------------------------------------
/src/app/api/update/user_stats/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { StatsRepository } from '@/lib/repositories/statsRepository';
3 | import { StatsService } from '@/lib/services/statsServices';
4 | import { auth } from '@/app/auth';
5 | import logger, { withLogging } from '@/lib/log_utils';
6 |
7 | async function updateUserStatsHandler() {
8 | try {
9 | const session = await auth();
10 | if (!session?.user?.id) {
11 | logger.logWarning('API', 'POST /api/update/user_stats', 'Unauthorized request - no user ID found in session');
12 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
13 | }
14 | const statsRepository = new StatsRepository();
15 | const statsService = new StatsService(statsRepository);
16 |
17 | await statsService.refreshUserStats(session.user.id, session.user.has_onboarded);
18 |
19 | return NextResponse.json({ success: true });
20 | } catch (error) {
21 | console.error('[UserStats] Error updating user stats:', error);
22 | if (error instanceof Error) {
23 | console.error('[UserStats] Error details:', error.message);
24 | }
25 | const userId = (await auth())?.user?.id || 'unknown';
26 | logger.logError('API', 'POST /api/update/user_stats', error, userId, {
27 | context: 'Updating user stats'
28 | });
29 | return NextResponse.json(
30 | { error: 'Failed to update user stats' },
31 | { status: 500 }
32 | );
33 | }
34 | }
35 |
36 | export const POST = withLogging(updateUserStatsHandler);
--------------------------------------------------------------------------------
/src/app/api/users/automatic-reconnect/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { auth } from "@/app/auth";
3 | import { authClient } from '@/lib/supabase'
4 | import logger, { withLogging } from '@/lib/log_utils';
5 |
6 | async function automaticReconnectHandler(request: Request) {
7 | try {
8 | const session = await auth();
9 |
10 | if (!session?.user?.id) {
11 | logger.logWarning('API', 'POST /api/users/automatic-reconnect', 'Unauthorize','anonymous');
12 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
13 | }
14 |
15 | const { automatic_reconnect } = await request.json();
16 |
17 | if (typeof automatic_reconnect !== 'boolean') {
18 | logger.logWarning('API', 'POST /api/users/automatic-reconnect', 'Invalid value for automatic_reconnect', session.user.id);
19 | return NextResponse.json({ error: 'Invalid value for automatic_reconnect' }, { status: 400 });
20 | }
21 |
22 | // Mettre à jour dans Supabase
23 | const { error: updateError } = await authClient
24 | .from('users')
25 | .update({ automatic_reconnect })
26 | .eq('id', session.user.id);
27 |
28 | if (updateError) {
29 | logger.logError('API', 'POST /api/users/automatic-reconnect', updateError, session.user.id, {
30 | context: 'Updating automatic_reconnect setting'
31 | });
32 | return NextResponse.json({ error: 'Failed to update automatic_reconnect' }, { status: 500 });
33 | }
34 |
35 | return NextResponse.json({ success: true, automatic_reconnect });
36 |
37 | } catch (error) {
38 | const userId = (await auth())?.user?.id || 'unknown';
39 | logger.logError('API', 'POST /api/users/automatic-reconnect', error, userId, {
40 | context: 'Processing automatic reconnect request'
41 | });
42 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
43 | }
44 | }
45 |
46 | export const POST = withLogging(automaticReconnectHandler);
--------------------------------------------------------------------------------
/src/app/api/users/bot-newsletter/route.ts:
--------------------------------------------------------------------------------
1 | // import { NextResponse } from 'next/server';
2 | // import { auth } from "@/app/auth";
3 | // import { authClient } from '@/lib/supabase'
4 | // import logger, { withLogging } from '@/lib/log_utils';
5 |
6 | // /**
7 | // * Gère les requêtes POST pour mettre à jour le statut have_seen_bot_newsletter de l'utilisateur
8 | // */
9 | // async function botNewsletterHandler(request: Request) {
10 | // try {
11 | // const session = await auth();
12 |
13 | // if (!session?.user?.id) {
14 | // logger.logWarning('API', 'POST /api/users/bot-newsletter', 'Unauthorized', 'anonymous');
15 | // return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
16 | // }
17 |
18 | // const { haveSeenBotNewsletter } = await request.json();
19 |
20 | // if (typeof haveSeenBotNewsletter !== 'boolean') {
21 | // logger.logWarning('API', 'POST /api/users/bot-newsletter', 'Invalid value for haveSeenBotNewsletter', session.user.id);
22 | // return NextResponse.json({ error: 'Invalid value for haveSeenBotNewsletter' }, { status: 400 });
23 | // }
24 |
25 | // // // Mettre à jour dans Supabase
26 | // // const { error: updateError } = await authClient
27 | // // .from('users')
28 | // // .update({ have_seen_bot_newsletter: haveSeenBotNewsletter })
29 | // // .eq('id', session.user.id);
30 |
31 | // // if (updateError) {
32 | // // logger.logError('API', 'POST /api/users/bot-newsletter', updateError, session.user.id, {
33 | // // context: 'Updating have_seen_bot_newsletter setting'
34 | // // });
35 | // // return NextResponse.json({ error: 'Failed to update have_seen_bot_newsletter' }, { status: 500 });
36 | // // }
37 |
38 | // return NextResponse.json({ success: true, have_seen_bot_newsletter: haveSeenBotNewsletter });
39 |
40 | // } catch (error) {
41 | // const userId = (await auth())?.user?.id || 'unknown';
42 | // logger.logError('API', 'POST /api/users/bot-newsletter', error, userId, {
43 | // context: 'Processing bot newsletter request'
44 | // });
45 | // return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
46 | // }
47 | // }
48 |
49 | // export const POST = withLogging(botNewsletterHandler);
--------------------------------------------------------------------------------
/src/app/api/users/language/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { auth } from "@/app/auth"
3 | import { UserService } from '@/lib/services/userServices';
4 |
5 | const userService = new UserService();
6 |
7 | export async function GET(req: NextRequest) {
8 | try {
9 | const session = await auth()
10 | if (!session?.user?.id) {
11 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 | }
13 |
14 | const languagePref = await userService.getLanguagePreference(session.user.id);
15 | return NextResponse.json(languagePref);
16 | } catch (error) {
17 | console.error('Error getting language preference:', error);
18 | return NextResponse.json(
19 | { error: 'Failed to get language preference' },
20 | { status: 500 }
21 | );
22 | }
23 | }
24 |
25 | export async function POST(req: NextRequest) {
26 | try {
27 | const session = await auth()
28 | if (!session?.user?.id) {
29 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
30 | }
31 |
32 | const data = await req.json();
33 | const { language } = data;
34 |
35 | if (!language) {
36 | return NextResponse.json(
37 | { error: 'Language is required' },
38 | { status: 400 }
39 | );
40 | }
41 |
42 | // const metadata = {
43 | // ip_address: req.ip,
44 | // user_agent: req.headers.get('user-agent')
45 | // };
46 |
47 | await userService.updateLanguagePreference(session.user.id, language);
48 | return NextResponse.json({ success: true });
49 | } catch (error) {
50 | console.error('Error updating language preference:', error);
51 | return NextResponse.json(
52 | { error: 'Failed to update language preference' },
53 | { status: 500 }
54 | );
55 | }
56 | }
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ISCPIF/OpenPortability/e2ab74e39c25954a6387d06252854863e89ff19b/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ISCPIF/OpenPortability/e2ab74e39c25954a6387d06252854863e89ff19b/src/app/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/src/app/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ISCPIF/OpenPortability/e2ab74e39c25954a6387d06252854863e89ff19b/src/app/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/src/app/fonts/SyneTactile-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ISCPIF/OpenPortability/e2ab74e39c25954a6387d06252854863e89ff19b/src/app/fonts/SyneTactile-Regular.ttf
--------------------------------------------------------------------------------
/src/app/fonts/plex.ts:
--------------------------------------------------------------------------------
1 | import { IBM_Plex_Mono, Caveat } from 'next/font/google'
2 |
3 | export const plex = IBM_Plex_Mono({
4 | subsets: ['latin'],
5 | weight: ["300", "400", "700"]
6 | })
7 |
8 | export const caveat = Caveat({
9 | subsets: ['latin'],
10 | weight: ["400", "700"]
11 | })
12 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @font-face {
4 | font-family: 'SyneTactile';
5 | src: url('/fonts/SyneTactile-Regular.ttf') format('truetype');
6 | font-weight: normal;
7 | font-style: normal;
8 | }
9 |
10 | :root {
11 | --background: #ffffff;
12 | --foreground: #171717;
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | --font-space-grotesk: 'Space Grotesk', sans-serif;
17 | }
18 |
19 | @media (prefers-color-scheme: dark) {
20 | :root {
21 | --background: #0a0a0a;
22 | --foreground: #ededed;
23 | }
24 | }
25 |
26 | body {
27 | color: var(--foreground);
28 | background: var(--background);
29 | font-family: Arial, Helvetica, sans-serif;
30 | }
31 |
32 | .font-space-grotesk {
33 | font-family: var(--font-space-grotesk);
34 | }
35 |
36 | @keyframes blob {
37 | 0% {
38 | transform: translate(0px, 0px) scale(1);
39 | }
40 | 33% {
41 | transform: translate(30px, -50px) scale(1.1);
42 | }
43 | 66% {
44 | transform: translate(-20px, 20px) scale(0.9);
45 | }
46 | 100% {
47 | transform: translate(0px, 0px) scale(1);
48 | }
49 | }
50 |
51 | .animate-blob {
52 | animation: blob 7s infinite;
53 | }
54 |
55 | .animation-delay-2000 {
56 | animation-delay: 2s;
57 | }
58 |
59 | .animation-delay-4000 {
60 | animation-delay: 4s;
61 | }
62 |
63 | /* Animation de mise en évidence pour la section de support personnalisé */
64 | @keyframes highlight-pulse {
65 | 0% {
66 | box-shadow: 0 0 0 0 rgba(214, 53, 111, 0.7);
67 | }
68 | 70% {
69 | box-shadow: 0 0 0 10px rgba(214, 53, 111, 0);
70 | }
71 | 100% {
72 | box-shadow: 0 0 0 0 rgba(214, 53, 111, 0);
73 | }
74 | }
75 |
76 | .highlight-animation {
77 | animation: highlight-pulse 1s ease-in-out 3;
78 | }
79 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function RootLayout({
2 | children,
3 | }: {
4 | children: React.ReactNode
5 | }) {
6 | // Remove console.log as it can also contribute to hydration issues
7 | // by executing differently on server vs client
8 |
9 | // Don't wrap with HTML or body tags - these are already in the locale layout
10 | return children;
11 | }
--------------------------------------------------------------------------------
/src/app/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { SessionProvider } from "next-auth/react"
4 |
5 | type ProvidersProps = {
6 | children: React.ReactNode;
7 | session?: any;
8 | }
9 |
10 | export function Providers({ children, session }: ProvidersProps) {
11 | // console.log("Session provider called !", { session })
12 | return (
13 |
14 | {children}
15 |
16 | )
17 | }
--------------------------------------------------------------------------------
/src/hooks/useAuthTokens.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState, useRef } from "react";
2 |
3 | // Module-level variables to track verification state across component instances
4 | const globalTokensVerified = { current: false };
5 | // Shared promise for concurrent verification requests
6 | let activeVerificationPromise: Promise | null = null;
7 |
8 | export function useAuthTokens() {
9 | const [missingProviders, setMissingProviders] = useState<('bluesky' | 'mastodon')[]>([]);
10 | // Local ref to track if tokens have been verified in this component instance
11 | const tokensVerifiedRef = useRef(globalTokensVerified.current);
12 |
13 | const verifyTokens = useCallback(async () => {
14 | // If tokens are already verified, return immediately
15 | if (tokensVerifiedRef.current || globalTokensVerified.current) {
16 | tokensVerifiedRef.current = true;
17 | globalTokensVerified.current = true;
18 | return { isValid: true };
19 | }
20 |
21 | // If there's an active verification in progress, reuse that promise
22 | if (activeVerificationPromise) {
23 | return activeVerificationPromise;
24 | }
25 |
26 | // Create a new verification promise
27 | activeVerificationPromise = (async () => {
28 | try {
29 | const response = await fetch('/api/auth/refresh', {
30 | method: 'POST',
31 | headers: {
32 | 'Cache-Control': 'no-cache',
33 | 'X-Request-ID': `auth-refresh-${Date.now()}` // Add unique identifier
34 | }
35 | });
36 | const data = await response.json();
37 |
38 | if (!data.success && data.providers) {
39 | setMissingProviders(data.providers);
40 | }
41 |
42 | tokensVerifiedRef.current = true;
43 | globalTokensVerified.current = true;
44 |
45 | return { isValid: data.success, providers: data.providers };
46 | } catch (error) {
47 | console.error('Error verifying tokens:', error);
48 | return { isValid: false, error };
49 | } finally {
50 | // Clear the active promise reference when done
51 | activeVerificationPromise = null;
52 | }
53 | })();
54 |
55 | return activeVerificationPromise;
56 | }, []);
57 |
58 | // Function to manually reset the verification state (useful for logout, etc.)
59 | const resetTokenVerification = useCallback(() => {
60 | tokensVerifiedRef.current = false;
61 | globalTokensVerified.current = false;
62 | activeVerificationPromise = null;
63 | }, []);
64 |
65 | return { missingProviders, verifyTokens, resetTokenVerification };
66 | }
--------------------------------------------------------------------------------
/src/hooks/useMastodonInstances.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export function useMastodonInstances() {
4 | const [mastodonInstances, setMastodonInstances] = useState([]);
5 |
6 | useEffect(() => {
7 | const fetchMastodonInstances = async () => {
8 | try {
9 | const response = await fetch('/api/auth/mastodon');
10 | const data = await response.json();
11 | if (data.success) {
12 | setMastodonInstances(data.instances);
13 | }
14 | } catch (error) {
15 | console.error('Error fetching Mastodon instances:', error);
16 | }
17 | };
18 |
19 | fetchMastodonInstances();
20 | }, []);
21 |
22 | return mastodonInstances;
23 | }
--------------------------------------------------------------------------------
/src/i18n/navigation.ts:
--------------------------------------------------------------------------------
1 | import {createSharedPathnamesNavigation} from 'next-intl/navigation';
2 |
3 | export const locales = ['en', 'fr', 'es', 'it', 'de', 'sv', 'pt'] as const;
4 | export const localePrefix = 'always'; // Default
5 |
6 | export const {Link, redirect, usePathname, useRouter} =
7 | createSharedPathnamesNavigation({locales, localePrefix});
--------------------------------------------------------------------------------
/src/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import {getRequestConfig} from 'next-intl/server';
2 | import { headers } from 'next/headers';
3 |
4 | export default getRequestConfig(async () => {
5 | const headersList = await headers();
6 | const locale = headersList.get('X-NEXT-INTL-LOCALE') || 'fr';
7 | return {
8 | messages: (await import(`../../messages/${locale}.json`)).default,
9 | locale: locale
10 | };
11 | });
--------------------------------------------------------------------------------
/src/lib/bluesky.ts:
--------------------------------------------------------------------------------
1 | import { BskyAgent } from '@atproto/api'
2 |
3 | const BSKY_SERVICE = 'https://bsky.social'
4 |
5 | type BlueSkySession = {
6 | accessToken: string
7 | refreshToken: string
8 | handle: string
9 | }
10 |
11 | export async function getProfileInfo(handle: string, session?: BlueSkySession) {
12 | const agent = new BskyAgent({ service: BSKY_SERVICE })
13 |
14 | try {
15 | if (session?.accessToken) {
16 | await agent.resumeSession({
17 | accessJwt: session.accessToken,
18 | refreshJwt: session.refreshToken,
19 | handle: session.handle,
20 | did: `did:plc:${session.handle.replace('.', '')}`,
21 | active: true
22 | })
23 | }
24 |
25 | const response = await agent.getProfile({ actor: handle })
26 | return {
27 | handle: response.data.handle,
28 | displayName: response.data.displayName,
29 | avatar: response.data.avatar,
30 | description: response.data.description
31 | }
32 | } catch (error) {
33 | console.error(`Erreur lors de la récupération du profil ${handle}:`, error)
34 | return null
35 | }
36 | }
--------------------------------------------------------------------------------
/src/lib/dataFormating.ts:
--------------------------------------------------------------------------------
1 | import { UserCompleteStats } from "@/lib/types/stats";
2 |
3 | export function formatNumber(num: number): string {
4 | return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
5 | }
6 |
7 | export function calculateTotalMatches(stats: UserCompleteStats) {
8 | const totalMatches = stats.matches.bluesky.notFollowed + stats.matches.mastodon.notFollowed;
9 | const totalHasFollowed = stats.matches.bluesky.hasFollowed + stats.matches.mastodon.hasFollowed;
10 |
11 | return { totalMatches, totalHasFollowed };
12 | }
--------------------------------------------------------------------------------
/src/lib/encryption.ts:
--------------------------------------------------------------------------------
1 | import CryptoJS from 'crypto-js';
2 |
3 | const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '';
4 |
5 | if (!ENCRYPTION_KEY) {
6 | throw new Error('ENCRYPTION_KEY environment variable is not set');
7 | }
8 |
9 | export function encrypt(text: string): string {
10 | if (!text) return text;
11 | return CryptoJS.AES.encrypt(text, ENCRYPTION_KEY).toString();
12 | }
13 |
14 | export function decrypt(encryptedText: string): string {
15 | if (!encryptedText) return encryptedText;
16 | const bytes = CryptoJS.AES.decrypt(encryptedText, ENCRYPTION_KEY);
17 | return bytes.toString(CryptoJS.enc.Utf8);
18 | }
--------------------------------------------------------------------------------
/src/lib/services/newsletterService.ts:
--------------------------------------------------------------------------------
1 | // Types for newsletter data matching DB structure
2 | export type ConsentType = 'email_newsletter' | 'oep_newsletter' | 'research_participation' | 'personalized_support' | 'bluesky_dm' | 'mastodon_dm';
3 |
4 | export interface Consent {
5 | type: ConsentType;
6 | value: boolean;
7 | }
8 |
9 | // Type pour les données brutes reçues de l'API
10 | export interface RawNewsletterResponse {
11 | email?: string;
12 | email_newsletter?: boolean;
13 | oep_newsletter?: boolean;
14 | research_participation?: boolean;
15 | personalized_support?: boolean;
16 | bluesky_dm?: boolean;
17 | mastodon_dm?: boolean;
18 | }
19 |
20 | export interface NewsletterData {
21 | email?: string;
22 | consents: {
23 | [key in ConsentType]?: boolean;
24 | };
25 | }
26 |
27 | /**
28 | * Fetches all newsletter data
29 | */
30 | export const fetchNewsletterData = async (): Promise => {
31 | const response = await fetch('/api/newsletter/request', {
32 | method: 'GET',
33 | headers: { 'Content-Type': 'application/json' }
34 | });
35 |
36 | if (!response.ok) {
37 | throw new Error('Failed to fetch newsletter data');
38 | }
39 |
40 | return response.json();
41 | };
42 |
43 | /**
44 | * Updates a single newsletter consent
45 | */
46 | export const updateNewsletterConsent = async (
47 | consent: Consent,
48 | email?: string
49 | ): Promise => {
50 | try {
51 | const response = await fetch('/api/newsletter/request', {
52 | method: 'POST',
53 | headers: { 'Content-Type': 'application/json' },
54 | body: JSON.stringify({
55 | email,
56 | consents: [consent]
57 | })
58 | });
59 |
60 | if (!response.ok) {
61 | throw new Error('Failed to update newsletter consent');
62 | }
63 |
64 | await response.json();
65 | return true;
66 | } catch (error) {
67 | console.error('Error updating newsletter consent:', error);
68 | return false;
69 | }
70 | };
--------------------------------------------------------------------------------
/src/lib/services/statsServices.ts:
--------------------------------------------------------------------------------
1 | import { StatsRepository } from "@/lib/repositories/statsRepository";
2 | import { UserCompleteStats, GlobalStats, ReconnectionStats } from "@/lib/types/stats";
3 |
4 | export class StatsService {
5 | private repository: StatsRepository;
6 |
7 | constructor(repository: StatsRepository) {
8 | this.repository = repository;
9 | }
10 |
11 | async getGlobalStats(): Promise {
12 | return this.repository.getGlobalStats();
13 | }
14 |
15 | async getUserStats(userId: string, has_onboard: boolean): Promise {
16 | return this.repository.getUserCompleteStats(userId, has_onboard);
17 | }
18 |
19 | async getReconnectionStats(): Promise {
20 | const globalStats = await this.repository.getGlobalStats();
21 |
22 | return {
23 | connections: globalStats.connections.followers + globalStats.connections.following,
24 | blueskyMappings: globalStats.connections.withHandle,
25 | sources: globalStats.users.total
26 | };
27 | }
28 |
29 | async refreshUserStats(userId: string, has_onboard: boolean): Promise {
30 | return this.repository.refreshUserStatsCache(userId, has_onboard);
31 | }
32 |
33 | async refreshGlobalStats(): Promise {
34 | return this.repository.refreshGlobalStatsCache();
35 | }
36 | }
--------------------------------------------------------------------------------
/src/lib/supabase.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@supabase/supabase-js'
2 |
3 | if (!process.env.NEXT_PUBLIC_SUPABASE_URL) {
4 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_URL')
5 | }
6 |
7 | if (!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
8 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_ANON_KEY')
9 | }
10 |
11 | if (!process.env.SUPABASE_SERVICE_ROLE_KEY) {
12 | throw new Error('Missing env.SUPABASE_SERVICE_ROLE_KEY')
13 | }
14 |
15 | export const supabase = createClient(
16 | process.env.NEXT_PUBLIC_SUPABASE_URL,
17 | process.env.SUPABASE_SERVICE_ROLE_KEY,
18 | {
19 | auth: {
20 | autoRefreshToken: true,
21 | persistSession: true,
22 | detectSessionInUrl: true
23 | }
24 | }
25 | )
26 |
27 | export const authClient = createClient(
28 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
29 | process.env.SUPABASE_SERVICE_ROLE_KEY!,
30 | {
31 | auth: {
32 | autoRefreshToken: false,
33 | persistSession: false
34 | },
35 | db: {
36 | schema: "next-auth"
37 | }
38 | }
39 | )
--------------------------------------------------------------------------------
/src/lib/types/account.ts:
--------------------------------------------------------------------------------
1 | export type Provider = 'bluesky' | 'mastodon';
2 |
3 | export interface TokenData {
4 | access_token: string;
5 | refresh_token?: string;
6 | expires_at?: Date;
7 | provider: Provider;
8 | provider_account_id?: string;
9 | }
10 |
11 | export interface TokenUpdate {
12 | access_token?: string;
13 | refresh_token?: string;
14 | expires_at?: Date;
15 | }
16 |
17 | export interface RefreshResult {
18 | success: boolean;
19 | error?: string;
20 | requiresReauth?: boolean;
21 | }
22 |
23 | export interface BlueskyCredentials {
24 | accessJwt: string;
25 | refreshJwt: string;
26 | handle: string;
27 | did: string;
28 | }
--------------------------------------------------------------------------------
/src/lib/types/bluesky.ts:
--------------------------------------------------------------------------------
1 | export interface BlueskyProfile {
2 | did: string
3 | handle: string
4 | displayName?: string
5 | avatar?: string
6 | }
7 |
8 | export interface BlueskySessionData {
9 | accessJwt: string
10 | refreshJwt: string
11 | handle: string
12 | did: string
13 | }
14 |
15 | export interface BlueskyAuthResult {
16 | success: boolean
17 | data?: BlueskySessionData
18 | error?: string
19 | }
20 |
21 | export interface BatchFollowResult {
22 | attempted: number
23 | succeeded: number
24 | failures: Array<{
25 | handle: string
26 | error: string
27 | }>
28 | }
29 |
30 | export interface IBlueskyRepository {
31 | getUserByBlueskyId(did: string): Promise
32 | linkBlueskyAccount(userId: string, blueskyData: BlueskySessionData): Promise
33 | updateBlueskyProfile(userId: string, profile: BlueskyProfile): Promise
34 | }
35 |
36 | export interface IBlueskyService {
37 | login(identifier: string, password: string): Promise
38 | resumeSession(sessionData: BlueskySessionData): Promise
39 | logout(): Promise
40 | getProfile(handle: string): Promise
41 | follow(did: string): Promise
42 | batchFollow(handles: string[]): Promise
43 | }
--------------------------------------------------------------------------------
/src/lib/types/common.ts:
--------------------------------------------------------------------------------
1 | export interface UserSession {
2 | user: {
3 | id?: string;
4 | twitter_username?: string;
5 | bluesky_username?: string | null;
6 | mastodon_username?: string | null;
7 | mastodon_instance?: string | null;
8 | twitter_id?: string;
9 | bluesky_id?: string;
10 | mastodon_id?: string;
11 | has_onboarded?: boolean;
12 | have_seen_newsletter?: boolean;
13 | have_seen_bot_newsletter?: boolean;
14 | }
15 | }
16 |
17 | export interface ConnectedServices {
18 | twitter: boolean;
19 | bluesky: boolean;
20 | mastodon: boolean;
21 | }
--------------------------------------------------------------------------------
/src/lib/types/mastodon.ts:
--------------------------------------------------------------------------------
1 | export interface MastodonCredentials {
2 | accessToken: string;
3 | userInstance: string;
4 | }
5 |
6 | export interface MastodonAccount {
7 | id: string;
8 | username: string;
9 | acct: string;
10 | url: string;
11 | }
12 |
13 | export interface MastodonFollowResult {
14 | success: boolean;
15 | error?: string;
16 | accountId?: string;
17 | }
18 |
19 | export interface MastodonTarget {
20 | username: string;
21 | instance: string;
22 | id?: string;
23 | }
24 |
25 | export interface MastodonBatchFollowResult {
26 | attempted: number;
27 | succeeded: number;
28 | failures: Array<{
29 | handle: string;
30 | error?: string;
31 | }>;
32 | successfulHandles: string[];
33 | }
34 |
35 | export interface IMastodonService {
36 | followAccount(
37 | accessToken: string,
38 | userInstance: string,
39 | targetUsername: string,
40 | targetInstance: string,
41 | targetId?: string
42 | ): Promise;
43 |
44 | batchFollow(
45 | accessToken: string,
46 | userInstance: string,
47 | targets: Array
48 | ): Promise;
49 | }
--------------------------------------------------------------------------------
/src/lib/types/matching.ts:
--------------------------------------------------------------------------------
1 | export interface MatchingTarget {
2 | target_twitter_id: string | null;
3 | bluesky_handle: string | null;
4 | mastodon_handle: string | null;
5 | mastodon_username: string | null;
6 | mastodon_instance: string | null;
7 | mastodon_id: string | null;
8 | has_follow_bluesky: boolean;
9 | has_follow_mastodon: boolean;
10 | total_count?: number;
11 | }
12 |
13 | export interface MatchedFollower {
14 | source_twitter_id: string;
15 | bluesky_handle: string | null;
16 | mastodon_id: string | null;
17 | mastodon_username: string | null;
18 | mastodon_instance: string | null;
19 | has_been_followed_on_bluesky: boolean;
20 | has_been_followed_on_mastodon: boolean;
21 | full_count?: number;
22 | }
23 |
24 | export interface MatchingStats {
25 | total_following: number;
26 | matched_following: number;
27 | bluesky_matches: number;
28 | mastodon_matches: number;
29 | }
30 |
31 | export interface MatchingResult {
32 | following: MatchingTarget[];
33 | stats: MatchingStats;
34 | }
--------------------------------------------------------------------------------
/src/lib/types/stats.ts:
--------------------------------------------------------------------------------
1 | export interface PlatformStats {
2 | total: number;
3 | hasFollowed: number;
4 | notFollowed: number;
5 | }
6 |
7 | export interface UserCompleteStats {
8 | connections: {
9 | followers: number;
10 | following: number;
11 | totalEffectiveFollowers: number;
12 | };
13 | matches: {
14 | bluesky: PlatformStats;
15 | mastodon: PlatformStats;
16 | };
17 | updated_at: string;
18 | }
19 |
20 | export interface GlobalStats {
21 | users: {
22 | total: number;
23 | onboarded: number;
24 | };
25 | connections: {
26 | followers: number;
27 | following: number;
28 | withHandle: number;
29 | withHandleBluesky: number;
30 | withHandleMastodon: number;
31 | followedOnBluesky: number;
32 | followedOnMastodon: number;
33 | };
34 | updated_at: string;
35 | }
36 |
37 | export interface ReconnectionStats {
38 | connections: number;
39 | blueskyMappings: number;
40 | sources: number;
41 | }
42 |
43 | export interface RawStatsData {
44 | count: number;
45 | }
46 |
47 | export interface StatsError {
48 | error: string;
49 | status: number;
50 | }
51 |
52 | export interface StatsResponse {
53 | total_followers: number;
54 | total_following: number;
55 | total_sources: number;
56 | }
57 |
58 | export type UserStats = {
59 | following: number;
60 | followers: number;
61 | }
--------------------------------------------------------------------------------
/src/lib/types/user.ts:
--------------------------------------------------------------------------------
1 | // Type de base pour un utilisateur
2 | export interface User {
3 | id: string;
4 | name?: string;
5 | twitter_id?: string;
6 | twitter_username?: string;
7 | twitter_image?: string;
8 | bluesky_id?: string;
9 | bluesky_username?: string;
10 | bluesky_image?: string;
11 | mastodon_id?: string;
12 | mastodon_username?: string;
13 | mastodon_image?: string;
14 | mastodon_instance?: string;
15 | email?: string;
16 | email_verified?: Date;
17 | image?: string;
18 | created_at: Date;
19 | updated_at: Date;
20 | has_onboarded: boolean;
21 | hqx_newsletter: boolean;
22 | oep_accepted: boolean;
23 | automatic_reconnect: boolean;
24 | research_accepted: boolean;
25 | have_seen_newsletter: boolean;
26 | personalized_support: boolean;
27 | }
28 |
29 | // Type pour les mises à jour utilisateur
30 | export type UserUpdate = Partial>;
31 |
32 | // Types spécifiques pour différents types de mises à jour
33 | export interface NewsletterUpdate extends Pick {}
34 |
35 | export interface ShareUpdate extends Pick {}
36 |
37 | // Type pour les événements de partage
38 | export interface ShareEvent {
39 | id?: string;
40 | source_id: string;
41 | platform: string;
42 | shared_at: string;
43 | success: boolean;
44 | created_at: string;
45 | }
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import createMiddleware from 'next-intl/middleware';
2 | import { NextResponse } from 'next/server';
3 | import type { NextRequest } from 'next/server';
4 |
5 | const locales = ['fr', 'en', 'es', 'it', 'de', 'sv', 'pt'];
6 | const defaultLocale = 'en';
7 |
8 | // Create the i18n middleware
9 | const intlMiddleware = createMiddleware({
10 | locales,
11 | defaultLocale,
12 | localePrefix: 'always'
13 | });
14 |
15 | // Middleware handler
16 | export default function middleware(request: NextRequest) {
17 | return intlMiddleware(request);
18 | }
19 |
20 | // Add matcher configuration to limit middleware execution
21 | export const config = {
22 | matcher: [
23 | // Skip all internal paths (_next), API routes, and static files
24 | '/((?!api|_next/static|_next/image|favicon.ico).*)',
25 | ],
26 | };
--------------------------------------------------------------------------------
/src/services/api.ts:
--------------------------------------------------------------------------------
1 | export async function fetchUserStats() {
2 | const response = await fetch('/api/stats', {
3 | headers: { 'Cache-Control': 'no-cache' }
4 | });
5 | return response.json();
6 | }
7 |
8 | export async function fetchGlobalStats() {
9 | const response = await fetch('/api/stats/total', {
10 | headers: { 'Cache-Control': 'no-cache' }
11 | });
12 | return response.json();
13 | }
14 |
15 | export async function updateUserStats() {
16 | const response = await fetch('/api/update/user_stats', {
17 | method: 'POST',
18 | headers: { 'Content-Type': 'application/json' }
19 | });
20 | return response.json();
21 | }
--------------------------------------------------------------------------------
/supabase/.gitignore:
--------------------------------------------------------------------------------
1 | # Supabase
2 | .branches
3 | .temp
4 | .env
5 |
--------------------------------------------------------------------------------
/supabase/migrations/001_create_schemas.sql:
--------------------------------------------------------------------------------
1 | create schema if not exists "next-auth";
2 | create extension if not exists "uuid-ossp";
--------------------------------------------------------------------------------
/supabase/migrations/002_create_users_table.sql:
--------------------------------------------------------------------------------
1 | create table "next-auth"."users" (
2 | "id" uuid not null default uuid_generate_v4(),
3 | "name" text,
4 | "twitter_id" text unique,
5 | "twitter_username" text unique,
6 | "twitter_image" text,
7 | "bluesky_id" text unique,
8 | "bluesky_username" text unique,
9 | "bluesky_image" text,
10 | "mastodon_id" text unique,
11 | "mastodon_username" text unique,
12 | "mastodon_image" text,
13 | "mastodon_instance" text,
14 | "email" text unique,
15 | "email_verified" timestamptz,
16 | "image" text,
17 | "created_at" timestamptz not null default now(),
18 | "updated_at" timestamptz not null default now(),
19 | "has_onboarded" boolean not null default false,
20 | "hqx_newsletter" boolean not null default false,
21 | "oep_accepted" boolean not null default false,
22 | "automatic_reconnect" boolean not null default false,
23 | "oep_accepted" boolean not null default false,
24 | "research_accepted" boolean not null default false,
25 | "have_seen_newsletter" boolean not null default false,
26 | primary key ("id"),
27 | unique("email")
28 | );
29 |
30 | -- Create a trigger to automatically update the updated_at column
31 | create or replace function update_updated_at_column()
32 | returns trigger as $$
33 | begin
34 | new.updated_at = now();
35 | return new;
36 | end;
37 | $$ language 'plpgsql';
38 |
39 | create trigger update_users_updated_at
40 | before update on "next-auth"."users"
41 | for each row
42 | execute procedure update_updated_at_column();
--------------------------------------------------------------------------------
/supabase/migrations/003_create_accounts_table.sql:
--------------------------------------------------------------------------------
1 | create table "next-auth"."accounts" (
2 | "id" uuid not null default uuid_generate_v4(),
3 | "user_id" uuid not null,
4 | "type" text not null,
5 | "provider" text not null,
6 | "provider_account_id" text not null,
7 | "refresh_token" text,
8 | "access_token" text,
9 | "expires_at" bigint,
10 | "token_type" text,
11 | "scope" text,
12 | "id_token" text,
13 | "session_state" text,
14 | "created_at" timestamptz not null default now(),
15 | "updated_at" timestamptz not null default now(),
16 | primary key ("id"),
17 | foreign key ("user_id") references "next-auth"."users"("id") on delete cascade,
18 | unique("provider", "provider_account_id")
19 | );
20 |
21 | create trigger update_accounts_updated_at
22 | before update on "next-auth"."accounts"
23 | for each row
24 | execute procedure update_updated_at_column();
--------------------------------------------------------------------------------
/supabase/migrations/004_create_sessions_table.sql:
--------------------------------------------------------------------------------
1 | create table "next-auth"."sessions" (
2 | "id" uuid not null default uuid_generate_v4(),
3 | "user_id" uuid not null,
4 | "expires" timestamptz not null,
5 | "session_token" text not null,
6 | "created_at" timestamptz not null default now(),
7 | "updated_at" timestamptz not null default now(),
8 | primary key ("id"),
9 | foreign key ("user_id") references "next-auth"."users"("id") on delete cascade,
10 | unique("session_token")
11 | );
12 |
13 | create trigger update_sessions_updated_at
14 | before update on "next-auth"."sessions"
15 | for each row
16 | execute procedure update_updated_at_column();
--------------------------------------------------------------------------------
/supabase/migrations/005_create_verification_tokens_table.sql:
--------------------------------------------------------------------------------
1 | create table "next-auth"."verification_tokens" (
2 | "identifier" text not null,
3 | "token" text not null,
4 | "expires" timestamptz not null,
5 | "created_at" timestamptz not null default now(),
6 | primary key ("identifier", "token")
7 | );
--------------------------------------------------------------------------------
/supabase/migrations/006_create_user_data_table.sql:
--------------------------------------------------------------------------------
1 | -- Grant permissions to the service_role
2 | grant usage on schema "next-auth" to service_role;
3 | grant all privileges on all tables in schema "next-auth" to service_role;
4 | grant all privileges on all sequences in schema "next-auth" to service_role;
5 |
6 | -- Grant permissions to the authenticated role
7 | grant usage on schema "next-auth" to authenticated;
8 | grant all privileges on all tables in schema "next-auth" to authenticated;
9 | grant all privileges on all sequences in schema "next-auth" to authenticated;
10 |
11 | -- Grant permissions to the anon role
12 | grant usage on schema "next-auth" to anon;
13 | grant all privileges on all tables in schema "next-auth" to anon;
14 |
15 | -- Grant specific table permissions
16 | grant all privileges on table "next-auth".users to service_role, authenticated, anon;
17 | grant all privileges on table "next-auth".accounts to service_role, authenticated, anon;
18 | grant all privileges on table "next-auth".sessions to service_role, authenticated, anon;
19 | grant all privileges on table "next-auth".verification_tokens to service_role, authenticated, anon;
20 |
21 | create or replace function "next-auth".verify_twitter_token(token_param text)
22 | returns table (
23 | user_id text,
24 | user_name text,
25 | user_email text,
26 | provider text,
27 | provider_account_id text
28 | )
29 | security definer
30 | set search_path = public, "next-auth"
31 | as $$
32 | begin
33 | return query
34 | select
35 | u.id::text as user_id,
36 | u.name as user_name,
37 | u.email as user_email,
38 | a.provider,
39 | a.provider_account_id
40 | from "next-auth".accounts a
41 | join "next-auth".users u on u.id = a.user_id::uuid
42 | where a.provider = 'twitter'
43 | and a.access_token = token_param
44 | and a.expires_at > extract(epoch from now())::bigint;
45 | end;
46 | $$ language plpgsql;
47 |
48 |
49 |
50 | -- Grant execute permission to authenticated users
51 | grant execute on function "next-auth".verify_twitter_token(text) to authenticated;
52 | grant execute on function "next-auth".verify_twitter_token(text) to anon;
53 | grant execute on function "next-auth".verify_twitter_token(text) to service_role;
54 |
55 | create table "public"."user_data" (
56 | "id" uuid not null default uuid_generate_v4(),
57 | "user_id" uuid not null,
58 | "created_at" timestamptz not null default now(),
59 | "updated_at" timestamptz not null default now(),
60 | primary key ("id")
61 | );
--------------------------------------------------------------------------------
/supabase/migrations/007_create_row_level_security.sql:
--------------------------------------------------------------------------------
1 | -- Enable RLS on public.user_data
2 | alter table "public"."user_data" enable row level security;
3 |
4 | -- Create policy for user_data table
5 | create policy "Users can view and edit their own data"
6 | on "public"."user_data"
7 | for all
8 | using (auth.uid() = user_id)
9 | with check (auth.uid() = user_id);
--------------------------------------------------------------------------------
/supabase/migrations/009_create_table_public_profiles.sql:
--------------------------------------------------------------------------------
1 | -- Create sources table (authenticated users)
2 | create table if not exists "public"."sources" (
3 | "id" uuid references "next-auth"."users" on delete cascade primary key
4 | );
5 |
6 | -- Enable RLS on sources
7 | alter table "public"."sources" enable row level security;
8 |
9 | -- Create RLS policies for sources
10 | create policy "Public sources are viewable by everyone"
11 | on sources for select using ( true );
12 |
13 | create policy "Users can update their own source"
14 | on sources for update using ( auth.uid() = id );
15 |
16 | -- Create targets table (Twitter accounts to follow)
17 | create table if not exists "public"."targets" (
18 | "twitter_id" text primary key,
19 | "bluesky_handle" text,
20 | "bluesky_did" text
21 | );
22 |
23 | -- Create sources_targets junction table
24 | create table if not exists "public"."sources_targets" (
25 | "source_id" uuid references "public"."sources"(id) on delete cascade,
26 | "target_twitter_id" text references "public"."targets"(twitter_id) on delete cascade,
27 | "bluesky_handle" text,
28 | "has_follow_bluesky" boolean DEFAULT false,
29 | "followed_at_bluesky" timestamp with time zone,
30 | primary key (source_id, target_twitter_id)
31 | );
32 |
33 | -- Enable RLS
34 | alter table "public"."targets" enable row level security;
35 | alter table "public"."sources_targets" enable row level security;
36 |
37 | -- RLS policies for targets
38 | create policy "Targets are viewable by everyone"
39 | on targets for select using ( true );
40 |
41 | create policy "Authenticated users can create targets"
42 | on targets for insert with check ( auth.uid() in (select id from public.sources) );
43 |
44 | -- RLS policies for sources_targets
45 | create policy "Anyone can view source-target relationships"
46 | on sources_targets for select using ( true );
47 |
48 | create policy "Sources can create their own relationships"
49 | on sources_targets for insert
50 | with check ( auth.uid() = source_id );
51 |
52 | create policy "Sources can delete their own relationships"
53 | on sources_targets for delete
54 | using ( auth.uid() = source_id );
55 |
56 | create policy "Sources can update their own relationships"
57 | on sources_targets for update
58 | using ( auth.uid() = source_id );
--------------------------------------------------------------------------------
/supabase/migrations/010_create_bluesky_table.sql:
--------------------------------------------------------------------------------
1 | -- Create followers table to store Twitter followers
2 | create table if not exists "public"."followers" (
3 | "twitter_id" text primary key,
4 | "bluesky_handle" text,
5 | "bluesky_did" text
6 | );
7 |
8 | -- Enable RLS on followers
9 | alter table "public"."followers" enable row level security;
10 |
11 | -- Create RLS policies for followers
12 | create policy "Followers are viewable by everyone"
13 | on followers for select using ( true );
14 |
15 | create policy "Authenticated users can create followers"
16 | on followers for insert
17 | with check ( auth.uid() in (select id from public.sources) );
18 |
19 | -- Create sources_followers table to store relationships
20 | create table if not exists "public"."sources_followers" (
21 | "source_id" uuid references public.sources(id) on delete cascade,
22 | "follower_id" text references public.followers(twitter_id) on delete cascade,
23 | "bluesky_handle" text,
24 | "has_follow_bluesky" boolean DEFAULT false,
25 | "followed_at_bluesky" timestamp with time zone,
26 | primary key (source_id, follower_id)
27 | );
28 |
29 | -- Enable RLS on sources_followers
30 | alter table "public"."sources_followers" enable row level security;
31 |
32 | -- Create RLS policies for sources_followers
33 | create policy "Sources followers are viewable by everyone"
34 | on sources_followers for select using ( true );
35 |
36 | create policy "Users can manage their own followers"
37 | on sources_followers for all
38 | using ( auth.uid() = source_id );
--------------------------------------------------------------------------------
/supabase/migrations/011_share_events.sql:
--------------------------------------------------------------------------------
1 | -- Drop existing table if it exists
2 | drop table if exists public.share_events;
3 |
4 | -- Create the share_events table
5 | create table if not exists public.share_events (
6 | id uuid default gen_random_uuid() primary key,
7 | source_id uuid references public.sources(id) on delete cascade,
8 | platform text not null,
9 | shared_at timestamp with time zone default timezone('utc'::text, now()),
10 | success boolean default true,
11 | created_at timestamp with time zone default timezone('utc'::text, now())
12 | );
13 |
14 | -- Enable RLS
15 | alter table public.share_events enable row level security;
16 |
17 | -- Create RLS policies
18 | create policy "Users can view their own share events"
19 | on share_events for select
20 | using (auth.uid() = source_id);
21 |
22 | create policy "Users can create their own share events"
23 | on share_events for insert
24 | with check (auth.uid() = source_id);
25 |
26 | -- Create index for better query performance
27 | create index if not exists idx_share_events_source_id
28 | on public.share_events(source_id);
29 |
30 | -- Grant access to authenticated users
31 | grant insert, select on public.share_events to authenticated;
--------------------------------------------------------------------------------
/supabase/migrations/012_create_import_jobs.sql:
--------------------------------------------------------------------------------
1 | -- Create import_jobs table
2 | create table if not exists public.import_jobs (
3 | id uuid default uuid_generate_v4() primary key,
4 | user_id uuid references "next-auth"."users"(id),
5 | status text check (status in ('pending', 'processing', 'completed', 'failed')),
6 | total_items integer default 0,
7 | error_log text,
8 | job_type text check (job_type in ('large_file_import', 'direct_import')),
9 | file_paths text[],
10 | stats jsonb default '{"followers": 0, "following": 0, "total": 0, "processed": 0}'::jsonb,
11 | created_at timestamp with time zone default now(),
12 | updated_at timestamp with time zone default now(),
13 | started_at timestamp with time zone,
14 | completed_at timestamp with time zone
15 | );
16 |
17 | -- Add indexes
18 | create index if not exists import_jobs_status_idx on import_jobs(status);
19 | create index if not exists import_jobs_user_id_idx on import_jobs(user_id);
20 | create index if not exists import_jobs_created_at_idx on import_jobs(created_at);
21 |
22 | -- Create the function to claim the next pending job
23 | CREATE OR REPLACE FUNCTION claim_next_pending_job(worker_id_input TEXT)
24 | RETURNS SETOF import_jobs AS $$
25 | BEGIN
26 | RETURN QUERY
27 | WITH next_job AS (
28 | UPDATE import_jobs
29 | SET
30 | status = 'processing',
31 | started_at = NOW(),
32 | updated_at = NOW()
33 | WHERE id = (
34 | SELECT id
35 | FROM import_jobs
36 | WHERE status = 'pending'
37 | ORDER BY created_at ASC
38 | LIMIT 1
39 | FOR UPDATE SKIP LOCKED
40 | )
41 | RETURNING *
42 | )
43 | SELECT * FROM next_job;
44 | END;
45 | $$ LANGUAGE plpgsql;
46 |
47 | -- Enable RLS but allow service role full access
48 | alter table import_jobs enable row level security;
49 |
50 | create policy "Service role can manage import jobs"
51 | on import_jobs for all
52 | using (true)
53 | with check (true);
54 |
55 | -- Add policy for users to view their own jobs
56 | create policy "Users can view their own import jobs"
57 | on import_jobs for select
58 | using (auth.uid() = user_id);
59 |
60 | CREATE POLICY "Users can insert their own import jobs"
61 | ON import_jobs FOR INSERT
62 | TO authenticated
63 | WITH CHECK (auth.uid() = user_id);
64 |
65 | CREATE POLICY "Users can update their own import jobs"
66 | ON import_jobs FOR UPDATE
67 | TO authenticated
68 | USING (auth.uid() = user_id);
--------------------------------------------------------------------------------
/supabase/migrations/013_create_mastodon_tables.sql:
--------------------------------------------------------------------------------
1 | create table "public"."mastodon_instances" (
2 | "id" uuid not null default uuid_generate_v4(),
3 | "instance" text not null,
4 | "client_id" text not null,
5 | "client_secret" text not null,
6 | "created_at" timestamptz not null default now(),
7 | "updated_at" timestamptz not null default now(),
8 | primary key ("id")
9 | );
10 |
11 | create trigger update_mastodon_instances_updated_at
12 | before update on "public"."mastodon_instances"
13 | for each row
14 | execute procedure update_updated_at_column();
15 |
--------------------------------------------------------------------------------
/supabase/migrations/014_create_bluesky_mapping.sql:
--------------------------------------------------------------------------------
1 | -- Create bluesky_mappings table to store Twitter to Bluesky handle mappings
2 | create table if not exists "public"."bluesky_mappings" (
3 | "twitter_id" text primary key,
4 | "bluesky_handle" text not null,
5 | "imported_at" timestamp with time zone default timezone('utc'::text, now()) not null
6 | );
7 |
8 | -- Enable RLS on bluesky_mappings
9 | alter table "public"."bluesky_mappings" enable row level security;
10 |
11 | -- Create RLS policies for bluesky_mappings
12 | create policy "Bluesky mappings are viewable by everyone"
13 | on bluesky_mappings for select using ( true );
14 |
15 | create policy "Authenticated users can create bluesky mappings"
16 | on bluesky_mappings for insert
17 | with check ( auth.uid() in (select id from public.sources) );
18 |
19 | -- Add indexes for better query performance
20 | create index if not exists idx_bluesky_mappings_twitter_id
21 | on public.bluesky_mappings(twitter_id);
22 | create index if not exists idx_bluesky_mappings_bluesky_handle
23 | on public.bluesky_mappings(bluesky_handle);
--------------------------------------------------------------------------------
/supabase/migrations/017_update_sources.sql:
--------------------------------------------------------------------------------
1 | -- Add columns to sources_targets
2 | ALTER TABLE sources_targets
3 | ADD COLUMN IF NOT EXISTS bluesky_handle text,
4 | ADD COLUMN IF NOT EXISTS has_follow_bluesky boolean DEFAULT false,
5 | ADD COLUMN IF NOT EXISTS followed_at_bluesky timestamp with time zone;
6 |
7 | -- Create function for updating sources_targets
8 | CREATE OR REPLACE FUNCTION update_sources_targets_handle() RETURNS TRIGGER AS $$
9 | BEGIN
10 | -- Update sources_targets when a new mapping is inserted or updated
11 | UPDATE sources_targets
12 | SET bluesky_handle = NEW.bluesky_handle
13 | WHERE target_twitter_id = NEW.twitter_id
14 | AND (bluesky_handle IS NULL OR bluesky_handle != NEW.bluesky_handle);
15 |
16 | RETURN NEW;
17 | END;
18 | $$ LANGUAGE plpgsql;
19 |
20 | -- Create or replace trigger
21 | DROP TRIGGER IF EXISTS trigger_update_sources_targets ON bluesky_mappings;
22 | CREATE TRIGGER trigger_update_sources_targets
23 | AFTER INSERT OR UPDATE OF bluesky_handle
24 | ON bluesky_mappings
25 | FOR EACH ROW
26 | EXECUTE FUNCTION update_sources_targets_handle();
27 |
28 | -- Initial sync of existing data
29 | WITH to_update AS (
30 | SELECT DISTINCT ON (st.target_twitter_id)
31 | st.target_twitter_id,
32 | bm.bluesky_handle
33 | FROM sources_targets st
34 | JOIN bluesky_mappings bm ON st.target_twitter_id = bm.twitter_id
35 | WHERE st.bluesky_handle IS NULL OR st.bluesky_handle != bm.bluesky_handle
36 | )
37 | UPDATE sources_targets st
38 | SET bluesky_handle = tu.bluesky_handle
39 | FROM to_update tu
40 | WHERE st.target_twitter_id = tu.target_twitter_id;
--------------------------------------------------------------------------------
/supabase/migrations/018_create_view_migration_bluesky.sql:
--------------------------------------------------------------------------------
1 | -- Créer la vue qui combine followers et following avec leurs handles Bluesky
2 | CREATE OR REPLACE VIEW migration_bluesky_view AS
3 | SELECT
4 | st.source_id as user_id,
5 | st.target_twitter_id as twitter_id,
6 | COALESCE(bm.bluesky_handle, st.bluesky_handle) as bluesky_handle,
7 | st.has_follow_bluesky,
8 | 'following' as relationship_type
9 | FROM sources_targets st
10 | LEFT JOIN bluesky_mappings bm ON st.target_twitter_id = bm.twitter_id;
11 |
12 | -- Créer un index sur user_id pour améliorer les performances
13 | CREATE INDEX IF NOT EXISTS idx_migration_bluesky_view_user_id_targets
14 | ON sources_targets(source_id);
15 |
16 | -- Ajouter des commentaires sur la vue
17 | COMMENT ON VIEW migration_bluesky_view IS 'Vue des following avec leurs handles Bluesky pour la migration';
18 | COMMENT ON COLUMN migration_bluesky_view.user_id IS 'ID de l''utilisateur';
19 | COMMENT ON COLUMN migration_bluesky_view.twitter_id IS 'ID Twitter du following';
20 | COMMENT ON COLUMN migration_bluesky_view.bluesky_handle IS 'Handle Bluesky s''il existe';
21 | COMMENT ON COLUMN migration_bluesky_view.has_follow_bluesky IS 'Si l''utilisateur suit déjà ce compte sur Bluesky';
22 | COMMENT ON COLUMN migration_bluesky_view.relationship_type IS 'Type de relation (following)';
--------------------------------------------------------------------------------
/supabase/migrations/020_update_targets_with_mastodon.sql:
--------------------------------------------------------------------------------
1 |
2 | -- Migration pour ajouter le support Mastodon dans la table sources_targets
3 |
4 | -- Ajouter les nouvelles colonnes pour Mastodon
5 | ALTER TABLE "public"."sources_targets"
6 | ADD COLUMN IF NOT EXISTS "mastodon_id" text,
7 | ADD COLUMN IF NOT EXISTS "mastodon_username" text,
8 | ADD COLUMN IF NOT EXISTS "mastodon_instance" text,
9 | ADD COLUMN IF NOT EXISTS "has_follow_mastodon" boolean DEFAULT false,
10 | ADD COLUMN IF NOT EXISTS "followed_at_mastodon" timestamp with time zone;
11 |
12 | -- Créer des index pour optimiser les recherches
13 | CREATE INDEX IF NOT EXISTS idx_sources_targets_mastodon_id
14 | ON "public"."sources_targets"(mastodon_id);
15 |
16 | CREATE INDEX IF NOT EXISTS idx_sources_targets_mastodon_username
17 | ON "public"."sources_targets"(mastodon_username);
18 |
19 | CREATE INDEX IF NOT EXISTS idx_sources_targets_mastodon_instance
20 | ON "public"."sources_targets"(mastodon_instance);
21 |
22 | -- Mettre à jour les données Mastodon depuis twitter_mastodon_users
23 | UPDATE "public"."sources_targets" st
24 | SET
25 | mastodon_id = tmu.mastodon_id,
26 | mastodon_username = tmu.mastodon_username,
27 | mastodon_instance = tmu.mastodon_instance
28 | FROM "public"."twitter_mastodon_users" tmu
29 | WHERE st.target_twitter_id = tmu.twitter_id
30 | AND (st.mastodon_id IS NULL OR st.mastodon_username IS NULL OR st.mastodon_instance IS NULL);
--------------------------------------------------------------------------------
/supabase/migrations/021_create_reconnect_queue.sql:
--------------------------------------------------------------------------------
1 | create table reconnect_queue (
2 | id bigint generated by default as identity primary key,
3 | user_id uuid references next-auth.users not null,
4 | accounts jsonb not null,
5 | created_at timestamp with time zone default timezone('utc'::text, now()) not null,
6 | updated_at timestamp with time zone default timezone('utc'::text, now()) not null,
7 | status text default 'pending' not null
8 | );
9 |
10 | create index idx_reconnect_queue_user_id on reconnect_queue(user_id);
--------------------------------------------------------------------------------
/supabase/migrations/023_create_functions_for_stats.sql:
--------------------------------------------------------------------------------
1 | -- Fonction pour compter les followers
2 | CREATE OR REPLACE FUNCTION count_followers()
3 | RETURNS TABLE (count bigint)
4 | LANGUAGE sql
5 | STABLE -- Indique que la fonction ne modifie pas la BD
6 | PARALLEL SAFE -- Peut s'exécuter en parallèle
7 | AS $$
8 | SELECT reltuples::bigint as count
9 | FROM pg_class
10 | WHERE relname = 'sources_followers';
11 | $$;
12 |
13 | -- Fonction pour compter les targets (following)
14 | CREATE OR REPLACE FUNCTION count_targets()
15 | RETURNS TABLE (count bigint)
16 | LANGUAGE sql
17 | STABLE
18 | PARALLEL SAFE
19 | AS $$
20 | SELECT reltuples::bigint as count
21 | FROM pg_class
22 | WHERE relname = 'sources_targets';
23 | $$;
24 |
25 | -- Fonction pour compter les targets avec handle
26 | CREATE OR REPLACE FUNCTION count_targets_with_handle()
27 | RETURNS TABLE (count bigint)
28 | LANGUAGE sql
29 | STABLE
30 | PARALLEL SAFE
31 | AS $$
32 | SELECT (
33 | reltuples::bigint * (
34 | COALESCE(
35 | (SELECT n_distinct
36 | FROM pg_stats
37 | WHERE tablename = 'sources_targets'
38 | AND attname IN ('bluesky_handle', 'mastodon_id')
39 | AND n_distinct > 0
40 | LIMIT 1
41 | ), 0.1)
42 | )
43 | )::bigint as count
44 | FROM pg_class
45 | WHERE relname = 'sources_targets';
46 | $$;
47 |
48 | -- Accorder les permissions d'exécution
49 | GRANT EXECUTE ON FUNCTION count_followers() TO authenticated, service_role;
50 | GRANT EXECUTE ON FUNCTION count_targets() TO authenticated, service_role;
51 | GRANT EXECUTE ON FUNCTION count_targets_with_handle() TO authenticated, service_role;
52 |
53 | -- Ajouter un ANALYZE pour mettre à jour les statistiques
54 | ANALYZE sources_followers;
55 | ANALYZE sources_targets;
--------------------------------------------------------------------------------
/supabase/migrations_backup/001_create_schemas.sql:
--------------------------------------------------------------------------------
1 | create schema if not exists "next-auth";
2 | create extension if not exists "uuid-ossp";
--------------------------------------------------------------------------------
/supabase/migrations_backup/20241217191141_create_schemas.sql:
--------------------------------------------------------------------------------
1 | create schema if not exists "next-auth";
2 | create extension if not exists "uuid-ossp";
--------------------------------------------------------------------------------
/supabase/migrations_backup/20241217191212_create_users_table.sql:
--------------------------------------------------------------------------------
1 | create table if not exists "next-auth"."users" (
2 | "id" uuid not null default uuid_generate_v4(),
3 | "name" text,
4 | "twitter_id" text unique,
5 | "twitter_username" text unique,
6 | "twitter_image" text unique,
7 | "bluesky_id" text unique,
8 | "bluesky_username" text unique,
9 | "bluesky_image" text unique,
10 | "mastodon_id" text unique,
11 | "mastodon_username" text unique,
12 | "mastodon_image" text unique,
13 | "mastodon_instance" text,
14 | "email" text unique,
15 | "email_verified" timestamptz,
16 | "image" text,
17 | "created_at" timestamptz not null default now(),
18 | "updated_at" timestamptz not null default now(),
19 | "has_onboarded" boolean not null default false,
20 | primary key ("id"),
21 | unique("email")
22 | );
23 |
24 | -- Create a trigger to automatically update the updated_at column
25 | create or replace function update_updated_at_column()
26 | returns trigger as $$
27 | begin
28 | new.updated_at = now();
29 | return new;
30 | end;
31 | $$ language 'plpgsql';
32 |
33 | create trigger update_users_updated_at
34 | before update on "next-auth"."users"
35 | for each row
36 | execute procedure update_updated_at_column();
--------------------------------------------------------------------------------
/supabase/migrations_backup/20241217191213_create_accounts_table.sql:
--------------------------------------------------------------------------------
1 | create table "next-auth"."accounts" (
2 | "id" uuid not null default uuid_generate_v4(),
3 | "user_id" uuid not null,
4 | "type" text not null,
5 | "provider" text not null,
6 | "provider_account_id" text not null,
7 | "refresh_token" text,
8 | "access_token" text,
9 | "expires_at" bigint,
10 | "token_type" text,
11 | "scope" text,
12 | "id_token" text,
13 | "session_state" text,
14 | "created_at" timestamptz not null default now(),
15 | "updated_at" timestamptz not null default now(),
16 | primary key ("id"),
17 | foreign key ("user_id") references "next-auth"."users"("id") on delete cascade,
18 | unique("provider", "provider_account_id")
19 | );
20 |
21 | create trigger update_accounts_updated_at
22 | before update on "next-auth"."accounts"
23 | for each row
24 | execute procedure update_updated_at_column();
--------------------------------------------------------------------------------
/supabase/migrations_backup/20241217191214_create_sessions_table.sql:
--------------------------------------------------------------------------------
1 | create table "next-auth"."sessions" (
2 | "id" uuid not null default uuid_generate_v4(),
3 | "user_id" uuid not null,
4 | "expires" timestamptz not null,
5 | "session_token" text not null,
6 | "created_at" timestamptz not null default now(),
7 | "updated_at" timestamptz not null default now(),
8 | primary key ("id"),
9 | foreign key ("user_id") references "next-auth"."users"("id") on delete cascade,
10 | unique("session_token")
11 | );
12 |
13 | create trigger update_sessions_updated_at
14 | before update on "next-auth"."sessions"
15 | for each row
16 | execute procedure update_updated_at_column();
--------------------------------------------------------------------------------
/supabase/migrations_backup/20241217191215_create_verification_tokens_table.sql:
--------------------------------------------------------------------------------
1 | create table "next-auth"."verification_tokens" (
2 | "identifier" text not null,
3 | "token" text not null,
4 | "expires" timestamptz not null,
5 | "created_at" timestamptz not null default now(),
6 | primary key ("identifier", "token")
7 | );
--------------------------------------------------------------------------------
/supabase/migrations_backup/20241217191216_create_user_data_table.sql:
--------------------------------------------------------------------------------
1 | -- Grant permissions to the service_role
2 | grant usage on schema "next-auth" to service_role;
3 | grant all privileges on all tables in schema "next-auth" to service_role;
4 | grant all privileges on all sequences in schema "next-auth" to service_role;
5 |
6 | -- Grant permissions to the authenticated role
7 | grant usage on schema "next-auth" to authenticated;
8 | grant all privileges on all tables in schema "next-auth" to authenticated;
9 | grant all privileges on all sequences in schema "next-auth" to authenticated;
10 |
11 | -- Grant permissions to the anon role
12 | grant usage on schema "next-auth" to anon;
13 | grant all privileges on all tables in schema "next-auth" to anon;
14 |
15 | -- Grant specific table permissions
16 | grant all privileges on table "next-auth".users to service_role, authenticated, anon;
17 | grant all privileges on table "next-auth".accounts to service_role, authenticated, anon;
18 | grant all privileges on table "next-auth".sessions to service_role, authenticated, anon;
19 | grant all privileges on table "next-auth".verification_tokens to service_role, authenticated, anon;
20 |
21 | create or replace function "next-auth".verify_twitter_token(token_param text)
22 | returns table (
23 | user_id text,
24 | user_name text,
25 | user_email text,
26 | provider text,
27 | provider_account_id text
28 | )
29 | security definer
30 | set search_path = public, "next-auth"
31 | as $$
32 | begin
33 | return query
34 | select
35 | u.id::text as user_id,
36 | u.name as user_name,
37 | u.email as user_email,
38 | a.provider,
39 | a.provider_account_id
40 | from "next-auth".accounts a
41 | join "next-auth".users u on u.id = a.user_id::uuid
42 | where a.provider = 'twitter'
43 | and a.access_token = token_param
44 | and a.expires_at > extract(epoch from now())::bigint;
45 | end;
46 | $$ language plpgsql;
47 |
48 |
49 |
50 | -- Grant execute permission to authenticated users
51 | grant execute on function "next-auth".verify_twitter_token(text) to authenticated;
52 | grant execute on function "next-auth".verify_twitter_token(text) to anon;
53 | grant execute on function "next-auth".verify_twitter_token(text) to service_role;
54 |
55 | create table "public"."user_data" (
56 | "id" uuid not null default uuid_generate_v4(),
57 | "user_id" uuid not null,
58 | "created_at" timestamptz not null default now(),
59 | "updated_at" timestamptz not null default now(),
60 | primary key ("id")
61 | );
--------------------------------------------------------------------------------
/supabase/migrations_backup/20241217191217_create_row_level_security.sql:
--------------------------------------------------------------------------------
1 | -- Enable RLS on public.user_data
2 | alter table "public"."user_data" enable row level security;
3 |
4 | -- Create policy for user_data table
5 | create policy "Users can view and edit their own data"
6 | on "public"."user_data"
7 | for all
8 | using (auth.uid() = user_id)
9 | with check (auth.uid() = user_id);
--------------------------------------------------------------------------------
/supabase/migrations_backup/20241217191221_share_events.sql:
--------------------------------------------------------------------------------
1 | -- Drop existing table if it exists
2 | drop table if exists public.share_events;
3 |
4 | -- Create the share_events table
5 | create table if not exists public.share_events (
6 | id uuid default gen_random_uuid() primary key,
7 | source_id uuid references public.sources(id) on delete cascade,
8 | platform text not null,
9 | shared_at timestamp with time zone default timezone('utc'::text, now()),
10 | success boolean default true,
11 | created_at timestamp with time zone default timezone('utc'::text, now())
12 | );
13 |
14 | -- Enable RLS
15 | alter table public.share_events enable row level security;
16 |
17 | -- Create RLS policies
18 | create policy "Users can view their own share events"
19 | on share_events for select
20 | using (auth.uid() = source_id);
21 |
22 | create policy "Users can create their own share events"
23 | on share_events for insert
24 | with check (auth.uid() = source_id);
25 |
26 | -- Create index for better query performance
27 | create index if not exists idx_share_events_source_id
28 | on public.share_events(source_id);
29 |
30 | -- Grant access to authenticated users
31 | grant insert, select on public.share_events to authenticated;
--------------------------------------------------------------------------------
/supabase/seed.sql:
--------------------------------------------------------------------------------
1 | insert into "next-auth"."users" (
2 | id,
3 | name,
4 | email,
5 | email_verified,
6 | image,
7 | has_onboarded,
8 | twitter_id
9 | ) values (
10 | uuid_generate_v4(),
11 | 'Test User',
12 | 'test@example.com',
13 | now(),
14 | null,
15 | false,
16 | '1234567890'
17 | );
18 |
19 | insert into "public"."user_data" (user_id)
20 | select
21 | id as user_id
22 | from "next-auth"."users"
23 | where email = 'test@example.com';
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | background: "var(--background)",
13 | foreground: "var(--foreground)",
14 | },
15 | },
16 | },
17 | plugins: [],
18 | } satisfies Config;
19 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | background: "var(--background)",
13 | foreground: "var(--foreground)",
14 | },
15 | },
16 | },
17 | plugins: [],
18 | } satisfies Config;
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next.config.js", "tailwind.config.js"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/watcher/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine
2 |
3 | # Créer un utilisateur non-root
4 | RUN addgroup -g 1001 appgroup && \
5 | adduser -u 1001 -G appgroup -s /bin/sh -D appuser
6 |
7 | # Définir le répertoire de travail
8 | WORKDIR /app
9 |
10 | RUN npm install -g npm@11.2.0
11 |
12 | # Installer les dépendances système nécessaires (en tant que root)
13 | RUN apk add --no-cache python3 make g++ gcc
14 |
15 | # Copier les fichiers de dépendances avec les bonnes permissions
16 | COPY --chown=appuser:appgroup package*.json ./
17 |
18 | # Donner les permissions à l'utilisateur non-root
19 | RUN chown -R appuser:appgroup /app
20 |
21 | # Passer à l'utilisateur non-root pour les installations npm
22 | USER appuser
23 |
24 |
25 | # Installer les dépendances npm
26 | RUN npm install
27 |
28 | # Copier le reste des fichiers avec les bonnes permissions
29 | COPY --chown=appuser:appgroup . .
30 |
31 | # Build de l'application
32 | RUN npm run build
33 |
34 | # Exposer le port si nécessaire
35 | EXPOSE 3000
36 |
37 | CMD ["npm", "start"]
--------------------------------------------------------------------------------
/watcher/monitor.log:
--------------------------------------------------------------------------------
1 | {"level":"info","message":"Rapport envoyé"}
2 | {"level":"info","message":"Rapport envoyé"}
3 | {"level":"info","message":"Monitoring started"}
4 | {"level":"info","message":"Rapport envoyé"}
5 | {"level":"info","message":"Rapport envoyé"}
6 | {"level":"info","message":"Monitoring started"}
7 | {"level":"info","message":"Rapport envoyé"}
8 | {"level":"info","message":"Rapport envoyé"}
9 | {"level":"info","message":"Monitoring started"}
10 | {"level":"info","message":"Rapport envoyé"}
11 | {"level":"info","message":"Rapport envoyé"}
12 | {"level":"info","message":"Monitoring started"}
13 | {"level":"info","message":"Rapport envoyé"}
14 | {"level":"info","message":"Rapport envoyé"}
15 | {"level":"info","message":"Monitoring started"}
16 | {"level":"info","message":"Rapport envoyé"}
17 | {"level":"info","message":"Rapport envoyé"}
18 | {"level":"info","message":"Monitoring started"}
19 | {"level":"info","message":"Rapport envoyé"}
20 | {"level":"info","message":"Rapport envoyé"}
21 | {"level":"info","message":"Monitoring started"}
22 |
--------------------------------------------------------------------------------
/watcher/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "openportability-watcher",
3 | "version": "1.0.0",
4 | "main": "dist/monitor.js",
5 | "scripts": {
6 | "build": "tsc",
7 | "start": "node dist/monitor.js",
8 | "dev": "ts-node src/monitor.ts"
9 | },
10 | "dependencies": {
11 | "@supabase/supabase-js": "^2.39.0",
12 | "node-telegram-bot-api": "^0.64.0",
13 | "dotenv": "^16.3.1",
14 | "winston": "^3.11.0"
15 | },
16 | "devDependencies": {
17 | "@types/node-telegram-bot-api": "^0.64.0",
18 | "@types/node": "^20.10.5",
19 | "typescript": "^5.3.3",
20 | "ts-node": "^10.9.2"
21 | }
22 | }
--------------------------------------------------------------------------------
/watcher/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2018",
4 | "module": "commonjs",
5 | "outDir": "./dist",
6 | "rootDir": "./src",
7 | "strict": true,
8 | "esModuleInterop": true,
9 | "skipLibCheck": true,
10 | "forceConsistentCasingInFileNames": true
11 | },
12 | "include": ["src/**/*"],
13 | "exclude": ["node_modules"]
14 | }
15 |
--------------------------------------------------------------------------------
/worker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine
2 |
3 | # Créer un utilisateur non-root
4 | RUN addgroup -g 1001 appgroup && \
5 | adduser -u 1001 -G appgroup -s /bin/sh -D appuser
6 |
7 | # Installation de bash
8 | RUN apk add --no-cache bash
9 |
10 | RUN npm install -g npm@11.2.0
11 |
12 | # Définir le répertoire de travail
13 | WORKDIR /app
14 |
15 | # Copier les fichiers avec les bonnes permissions
16 | COPY --chown=appuser:appgroup package*.json ./
17 | COPY --chown=appuser:appgroup . .
18 |
19 | # Donner les permissions à l'utilisateur non-root
20 | RUN chown -R appuser:appgroup /app
21 |
22 | # Passer à l'utilisateur non-root pour l'installation et le build
23 | USER appuser
24 |
25 |
26 | RUN npm install
27 | RUN npm run build
28 |
29 | # Rendre le script executable
30 | RUN chmod +x start-workers.sh
31 |
32 | ENV NODE_ENV=production
33 |
34 | CMD ["/bin/bash", "./start-workers.sh"]
--------------------------------------------------------------------------------
/worker/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | FROM node:18-alpine
2 | WORKDIR /app
3 |
4 | # Install bash
5 | RUN apk add --no-cache bash
6 |
7 | COPY package*.json ./
8 | COPY . .
9 |
10 | RUN npm install
11 | RUN chmod +x start-workers.sh
12 |
13 | ENV NODE_ENV=development
14 |
15 | CMD ["/bin/bash", "./start-workers.sh"]
--------------------------------------------------------------------------------
/worker/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "import-worker",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "scripts": {
6 | "build": "tsc",
7 | "start": "npx ts-node src/index.ts",
8 | "start:workers": "./start-workers.sh",
9 | "dev": "npx ts-node src/index.ts"
10 | },
11 | "dependencies": {
12 | "@supabase/supabase-js": "^2.49.4",
13 | "dotenv": "^16.5.0",
14 | "date-fns": "^2.30.0"
15 |
16 | },
17 | "devDependencies": {
18 | "@types/node": "^18.19.87",
19 | "ts-node": "^10.9.2",
20 | "typescript": "^4.9.5"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/worker/src/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Utility function to sleep for a specified number of milliseconds
3 | * @param ms Number of milliseconds to sleep
4 | * @returns Promise that resolves after the specified time
5 | */
6 | export function sleep(ms: number): Promise {
7 | return new Promise(resolve => setTimeout(resolve, ms));
8 | }
--------------------------------------------------------------------------------
/worker/start-workers.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Arrays to store PIDs
4 | declare -a FOLLOWER_PIDS=()
5 | declare -a FOLLOWING_PIDS=()
6 |
7 | # Workers pour les followers
8 | export JOB_TYPES=followers
9 |
10 | for i in {1..10}; do
11 | export WORKER_ID=worker${i}_followers
12 | npx ts-node src/index.ts &
13 | FOLLOWER_PIDS+=($!)
14 | echo "Started follower worker $i with PID ${FOLLOWER_PIDS[-1]}"
15 | done
16 |
17 | # Workers pour les following
18 | export JOB_TYPES=following
19 |
20 | for i in {1..10}; do
21 | export WORKER_ID=worker${i}_following
22 | npx ts-node src/index.ts &
23 | FOLLOWING_PIDS+=($!)
24 | echo "Started following worker $i with PID ${FOLLOWING_PIDS[-1]}"
25 | done
26 |
27 | # Attendre que tous les workers se terminent
28 | echo "Waiting for follower workers..."
29 | for pid in "${FOLLOWER_PIDS[@]}"; do
30 | wait $pid
31 | done
32 |
33 | echo "Waiting for following workers..."
34 | for pid in "${FOLLOWING_PIDS[@]}"; do
35 | wait $pid
36 | done
37 |
38 | echo "All workers completed"
--------------------------------------------------------------------------------
/worker/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "commonjs",
5 | "outDir": "./dist",
6 | "rootDir": "./src",
7 | "strict": true,
8 | "esModuleInterop": true,
9 | "skipLibCheck": true,
10 | "forceConsistentCasingInFileNames": true
11 | },
12 | "include": ["src/**/*"]
13 | }
--------------------------------------------------------------------------------
/worker_refreshtoken/Dockerfile:
--------------------------------------------------------------------------------
1 | # Étape de build
2 | FROM node:18-alpine AS builder
3 |
4 | WORKDIR /app
5 |
6 | # Copier les fichiers de dépendances
7 | COPY package*.json ./
8 |
9 | # Installer les dépendances
10 | RUN npm install
11 |
12 | # Copier le reste des fichiers
13 | COPY . .
14 |
15 | # Compiler le TypeScript
16 | RUN npm run build
17 |
18 | # Étape de production
19 | FROM node:18-alpine
20 |
21 | WORKDIR /app
22 |
23 | # Copier uniquement les fichiers nécessaires depuis l'étape de build
24 | COPY --from=builder /app/package*.json ./
25 | COPY --from=builder /app/dist ./dist
26 |
27 | # Installer uniquement les dépendances de production
28 | RUN npm install --only=production
29 |
30 | # Démarrer le worker
31 | CMD ["npm", "start"]
--------------------------------------------------------------------------------
/worker_refreshtoken/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "token-refresh-worker",
3 | "version": "1.0.0",
4 | "description": "Worker pour rafraîchir les tokens Bluesky",
5 | "main": "dist/index.js",
6 | "scripts": {
7 | "dev": "ts-node src/index.ts",
8 | "build": "tsc",
9 | "start": "node dist/index.js"
10 | },
11 | "dependencies": {
12 | "@atproto/api": "^0.6.23",
13 | "@supabase/supabase-js": "^2.39.0",
14 | "dotenv": "^16.3.1"
15 | },
16 | "devDependencies": {
17 | "@types/node": "^20.10.4",
18 | "ts-node": "^10.9.1",
19 | "typescript": "^5.3.3"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/worker_refreshtoken/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2018",
4 | "module": "commonjs",
5 | "outDir": "./dist",
6 | "rootDir": "./src",
7 | "strict": true,
8 | "esModuleInterop": true,
9 | "skipLibCheck": true,
10 | "forceConsistentCasingInFileNames": true
11 | },
12 | "include": ["src/**/*"],
13 | "exclude": ["node_modules"]
14 | }
--------------------------------------------------------------------------------