Compare commits

..

No commits in common. "main" and "v2.6.3" have entirely different histories.
main ... v2.6.3

645 changed files with 25518 additions and 54725 deletions

View file

@ -40,7 +40,6 @@ STORAGE_PROVIDER="local"
#NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY=""
# Social Media API Settings
X_URL=""
X_API_KEY=""
X_API_SECRET=""
LINKEDIN_CLIENT_ID=""
@ -77,9 +76,6 @@ MASTODON_URL="https://mastodon.social"
MASTODON_CLIENT_ID=""
MASTODON_CLIENT_SECRET=""
# Chrome Extension Settings (for cookie-based platform integrations like Skool)
EXTENSION_ID=""
# Misc Settings
OPENAI_API_KEY=""
NEXT_PUBLIC_DISCORD_SUPPORT=""

View file

@ -0,0 +1,19 @@
name: "🙏🏻 Installation Problem"
description: "Report an issue with installation"
title: "Installation Problem"
labels: ["type: installation"]
body:
- type: markdown
attributes:
value: For installation issues, please visit our [Discord Support](https://discord.postiz.com) for assistance.
- type: textarea
id: feature-description
validations:
required: true
attributes:
label: For installation issues, please visit our https://discord.postiz.com for assistance.
description: For installation issues, please visit our [Discord Support](https://discord.postiz.com) for assistance.
placeholder: |
For installation issues, please visit our https://discord.postiz.com for assistance.
Please do not save this issue - do not submit installation issues on GitHub.

View file

@ -1,14 +0,0 @@
# Disable the default option to open a blank issue
blank_issues_enabled: true
# Define your custom links
contact_links:
# The first link definition
- name: 🙏 Installation Issue
url: https://discord.postiz.com
about: If you have an installation / configuration issue.
# You can add more links if needed
- name: Security Issue
url: https://github.com/gitroomhq/postiz-app/security/advisories/new
about: Please submit security Issues our GitHub Security Advisories.

View file

@ -1,5 +1,3 @@
<!-- Remember to first apply via [the contribution form](https://contribute.postiz.com/p/postiz) before submitting a PR. -->
# What kind of change does this PR introduce?
eg: Bug fix, feature, docs update, ...
@ -17,6 +15,5 @@ eg: Did you discuss this change with anybody before working on it (not required,
Put a "X" in the boxes below to indicate you have followed the checklist;
- [ ] I have read the [CONTRIBUTING](https://github.com/gitroomhq/postiz-app/blob/main/CONTRIBUTING.md) guide.
- [ ] I confirm I have not used AI to submit this PR or generate code for it.
- [ ] I checked that there were no similar issues or PRs already open for this.
- [ ] This PR fixes just ONE issue
- [ ] I checked that there were not similar issues or PRs already open for this.
- [ ] This PR fixes just ONE issue (do not include multiple issues or types of change in the same PR) For example, don't try and fix a UI issue and include new dependencies in the same PR.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

View file

@ -53,8 +53,6 @@ jobs:
-f Dockerfile.dev \
-t ghcr.io/gitroomhq/postiz-app:${{ env.CONTAINERVER }}-${{ matrix.arch }} \
--build-arg NEXT_PUBLIC_VERSION=${{ env.NEXT_PUBLIC_VERSION }} \
--pull \
--no-cache \
--provenance=false --sbom=false \
--output "type=registry,name=ghcr.io/gitroomhq/postiz-app:${{ env.CONTAINERVER }}-${{ matrix.arch }}" .

View file

@ -3,7 +3,6 @@ name: Build
on:
push:
merge_group:
pull_request:
jobs:
@ -13,14 +12,11 @@ jobs:
strategy:
matrix:
node-version: ['22.12.0']
node-version: ['20.17.0']
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
@ -29,7 +25,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 10
version: 8
run_install: false
- name: Get pnpm store directory
@ -37,15 +33,15 @@ jobs:
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
# - name: Setup pnpm cache
# uses: actions/cache@v4
# with:
# path: |
# ${{ env.STORE_PATH }}
# ${{ github.workspace }}/.next/cache
# key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
# restore-keys: |
# ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: |
${{ env.STORE_PATH }}
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install

View file

@ -1,16 +1,14 @@
---
name: "Code Quality Analysis"
name: "Code Quality Analysis"
on:
push:
branches:
- main
- dev1
paths:
- apps/**
- '!apps/docs/**'
- libraries/**
merge_group:
jobs:
analyze:

View file

@ -39,7 +39,7 @@ jobs:
node-version: '20'
cache: 'npm'
cache-dependency-path: |
**/pnpm-lock.yaml
**/package-lock.json
- name: Install ESLint
run: |

32
.github/workflows/pr-docker-build.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: Build and Publish PR Docker Image
on:
pull_request_target:
types: [opened, synchronize]
permissions: write-all
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set image tag
id: vars
run: echo "IMAGE_TAG=ghcr.io/gitroomhq/postiz-app-pr:${{ github.event.pull_request.number }}" >> $GITHUB_ENV
- name: Build Docker image from Dockerfile.dev
run: docker build -f Dockerfile.dev -t $IMAGE_TAG .
- name: Push Docker image to GHCR
run: docker push $IMAGE_TAG

5
.gitignore vendored
View file

@ -19,7 +19,7 @@ node_modules
.vscode/*
# IDE - VSCode
.vscode/
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
@ -58,6 +58,3 @@ Thumbs.db
.secrets/
libraries/plugins/src/plugins.ts
i18n.cache
# Generated by apps/frontend/scripts/fetch-gtm.mjs on install
apps/frontend/public/g.js

View file

@ -1,61 +0,0 @@
This project is Postiz, a tool to schedule social media and chat posts to 28+ channels.
You can add posts to the calendar, they will be added into a workflow and posted at the right time.
You can find things like:
- Schedule posts
- Calendar view
- Analytics
- Team management
- Media library
This project is a monorepo with a root only package.json of dependencies.
Made with PNPM.
We have 3 important folders
- apps/backend - this is where the API code is (NESTJS)
- apps/orchestrator - this is temporal, it's for background jobs (NESTJS) it contains all the workflows and activities
- apps/frontend - this is the code of the frontend (Vite ReactJS)
- /libraries contains a lot of services shared between backend and orchestrator and frontend components.
We are using only pnpm, don't use any other dependency manager.
Never install frontend components from npmjs, focus on writing native components.
The project uses tailwind 3, before writing any component look at:
- /apps/frontend/src/app/colors.scss
- /apps/frontend/src/app/global.scss
- /apps/frontend/tailwind.config.js
All the --color-custom* are deprecated, don't use them.
And check other components in the system before to get the right design.
When working on the backend we need to pass the 3 layers:
Controller >> Service >> Repository (no shortcuts)
In some cases we will have
Controller >> Mananger >> Service >> Repository.
Most of the server logic should be inside of libs/server.
The backend repository is mostly used to write controller, and import files from libs.server.
For the frontend follow this:
- Many of the UI components lives in /apps/frontend/src/components/ui
- Routing is in /apps/frontend/src/app
- Components are in /apps/frontend/src/components
- always use SWR to fetch stuff, and use "useFetch" hook from /libraries/helpers/src/utils/custom.fetch.tsx
When using SWR, each one have to be in a seperate hook and must comply with react-hooks/rules-of-hooks, never put eslint-disable-next-line on it.
It means that this is valid:
const useCommunity = () => {
return useSWR....
}
This is not valid:
const useCommunity = () => {
return {
communities: () => useSWR<CommunitiesListResponse>("communities", getCommunities),
providers: () => useSWR<ProvidersListResponse>("providers", getProviders),
};
}
- Linting of the project can run only from the root.
- Use only pnpm.

View file

@ -6,10 +6,6 @@ Contributions are welcome - code, docs, whatever it might be! If this is your fi
The main documentation site has a [developer guide](https://docs.postiz.com/developer-guide) . That guide provides you a good understanding of the project structure, and how to setup your development environment. Read this document after you have read that guide. This document is intended to provide you a good understanding of how to submit your first contribution.
## Apply via the contribution form
To submit your contribution, please fill out the [contribution form](https://contribute.postiz.com/p/postiz). This helps us evaluate whether your contribution is a good fit for the project. We will review your submission and get back to you as soon as possible.
## Write code with others
This is an open source project, with an open and welcoming community that is always keen to welcome new contributors. We recommend the two best ways to interact with the community are:
@ -31,11 +27,6 @@ Contributions can include:
- **Feature requests:** Suggesting new capabilities or integrations.
- **Bug reports:** Identifying and reporting issues.
## AI
To ensure the quality and maintainability of the codebase, **we do not accept Pull Requests generated primarily by AI tools** (e.g., ChatGPT, GitHub Copilot, Claude Code, etc.).
All contributions must be the original work of the author. We reserve the right to close any PR that appears to be AI-generated without further review.
## How to contribute
This project follows a Fork/Feature Branch/Pull Request model. If you're not familiar with this, here's how it works:

View file

@ -1,18 +1,11 @@
FROM node:22.20-bookworm-slim
FROM node:20-alpine3.19
ARG NEXT_PUBLIC_VERSION
ENV NEXT_PUBLIC_VERSION=$NEXT_PUBLIC_VERSION
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
make \
python3-pip \
bash \
nginx \
&& rm -rf /var/lib/apt/lists/*
RUN addgroup --system www \
&& adduser --system --ingroup www --home /www --shell /usr/sbin/nologin www \
&& mkdir -p /www \
&& chown -R www:www /www /var/lib/nginx
RUN apk add --no-cache g++ make py3-pip bash nginx
RUN adduser -D -g 'www' www
RUN mkdir /www
RUN chown -R www:www /var/lib/nginx
RUN chown -R www:www /www
RUN npm --no-update-notifier --no-fund --global install pnpm@10.6.1 pm2

View file

@ -1,96 +0,0 @@
// Declarative Pipeline for building Node.js application and running SonarQube analysis triggered by a push event.
pipeline {
// Defines the execution environment. Using 'agent any' to ensure an agent is available.
agent any
// Global environment block removed to prevent Groovy scoping issues with manual path calculation.
stages {
// Stage 1: Checkout the code (Relies on the initial SCM checkout done by Jenkins)
stage('Source Checkout') {
steps {
echo "Workspace already populated by the initial SCM checkout. Proceeding."
}
}
// Stage 2: Setup Node.js v20 and install pnpm
stage('Setup Environment and Tools') {
steps {
sh '''
echo "Ensuring required utilities and Node.js are installed..."
sudo apt-get update
sudo apt-get install -y curl unzip nodejs
# 1. Install Node.js v20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
echo "Node.js version: \$(node -v)"
# 2. Install pnpm globally (version 8)
npm install -g pnpm@8
echo "pnpm version: \$(pnpm -v)"
'''
}
}
// Stage 3: Install dependencies and build the application
stage('Install and Build') {
steps {
sh 'pnpm install'
sh 'pnpm run build'
}
}
// Stage 4: Run SonarQube analysis: Install scanner, get version, and execute.
stage('SonarQube Analysis') {
steps {
script {
// 1. Get the short 8-character commit SHA for project versioning
def commitShaShort = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
echo "Commit SHA (short) is: ${commitShaShort}"
// --- 2. MANUALLY INSTALL THE SONAR SCANNER CLI LOCALLY IN THIS STAGE ---
sh """
echo "Manually downloading and installing Sonar Scanner CLI..."
# Download the stable scanner CLI package
curl -sS -o sonar-scanner.zip \
"https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.7.0.2747.zip"
# Added -o flag to force overwrite and prevent interactive prompt failure
unzip -o -q sonar-scanner.zip -d .
"""
// 3. Find the extracted directory name and capture the full absolute bin path in Groovy
// This is defined locally and used directly, avoiding environment variable issues.
def scannerBinPath = sh(
returnStdout: true,
script: '''
SCANNER_DIR=$(find . -maxdepth 1 -type d -name "sonar-scanner*" | head -n 1)
# Get the full absolute path to the executable file
echo \$(pwd)/\${SCANNER_DIR}/bin/sonar-scanner
'''
).trim()
echo "Scanner executable path captured: ${scannerBinPath}"
// 4. Use withSonarQubeEnv to set up the secure variables (HOST and TOKEN)
withSonarQubeEnv(installationName: 'SonarQube-Server') {
// 5. Execute the scanner using the Groovy variable directly.
sh """
echo "Starting SonarQube Analysis for project version: ${commitShaShort}"
# Execute the full, absolute path captured in the Groovy variable.
'${scannerBinPath}' \\
-Dsonar.projectVersion=${commitShaShort} \\
-Dsonar.sources=.
# SONAR_HOST_URL and SONAR_TOKEN are automatically passed as environment variables
# by the withSonarQubeEnv block.
"""
}
}
}
}
}
}

View file

@ -1,100 +0,0 @@
// Declarative Pipeline for building Node.js application and running SonarQube analysis for a Pull Request.
pipeline {
// Defines the execution environment. Using 'agent any' to ensure an agent is available.
agent any
// Environment variables that hold PR details, provided by Jenkins Multibranch setup.
environment {
// FIX: Environment variables must be quoted or wrapped in a function call.
// We quote the 'env.CHANGE_ID' reference to fix the compilation error.
PR_KEY = "${env.CHANGE_ID}"
PR_BRANCH = "${env.CHANGE_BRANCH}"
PR_BASE = "${env.CHANGE_TARGET}"
}
stages {
// Stage 1: Checkout the code (Relies on the initial SCM checkout done by Jenkins)
stage('Source Checkout') {
steps {
echo "Workspace already populated by the initial SCM checkout. Proceeding."
}
}
// Stage 2: Setup Node.js v20, install pnpm, and install required tools (curl, unzip)
stage('Setup Environment and Tools') {
steps {
sh '''
echo "Ensuring required utilities and Node.js are installed..."
sudo apt-get update
sudo apt-get install -y curl unzip nodejs
# 1. Install Node.js v20 (closest matching the specified version '20.17.0')
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
echo "Node.js version: \$(node -v)"
# 2. Install pnpm globally (version 8)
npm install -g pnpm@8
echo "pnpm version: \$(pnpm -v)"
'''
}
}
// Stage 3: Install dependencies and build the application
stage('Install and Build') {
steps {
sh 'pnpm install'
sh 'pnpm run build'
}
}
// Stage 4: Run SonarQube PR analysis: Install scanner locally, get version, and execute.
stage('SonarQube Pull Request Analysis') {
steps {
script {
// 1. Get the short 8-character commit SHA for project versioning
def commitShaShort = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
echo "Commit SHA (short) is: ${commitShaShort}"
// --- 2. MANUALLY INSTALL THE SONAR SCANNER CLI LOCALLY ---
sh """
echo "Manually downloading and installing Sonar Scanner CLI..."
curl -sS -o sonar-scanner.zip \
"https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.7.0.2747.zip"
unzip -o -q sonar-scanner.zip -d .
"""
// 3. Find the extracted directory name and capture the full absolute executable path.
def scannerBinPath = sh(
returnStdout: true,
script: '''
SCANNER_DIR=$(find . -maxdepth 1 -type d -name "sonar-scanner*" | head -n 1)
# Get the full absolute path to the executable file
echo \$(pwd)/\${SCANNER_DIR}/bin/sonar-scanner
'''
).trim()
echo "Scanner executable path captured: ${scannerBinPath}"
// 4. Use withSonarQubeEnv to set up the secure variables (HOST and TOKEN)
withSonarQubeEnv(installationName: 'SonarQube-Server') {
// 5. Execute the scanner using the Groovy variable directly with PR parameters.
sh """
echo "Starting SonarQube Pull Request Analysis for PR #${PR_KEY}"
'${scannerBinPath}' \\
-Dsonar.projectVersion=${commitShaShort} \\
-Dsonar.sources=. \\
-Dsonar.pullrequest.key=${PR_KEY} \\
-Dsonar.pullrequest.branch=${PR_BRANCH} \\
-Dsonar.pullrequest.base=${PR_BASE}
# SONAR_HOST_URL and SONAR_TOKEN are automatically passed as environment variables
# by the withSonarQubeEnv block.
"""
}
}
}
}
}
}

70
Jenkinsfile vendored Normal file
View file

@ -0,0 +1,70 @@
pipeline {
agent any
environment {
NODE_VERSION = '20.17.0'
PR_NUMBER = "${env.CHANGE_ID}" // PR number comes from webhook payload
IMAGE_TAG="ghcr.io/gitroomhq/postiz-app-pr:${env.CHANGE_ID}"
}
stages {
stage('Checkout Repository') {
steps {
checkout scm
}
}
stage('Check Node.js and npm') {
steps {
script {
sh "node -v"
sh "npm -v"
}
}
}
stage('Install Dependencies') {
steps {
sh 'npm ci'
}
}
stage('Build Project') {
steps {
sh 'npm run build'
}
}
stage('Build and Push Docker Image') {
when {
expression { return env.CHANGE_ID != null } // Only run if it's a PR
}
steps {
withCredentials([string(credentialsId: 'gh-pat', variable: 'GITHUB_PASS')]) {
// Docker login step
sh '''
echo "$GITHUB_PASS" | docker login ghcr.io -u "egelhaus" --password-stdin
'''
// Build Docker image
sh '''
docker build -f Dockerfile.dev -t $IMAGE_TAG .
'''
// Push Docker image to GitHub Container Registry
sh '''
docker push $IMAGE_TAG
'''
}
}
}
}
post {
success {
echo 'Build completed successfully!'
}
failure {
echo 'Build failed!'
}
}
}

View file

@ -1,3 +1,9 @@
<p align="center">
<a href="https://github.com/growchief/growchief">
<img alt="automate" src="https://github.com/user-attachments/assets/d760188d-8d56-4b05-a6c1-c57e67ef25cd" />
</a>
</p>
<p align="center">
<a href="https://postiz.com/" target="_blank">
<picture>
@ -13,7 +19,6 @@
</a>
</p>
<h3 align="center"><strong><a href="https://github.com/gitroomhq/postiz-agent">NEW: check out Postiz agent CLI! perfect for OpenClaw and other agents</a></strong></h3>
<div align="center">
<strong>
<h2>Your ultimate AI social media scheduling tool</h2><br />
@ -65,14 +70,11 @@
<a href="https://apps.make.com/postiz">Make.com integration</a>
</p>
<br /><br />
## 🔌 See the leading Postiz features
<br />
<p align="center">
<a href="https://www.youtube.com/watch?v=BdsCVvEYgHU" target="_blank">
<img alt="Postiz" src="https://github.com/user-attachments/assets/8b9b7939-da1a-4be5-95be-42c6fce772de" />
</a>
<video src="https://github.com/user-attachments/assets/05436a01-19c8-4827-b57f-05a5e7637a67" width="100%" />
</p>
## ✨ Features
@ -81,15 +83,6 @@
| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| ![Image 3](https://github.com/user-attachments/assets/d51786ee-ddd8-4ef8-8138-5192e9cfe7c3) | ![Image 4](https://github.com/user-attachments/assets/91f83c89-22f6-43d6-b7aa-d2d3378289fb) |
### Our Sponsors
| Sponsor | Logo | Description |
|---------|:-----------------------------------------------------------------------:|-----------------|
| [Hostinger](https://www.hostinger.com/vps/docker/postiz?ref=postiz) | <img src=".github/sponsors/hostinger.png" alt="Hostinger" width="500"/> | Hostinger is on a mission to make online success possible for anyone from developers to aspiring bloggers and business owners |
| [Virlo](https://dev.virlo.ai/?ref=postiz) | <img src="https://github.com/user-attachments/assets/25182598-5344-45fc-b9cd-e4cfa16aabfd" alt="Virlo" width="500"/> | Virlo is the #1 social media trend spotting and all-in-one GTM tool for teams leveraging short-form video |
# Intro
- Schedule all your social media posts (many AI features)
@ -97,15 +90,14 @@
- Collaborate with other team members to exchange or buy posts.
- Invite your team members to collaborate, comment, and schedule posts.
- At the moment there is no difference between the hosted version to the self-hosted version
- Perfect for automation (API) with platforms like N8N, Make.com, Zapier, etc.
## Tech Stack
- Pnpm workspaces (Monorepo)
- NX (Monorepo)
- NextJS (React)
- NestJS
- Prisma (Default to PostgreSQL)
- Temporal
- Redis (BullMQ)
- Resend (email notifications)
## Quick Start
@ -132,7 +124,7 @@ Link: https://opencollective.com/postiz
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=gitroomhq/postiz-app&type=date&legend=top-left)](https://www.star-history.com/#gitroomhq/postiz-app&type=date&legend=top-left)
[![Star History Chart](https://api.star-history.com/svg?repos=gitroomhq/postiz-app&type=Date)](https://www.star-history.com/#gitroomhq/postiz-app&Date)
## License

View file

@ -4,48 +4,26 @@
The Postiz app is committed to ensuring the security and integrity of our users' data. This security policy outlines our procedures for handling security vulnerabilities and our disclosure policy.
## Scope
We, at Postiz (gitroomhq), cover the following scopes for vulnerability disclosures:
- The core repository for `postiz-app` (github.com/gitroomhq/postiz-app)
- All `gitroomhq` repositories that are official components, tooling, or integrations of Postiz
- Official Postiz container images published under `gitroomhq` on GHCR
- Official Postiz CLI tools and NPM packages (NPM org: @postiz)
- Postiz-Cloud related infrastructure & services. (API, Frontend, Configurations etc.)
- Plugins for Postiz maintained within the `gitroomhq` organization
Vulnerabilities in third-party dependencies or user-hosted infrastructure are outside of this scope.
## Supported Versions
This project currently only supports the latest release. We recommend that users always use the latest version of the Postiz app to ensure they have the latest security patches.
*CVE IDs will only be assigned to vulnerabilities affecting currently supported versions.*
## Reporting Security Vulnerabilities
If you discover a security vulnerability in the Postiz app, please report it through the [GitHub Security Advisory system](https://github.com/gitroomhq/postiz-app/security/advisories/new).
If you discover a security vulnerability in the Postiz app, please report it to us privately via email to one of the maintainers:
- @nevo-david
- @ennogelhaus ([email](mailto:gelhausenno@outlook.de))
When reporting a security vulnerability, please provide as much detail as possible, including:
- A clear description of the vulnerability
- Proof of concept (PoC), where possible
- Steps to reproduce the vulnerability
- Any relevant code or configuration files
If the report has immediate urgency, please contact one (or more) of the maintainers via email:
## Supported Versions
- @egelhaus ([E-Mail](mailto:egelhaus@ennogelhaus.de))
### AI Reports
Reports that appear to be LLM-generated without meaningful human analysis — typically lacking a working proof of concept, reproducible steps, or accurate impact assessment — will be closed without detailed response.
Reports that include AI-assisted analysis are welcome provided they have been validated by the reporter and include a proof of concept, reproduction steps, and impact assessment.
This project currently only supports the latest release. We recommend that users always use the latest version of the Postiz app to ensure they have the latest security patches.
## Disclosure Guidelines
We follow a private disclosure policy. If you discover a security vulnerability, please report it to us privately via GitHub Security Advisories, and if immediate urgency, via email as listed above. We will respond promptly to reports of vulnerabilities and work to resolve them as quickly as possible.
We follow a private disclosure policy. If you discover a security vulnerability, please report it to us privately via email to one of the maintainers listed above. We will respond promptly to reports of vulnerabilities and work to resolve them as quickly as possible.
We will not publicly disclose security vulnerabilities until a patch or fix is available to prevent malicious actors from exploiting the vulnerability before a fix is released.
@ -58,12 +36,8 @@ We take security vulnerabilities seriously and will respond promptly to reports
- Releasing the patch or fix as soon as possible.
- Notifying users of the vulnerability and the patch or fix.
## Response Timelines
## Template Attribution
We aim to follow these timelines:
This SECURITY.md file is based on the [GitHub Security Policy Template](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository).
- **Initial Acknowledgment:** Within 72 hours of initial report.
- **Completed Triage / Verification:** Within 7 days of initial acknowledgment.
- **Critical Issue Remediation:** Within 90 days of completed triage.
- **Non-Critical Issue Remediation:** Within 180 days of completed triage.
- **CVE Publication:** Within 24 hours of remediation release.
Thank you for helping to keep the `postiz-app` secure!

View file

@ -5,7 +5,7 @@
"scripts": {
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/backend/src/main",
"build": "cross-env NODE_ENV=production nest build",
"start": "dotenv -e ../../.env -- node --experimental-require-module ./dist/apps/backend/src/main.js",
"start": "dotenv -e ../../.env -- node ./dist/apps/backend/src/main.js",
"pm2": "pm2 start pnpm --name backend -- start"
},
"keywords": [],

View file

@ -16,10 +16,13 @@ import { MediaController } from '@gitroom/backend/api/routes/media.controller';
import { UploadModule } from '@gitroom/nestjs-libraries/upload/upload.module';
import { BillingController } from '@gitroom/backend/api/routes/billing.controller';
import { NotificationsController } from '@gitroom/backend/api/routes/notifications.controller';
import { MarketplaceController } from '@gitroom/backend/api/routes/marketplace.controller';
import { MessagesController } from '@gitroom/backend/api/routes/messages.controller';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service';
import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service';
import { CopilotController } from '@gitroom/backend/api/routes/copilot.controller';
import { AgenciesController } from '@gitroom/backend/api/routes/agencies.controller';
import { PublicController } from '@gitroom/backend/api/routes/public.controller';
import { RootController } from '@gitroom/backend/api/routes/root.controller';
import { TrackService } from '@gitroom/nestjs-libraries/track/track.service';
@ -28,22 +31,11 @@ import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments';
import { WebhookController } from '@gitroom/backend/api/routes/webhooks.controller';
import { SignatureController } from '@gitroom/backend/api/routes/signature.controller';
import { AutopostController } from '@gitroom/backend/api/routes/autopost.controller';
import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service';
import { McpController } from '@gitroom/backend/api/routes/mcp.controller';
import { SetsController } from '@gitroom/backend/api/routes/sets.controller';
import { ThirdPartyController } from '@gitroom/backend/api/routes/third-party.controller';
import { MonitorController } from '@gitroom/backend/api/routes/monitor.controller';
import { NoAuthIntegrationsController } from '@gitroom/backend/api/routes/no.auth.integrations.controller';
import { EnterpriseController } from '@gitroom/backend/api/routes/enterprise.controller';
import { OAuthAppController } from '@gitroom/backend/api/routes/oauth-app.controller';
import { ApprovedAppsController } from '@gitroom/backend/api/routes/approved-apps.controller';
import { OAuthController, OAuthAuthorizedController } from '@gitroom/backend/api/routes/oauth.controller';
import { AnnouncementsController } from '@gitroom/backend/api/routes/announcements.controller';
import { AdminController } from '@gitroom/backend/api/routes/admin.controller';
import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager';
import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider';
import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider';
import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider';
import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider';
import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider';
const authenticatedController = [
UsersController,
@ -54,17 +46,15 @@ const authenticatedController = [
MediaController,
BillingController,
NotificationsController,
MarketplaceController,
MessagesController,
CopilotController,
AgenciesController,
WebhookController,
SignatureController,
AutopostController,
SetsController,
ThirdPartyController,
OAuthAppController,
ApprovedAppsController,
OAuthAuthorizedController,
AnnouncementsController,
AdminController,
];
@Module({
imports: [UploadModule],
@ -73,10 +63,8 @@ const authenticatedController = [
StripeController,
AuthController,
PublicController,
McpController,
MonitorController,
EnterpriseController,
NoAuthIntegrationsController,
OAuthController,
...authenticatedController,
],
providers: [
@ -92,12 +80,7 @@ const authenticatedController = [
TrackService,
ShortLinkService,
Nowpayments,
AuthProviderManager,
GithubProvider,
GoogleProvider,
FarcasterProvider,
WalletProvider,
OauthProvider,
McpService,
],
get exports() {
return [...this.imports, ...this.providers];

View file

@ -1,47 +0,0 @@
import {
Controller,
Get,
HttpException,
Query,
} from '@nestjs/common';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { User } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { ErrorsService } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.service';
@ApiTags('Admin')
@Controller('/admin')
export class AdminController {
constructor(private _errorsService: ErrorsService) {}
private assertSuperAdmin(user: User) {
if (!user?.isSuperAdmin) {
throw new HttpException('Unauthorized', 400);
}
}
@Get('/errors')
async listErrors(
@GetUserFromRequest() user: User,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('platform') platform?: string,
@Query('email') email?: string,
@Query('unknownFirst') unknownFirst?: string
) {
this.assertSuperAdmin(user);
return this._errorsService.listErrors({
page: page ? parseInt(page, 10) : 0,
limit: limit ? parseInt(limit, 10) : 20,
platform: platform || undefined,
email: email || undefined,
unknownFirst: unknownFirst === 'true' || unknownFirst === '1',
});
}
@Get('/errors/platforms')
async listPlatforms(@GetUserFromRequest() user: User) {
this.assertSuperAdmin(user);
return this._errorsService.listPlatforms();
}
}

View file

@ -0,0 +1,37 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { User } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { AgenciesService } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.service';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { CreateAgencyDto } from '@gitroom/nestjs-libraries/dtos/agencies/create.agency.dto';
@ApiTags('Agencies')
@Controller('/agencies')
export class AgenciesController {
constructor(private _agenciesService: AgenciesService) {}
@Get('/')
async getAgencyByUser(@GetUserFromRequest() user: User) {
return (await this._agenciesService.getAgencyByUser(user)) || {};
}
@Post('/')
async createAgency(
@GetUserFromRequest() user: User,
@Body() body: CreateAgencyDto
) {
return this._agenciesService.createAgency(user, body);
}
@Post('/action/:action/:id')
async updateAgency(
@GetUserFromRequest() user: User,
@Param('action') action: string,
@Param('id') id: string
) {
if (!user.isSuperAdmin) {
return 400;
}
return this._agenciesService.approveOrDecline(user.email, action, id);
}
}

View file

@ -1,17 +1,56 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
Query,
} from '@nestjs/common';
import { Organization } from '@prisma/client';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import dayjs from 'dayjs';
import { StarsListDto } from '@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto';
import { ApiTags } from '@nestjs/swagger';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
@ApiTags('Analytics')
@Controller('/analytics')
export class AnalyticsController {
constructor(
private _integrationService: IntegrationService,
private _postsService: PostsService
private _starsService: StarsService,
private _integrationService: IntegrationService
) {}
@Get('/')
async getStars(@GetOrgFromRequest() org: Organization) {
return this._starsService.getStars(org.id);
}
@Get('/trending')
async getTrending() {
const todayTrending = dayjs(dayjs().format('YYYY-MM-DDT12:00:00'));
const last = todayTrending.isAfter(dayjs())
? todayTrending.subtract(1, 'day')
: todayTrending;
const nextTrending = last.add(1, 'day');
return {
last: last.format('YYYY-MM-DD HH:mm:ss'),
predictions: nextTrending.format('YYYY-MM-DD HH:mm:ss'),
};
}
@Post('/stars')
async getStarsFilter(
@GetOrgFromRequest() org: Organization,
@Body() starsFilter: StarsListDto
) {
return {
stars: await this._starsService.getStarsFilter(org.id, starsFilter),
};
}
@Get('/:integration')
async getIntegration(
@ -21,13 +60,4 @@ export class AnalyticsController {
) {
return this._integrationService.checkAnalytics(org, integration, date);
}
@Get('/post/:postId')
async getPostAnalytics(
@GetOrgFromRequest() org: Organization,
@Param('postId') postId: string,
@Query('date') date: string
) {
return this._postsService.checkPostAnalytics(org.id, postId, +date);
}
}

View file

@ -1,47 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpException,
Param,
Post,
} from '@nestjs/common';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { User } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { AnnouncementsService } from '@gitroom/nestjs-libraries/database/prisma/announcements/announcements.service';
import { AnnouncementDto } from '@gitroom/nestjs-libraries/dtos/announcements/announcements.dto';
@ApiTags('Announcements')
@Controller('/announcements')
export class AnnouncementsController {
constructor(private _announcementsService: AnnouncementsService) {}
@Get('/')
async getAnnouncements() {
return this._announcementsService.getAnnouncements();
}
@Post('/')
async createAnnouncement(
@GetUserFromRequest() user: User,
@Body() body: AnnouncementDto
) {
if (!user.isSuperAdmin) {
throw new HttpException('Unauthorized', 400);
}
return this._announcementsService.createAnnouncement(body);
}
@Delete('/:id')
async deleteAnnouncement(
@GetUserFromRequest() user: User,
@Param('id') id: string
) {
if (!user.isSuperAdmin) {
throw new HttpException('Unauthorized', 400);
}
return this._announcementsService.deleteAnnouncement(id);
}
}

View file

@ -1,24 +0,0 @@
import { Controller, Delete, Get, Param } from '@nestjs/common';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { User } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
@ApiTags('Approved Apps')
@Controller('/user/approved-apps')
export class ApprovedAppsController {
constructor(private _oauthService: OAuthService) {}
@Get('/')
async list(@GetUserFromRequest() user: User) {
return this._oauthService.getApprovedApps(user.id);
}
@Delete('/:id')
async revoke(
@GetUserFromRequest() user: User,
@Param('id') id: string
) {
return this._oauthService.revokeApp(user.id, id);
}
}

View file

@ -15,14 +15,12 @@ import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto';
import { ForgotPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot.password.dto';
import { ResendActivationDto } from '@gitroom/nestjs-libraries/dtos/auth/resend-activation.dto';
import { ApiTags } from '@nestjs/swagger';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { RealIP } from 'nestjs-real-ip';
import { UserAgent } from '@gitroom/nestjs-libraries/user/user.agent';
import { Provider } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
@ApiTags('Auth')
@Controller('/auth')
@ -103,7 +101,6 @@ export class AuthController {
}
}
Sentry.metrics.count('new_user', 1);
response.header('onboarding', 'true');
response.status(200).json({
register: true,
@ -199,19 +196,6 @@ export class AuthController {
};
}
@Get('/oauth-mobile-callback')
mobileCallback(
@Query('code') code: string,
@Query('state') state: string,
@Res({ passthrough: false }) response: Response
) {
const scheme = process.env.MOBILE_APP_SCHEME || 'postiz://auth/callback';
const params = new URLSearchParams();
if (code) params.set('code', code);
if (state) params.set('state', state);
return response.redirect(302, `${scheme}?${params.toString()}`);
}
@Get('/oauth/:provider')
async oauthLink(@Param('provider') provider: string, @Query() query: any) {
return this._authService.oauthLink(provider, query);
@ -220,13 +204,9 @@ export class AuthController {
@Post('/activate')
async activate(
@Body('code') code: string,
@Body('datafast_visitor_id') datafast_visitor_id: string,
@Res({ passthrough: false }) response: Response
) {
const activate = await this._authService.activate(
code,
datafast_visitor_id
);
const activate = await this._authService.activate(code);
if (!activate) {
return response.status(200).json({ can: false });
}
@ -252,33 +232,13 @@ export class AuthController {
return response.status(200).json({ can: true });
}
@Post('/resend-activation')
async resendActivation(@Body() body: ResendActivationDto) {
try {
await this._authService.resendActivationEmail(body.email);
return {
success: true,
};
} catch (e: any) {
return {
success: false,
message: e.message,
};
}
}
@Post('/oauth/:provider/exists')
async oauthExists(
@Body('code') code: string,
@Body('redirect_uri') redirect_uri: string,
@Param('provider') provider: string,
@Res({ passthrough: false }) response: Response
) {
const { jwt, token } = await this._authService.checkExists(
provider,
code,
redirect_uri
);
const { jwt, token } = await this._authService.checkExists(provider, code);
if (token) {
return response.json({ token });

View file

@ -15,7 +15,6 @@ import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permis
import { AutopostService } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.service';
import { AutopostDto } from '@gitroom/nestjs-libraries/dtos/autopost/autopost.dto';
import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { OnlyURL } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto';
@ApiTags('Autopost')
@Controller('/autopost')
@ -63,7 +62,7 @@ export class AutopostController {
}
@Post('/send')
async sendWebhook(@Query() query: OnlyURL) {
return this._autopostsService.loadXML(query.url);
async sendWebhook(@Query('url') url: string) {
return this._autopostsService.loadXML(url);
}
}

View file

@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpException, Param, Post, Req } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Req } from '@nestjs/common';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
@ -9,7 +9,6 @@ import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.req
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { Request } from 'express';
import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
@ApiTags('Billing')
@Controller('/billing')
@ -31,20 +30,6 @@ export class BillingController {
};
}
@Get('/check-discount')
async checkDiscount(@GetOrgFromRequest() org: Organization) {
return {
offerCoupon: !(await this._stripeService.checkDiscount(org.paymentId))
? false
: AuthService.signJWT({ discount: true }),
};
}
@Post('/apply-discount')
async applyDiscount(@GetOrgFromRequest() org: Organization) {
await this._stripeService.applyDiscount(org.paymentId);
}
@Post('/finish-trial')
async finishTrial(@GetOrgFromRequest() org: Organization) {
try {
@ -62,23 +47,6 @@ export class BillingController {
};
}
@Post('/embedded')
embedded(
@GetOrgFromRequest() org: Organization,
@GetUserFromRequest() user: User,
@Body() body: BillingSubscribeDto,
@Req() req: Request
) {
const uniqueId = req?.cookies?.track;
return this._stripeService.embedded(
uniqueId,
org.id,
user.id,
body,
org.allowTrial
);
}
@Post('/subscribe')
subscribe(
@GetOrgFromRequest() org: Organization,
@ -144,43 +112,6 @@ export class BillingController {
return this._stripeService.lifetimeDeal(org.id, body.code);
}
@Get('/charges')
async getCharges(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() org: Organization
) {
if (!user.isSuperAdmin) {
throw new HttpException('Unauthorized', 400);
}
return this._stripeService.getCharges(org.id);
}
@Post('/refund-charges')
async refundCharges(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() org: Organization,
@Body() body: { chargeIds: string[] }
) {
if (!user.isSuperAdmin) {
throw new HttpException('Unauthorized', 400);
}
return this._stripeService.refundCharges(org.id, body.chargeIds);
}
@Post('/cancel-subscription')
async cancelSubscription(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() org: Organization
) {
if (!user.isSuperAdmin) {
throw new HttpException('Unauthorized', 400);
}
return this._stripeService.cancelSubscription(org.id);
}
@Post('/add-subscription')
async addSubscription(
@Body() body: { subscription: string },

View file

@ -1,43 +1,18 @@
import {
Logger,
Controller,
Get,
Post,
Req,
Res,
Query,
Param,
} from '@nestjs/common';
import { Logger, Controller, Get, Post, Req, Res, Query } from '@nestjs/common';
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNodeHttpEndpoint,
copilotRuntimeNextJSAppRouterEndpoint,
copilotRuntimeNestEndpoint,
} from '@copilotkit/runtime';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { MastraAgent } from '@ag-ui/mastra';
import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';
import { Request, Response } from 'express';
import { RequestContext } from '@mastra/core/di';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/permissions/permission.exception.class';
export type ChannelsContext = {
integrations: string;
organization: string;
ui: string;
};
@Controller('/copilot')
export class CopilotController {
constructor(
private _subscriptionService: SubscriptionService,
private _mastraService: MastraService
) {}
constructor(private _subscriptionService: SubscriptionService) {}
@Post('/chat')
chatAgent(@Req() req: Request, @Res() res: Response) {
chat(@Req() req: Request, @Res() res: Response) {
if (
process.env.OPENAI_API_KEY === undefined ||
process.env.OPENAI_API_KEY === ''
@ -46,109 +21,28 @@ export class CopilotController {
return;
}
const copilotRuntimeHandler = copilotRuntimeNodeHttpEndpoint({
const copilotRuntimeHandler = copilotRuntimeNestEndpoint({
endpoint: '/copilot/chat',
runtime: new CopilotRuntime(),
serviceAdapter: new OpenAIAdapter({
model: 'gpt-4.1',
model:
// @ts-ignore
req?.body?.variables?.data?.metadata?.requestType ===
'TextareaCompletion'
? 'gpt-4o-mini'
: 'gpt-4.1',
}),
});
// @ts-ignore
return copilotRuntimeHandler(req, res);
}
@Post('/agent')
@CheckPolicies([AuthorizationActions.Create, Sections.AI])
async agent(
@Req() req: Request,
@Res() res: Response,
@GetOrgFromRequest() organization: Organization
) {
if (
process.env.OPENAI_API_KEY === undefined ||
process.env.OPENAI_API_KEY === ''
) {
Logger.warn('OpenAI API key not set, chat functionality will not work');
return;
}
const mastra = await this._mastraService.mastra();
const requestContext = new RequestContext<ChannelsContext>();
requestContext.set(
'integrations',
req?.body?.variables?.properties?.integrations || []
);
requestContext.set('organization', JSON.stringify(organization));
requestContext.set('ui', 'true');
const agents = MastraAgent.getLocalAgents({
resourceId: organization.id,
mastra,
requestContext: requestContext as any,
});
const runtime = new CopilotRuntime({
agents,
});
const copilotRuntimeHandler = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: '/copilot/agent',
runtime,
// properties: req.body.variables.properties,
serviceAdapter: new OpenAIAdapter({
model: 'gpt-4.1',
}),
});
return copilotRuntimeHandler.handleRequest(req, res);
}
@Get('/credits')
calculateCredits(
@GetOrgFromRequest() organization: Organization,
@Query('type') type: 'ai_images' | 'ai_videos'
@Query('type') type: 'ai_images' | 'ai_videos',
) {
return this._subscriptionService.checkCredits(
organization,
type || 'ai_images'
);
}
@Get('/:thread/list')
@CheckPolicies([AuthorizationActions.Create, Sections.AI])
async getMessagesList(
@GetOrgFromRequest() organization: Organization,
@Param('thread') threadId: string
): Promise<any> {
const mastra = await this._mastraService.mastra();
const memory = await mastra.getAgent('postiz').getMemory();
try {
return await memory.recall({
resourceId: organization.id,
threadId,
});
} catch (err) {
return { messages: [] };
}
}
@Get('/list')
@CheckPolicies([AuthorizationActions.Create, Sections.AI])
async getList(@GetOrgFromRequest() organization: Organization) {
const mastra = await this._mastraService.mastra();
const memory = await mastra.getAgent('postiz').getMemory();
const list = await memory.listThreads({
filter: { resourceId: organization.id },
perPage: 100000,
page: 0,
orderBy: { field: 'createdAt', direction: 'DESC' },
});
return {
threads: list.threads.map((p) => ({
id: p.id,
title: p.title,
})),
};
return this._subscriptionService.checkCredits(organization, type || 'ai_images');
}
}

View file

@ -1,128 +0,0 @@
import { Body, Controller, Param, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
@ApiTags('Enterprise')
@Controller('/enterprise')
export class EnterpriseController {
constructor(
private _integrationManager: IntegrationManager,
private _organizationService: OrganizationService,
private _integrationService: IntegrationService,
private _postsService: PostsService
) {}
@Post('/create-user')
async createUser(@Body('params') params: string) {
try {
const { id, name, saasName, email } = AuthService.verifyJWT(params) as {
id: string;
name: string;
email: string;
saasName: string;
};
try {
return await this._organizationService.createMaxUser(
id,
name,
saasName,
email
);
} catch (err) {
return { create: false };
}
} catch (err) {
return { success: false };
}
}
@Post('/url')
async redirectParams(@Body('params') params: string) {
try {
const load = AuthService.verifyJWT(params) as {
redirectUrl: string;
apiKey: string;
refreshId?: string;
provider: string;
webhookUrl: string;
};
if (!load || !load.redirectUrl || !load.apiKey || !load.provider) {
return;
}
const org = await this._organizationService.getOrgByApiKey(load.apiKey);
if (!org) {
throw new Error('Organization not found');
}
if (
!this._integrationManager
.getAllowedSocialsIntegrations()
.includes(load.provider)
) {
throw new Error('Integration not allowed');
}
const integrationProvider = this._integrationManager.getSocialIntegration(
load.provider
);
const { codeVerifier, state, url } =
await integrationProvider.generateAuthUrl();
if (load.refreshId) {
await ioRedis.set(`refresh:${state}`, load.refreshId, 'EX', 3600);
}
await ioRedis.set(`webhookUrl:${state}`, load.webhookUrl, 'EX', 3600);
await ioRedis.set(`redirect:${state}`, load.redirectUrl, 'EX', 3600);
await ioRedis.set(`organization:${state}`, org.id, 'EX', 3600);
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 3600);
return url;
} catch (err) {}
}
@Post('/delete-channel')
async deleteChannel(@Body('params') params: string) {
try {
const load = AuthService.verifyJWT(params) as {
apiKey: string;
id: string;
};
if (!load || !load.apiKey || !load.id) {
return { success: false };
}
const org = await this._organizationService.getOrgByApiKey(load.apiKey);
if (!org) {
return { success: false };
}
const isTherePosts = await this._integrationService.getPostsForChannel(
org.id,
load.id
);
if (isTherePosts.length) {
for (const post of isTherePosts) {
this._postsService.deletePost(org.id, post.group).catch(() => {});
}
}
await this._integrationService.deleteChannel(org.id, load.id);
return { success: true };
} catch (err) {
return { success: false };
}
}
}

View file

@ -3,35 +3,42 @@ import {
Controller,
Delete,
Get,
HttpException,
Param,
Post,
Put,
Query,
UseFilters,
} from '@nestjs/common';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization, User } from '@prisma/client';
import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.dto';
import { IntegrationFunctionDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.function.dto';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import { ApiTags } from '@nestjs/swagger';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import {
NotEnoughScopes,
RefreshToken,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { timer } from '@gitroom/helpers/utils/timer';
import { TelegramProvider } from '@gitroom/nestjs-libraries/integrations/social/telegram.provider';
import { MoltbookProvider } from '@gitroom/nestjs-libraries/integrations/social/moltbook.provider';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { uniqBy } from 'lodash';
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
@ApiTags('Integrations')
@Controller('/integrations')
@ -39,18 +46,11 @@ export class IntegrationsController {
constructor(
private _integrationManager: IntegrationManager,
private _integrationService: IntegrationService,
private _postService: PostsService,
private _refreshIntegrationService: RefreshIntegrationService
private _postService: PostsService
) {}
@Post('/provider/:id/connect')
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
async saveProviderPage(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body() body: any
) {
return this._integrationService.saveProviderPage(org.id, id, body);
@Get('/')
getIntegration() {
return this._integrationManager.getAllIntegrations();
}
@Get('/:identifier/internal-plugs')
@ -101,7 +101,6 @@ export class IntegrationsController {
internalId: p.internalId,
disabled: p.disabled,
editor: findIntegration.editor,
stripLinks: !!findIntegration?.stripLinks?.(),
picture: p.picture || '/no-picture.jpg',
identifier: p.providerIdentifier,
inBetweenSteps: p.inBetweenSteps,
@ -195,10 +194,7 @@ export class IntegrationsController {
async getIntegrationUrl(
@Param('integration') integration: string,
@Query('refresh') refresh: string,
@Query('externalUrl') externalUrl: string,
@Query('redirectUrl') redirectUrl: string,
@Query('onboarding') onboarding: string,
@GetOrgFromRequest() org: Organization
@Query('externalUrl') externalUrl: string
) {
if (
!this._integrationManager
@ -227,24 +223,15 @@ export class IntegrationsController {
await integrationProvider.generateAuthUrl(getExternalUrl);
if (refresh) {
await ioRedis.set(`refresh:${state}`, refresh, 'EX', 3600);
await ioRedis.set(`refresh:${state}`, refresh, 'EX', 300);
}
if (onboarding === 'true') {
await ioRedis.set(`onboarding:${state}`, 'true', 'EX', 3600);
}
if (redirectUrl) {
await ioRedis.set(`redirect:${state}`, redirectUrl, 'EX', 3600);
}
await ioRedis.set(`organization:${state}`, org.id, 'EX', 3600);
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 3600);
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
await ioRedis.set(
`external:${state}`,
JSON.stringify(getExternalUrl),
'EX',
3600
300
);
return { url };
@ -352,24 +339,37 @@ export class IntegrationsController {
return load;
} catch (err) {
if (err instanceof RefreshToken) {
const data = await this._refreshIntegrationService.refresh(
getIntegration
);
if (!data) {
return;
}
const { accessToken } = data;
const { accessToken, refreshToken, expiresIn, additionalSettings } =
await integrationProvider.refreshToken(getIntegration.refreshToken);
if (accessToken) {
await this._integrationService.createOrUpdateIntegration(
additionalSettings,
!!integrationProvider.oneTimeToken,
getIntegration.organizationId,
getIntegration.name,
getIntegration.picture!,
'social',
getIntegration.internalId,
getIntegration.providerIdentifier,
accessToken,
refreshToken,
expiresIn
);
getIntegration.token = accessToken;
if (integrationProvider.refreshWait) {
await timer(10000);
}
return this.functionIntegration(org, body);
} else {
await this._integrationService.disconnectChannel(
org.id,
getIntegration
);
return false;
}
return false;
}
return false;
@ -378,6 +378,154 @@ export class IntegrationsController {
throw new Error('Function not found');
}
@Post('/social/:integration/connect')
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
@UseFilters(new NotEnoughScopesFilter())
async connectSocialMedia(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@Body() body: ConnectIntegrationDto
) {
if (
!this._integrationManager
.getAllowedSocialsIntegrations()
.includes(integration)
) {
throw new Error('Integration not allowed');
}
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
const getCodeVerifier = integrationProvider.customFields
? 'none'
: await ioRedis.get(`login:${body.state}`);
if (!getCodeVerifier) {
throw new Error('Invalid state');
}
if (!integrationProvider.customFields) {
await ioRedis.del(`login:${body.state}`);
}
const details = integrationProvider.externalUrl
? await ioRedis.get(`external:${body.state}`)
: undefined;
if (details) {
await ioRedis.del(`external:${body.state}`);
}
const refresh = await ioRedis.get(`refresh:${body.state}`);
if (refresh) {
await ioRedis.del(`refresh:${body.state}`);
}
const {
error,
accessToken,
expiresIn,
refreshToken,
id,
name,
picture,
username,
additionalSettings,
// eslint-disable-next-line no-async-promise-executor
} = await new Promise<AuthTokenDetails>(async (res) => {
const auth = await integrationProvider.authenticate(
{
code: body.code,
codeVerifier: getCodeVerifier,
refresh: body.refresh,
},
details ? JSON.parse(details) : undefined
);
if (typeof auth === 'string') {
return res({
error: auth,
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
additionalSettings: [],
});
}
if (refresh && integrationProvider.reConnect) {
const newAuth = await integrationProvider.reConnect(
auth.id,
refresh,
auth.accessToken
);
return res(newAuth);
}
return res(auth);
});
if (error) {
throw new NotEnoughScopes(error);
}
if (!id) {
throw new NotEnoughScopes('Invalid API key');
}
if (refresh && String(id) !== String(refresh)) {
throw new NotEnoughScopes(
'Please refresh the channel that needs to be refreshed'
);
}
let validName = name;
if (!validName) {
if (username) {
validName = username.split('.')[0] ?? username;
} else {
validName = `Channel_${String(id).slice(0, 8)}`;
}
}
if (
process.env.STRIPE_PUBLISHABLE_KEY &&
org.isTrailing &&
(await this._integrationService.checkPreviousConnections(
org.id,
String(id)
))
) {
throw new HttpException('', 412);
}
return this._integrationService.createOrUpdateIntegration(
additionalSettings,
!!integrationProvider.oneTimeToken,
org.id,
validName.trim(),
picture,
'social',
String(id),
integration,
accessToken,
refreshToken,
expiresIn,
username,
refresh ? false : integrationProvider.isBetweenSteps,
body.refresh,
+body.timezone,
details
? AuthService.fixedEncryption(details)
: integrationProvider.customFields
? AuthService.fixedEncryption(
Buffer.from(body.code, 'base64').toString()
)
: undefined
);
}
@Post('/disable')
disableChannel(
@GetOrgFromRequest() org: Organization,
@ -386,6 +534,33 @@ export class IntegrationsController {
return this._integrationService.disableChannel(org.id, id);
}
@Post('/instagram/:id')
async saveInstagram(
@Param('id') id: string,
@Body() body: { pageId: string; id: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveInstagram(org.id, id, body);
}
@Post('/facebook/:id')
async saveFacebook(
@Param('id') id: string,
@Body() body: { page: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveFacebook(org.id, id, body.page);
}
@Post('/linkedin-page/:id')
async saveLinkedin(
@Param('id') id: string,
@Body() body: { page: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveLinkedin(org.id, id, body.page);
}
@Post('/enable')
enableChannel(
@GetOrgFromRequest() org: Organization,
@ -410,7 +585,7 @@ export class IntegrationsController {
);
if (isTherePosts.length) {
for (const post of isTherePosts) {
this._postService.deletePost(org.id, post.group).catch((err) => {});
await this._postService.deletePost(org.id, post.group);
}
}
@ -452,30 +627,4 @@ export class IntegrationsController {
async getUpdates(@Query() query: { word: string; id?: number }) {
return new TelegramProvider().getBotId(query);
}
@Post('/moltbook/register')
async moltbookRegister(@Body() body: { name: string; description: string }) {
try {
const provider = new MoltbookProvider();
const result = await provider.registerAgent(body.name, body.description);
return {
apiKey: result.api_key,
claimUrl: result.claim_url,
verificationCode: result.verification_code,
};
} catch (err: any) {
return { error: err.message || 'Registration failed' };
}
}
@Get('/moltbook/status')
async moltbookStatus(@Query('apiKey') apiKey: string) {
try {
const provider = new MoltbookProvider();
const result = await provider.checkAgentStatus(apiKey);
return { claimed: result?.status === 'claimed' };
} catch (err) {
return { claimed: false };
}
}
}

View file

@ -0,0 +1,242 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Organization, User } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { ItemUserService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/item.user.service';
import { AddRemoveItemDto } from '@gitroom/nestjs-libraries/dtos/marketplace/add.remove.item.dto';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { ChangeActiveDto } from '@gitroom/nestjs-libraries/dtos/marketplace/change.active.dto';
import { ItemsDto } from '@gitroom/nestjs-libraries/dtos/marketplace/items.dto';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { AudienceDto } from '@gitroom/nestjs-libraries/dtos/marketplace/audience.dto';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { CreateOfferDto } from '@gitroom/nestjs-libraries/dtos/marketplace/create.offer.dto';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
@ApiTags('Marketplace')
@Controller('/marketplace')
export class MarketplaceController {
constructor(
private _itemUserService: ItemUserService,
private _stripeService: StripeService,
private _userService: UsersService,
private _messagesService: MessagesService,
private _postsService: PostsService
) {}
@Post('/')
getInfluencers(
@GetOrgFromRequest() organization: Organization,
@GetUserFromRequest() user: User,
@Body() body: ItemsDto
) {
return this._userService.getMarketplacePeople(
organization.id,
user.id,
body
);
}
@Post('/conversation')
createConversation(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Body() body: NewConversationDto
) {
return this._messagesService.createConversation(
user.id,
organization.id,
body
);
}
@Get('/bank')
connectBankAccount(
@GetUserFromRequest() user: User,
@Query('country') country: string
) {
return this._stripeService.createAccountProcess(
user.id,
user.email,
country
);
}
@Post('/item')
async addItems(
@GetUserFromRequest() user: User,
@Body() body: AddRemoveItemDto
) {
return this._itemUserService.addOrRemoveItem(body.state, user.id, body.key);
}
@Post('/active')
async changeActive(
@GetUserFromRequest() user: User,
@Body() body: ChangeActiveDto
) {
await this._userService.changeMarketplaceActive(user.id, body.active);
}
@Post('/audience')
async changeAudience(
@GetUserFromRequest() user: User,
@Body() body: AudienceDto
) {
await this._userService.changeAudienceSize(user.id, body.audience);
}
@Get('/item')
async getItems(@GetUserFromRequest() user: User) {
return this._itemUserService.getItems(user.id);
}
@Get('/orders')
async getOrders(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Query('type') type: 'seller' | 'buyer'
) {
return this._messagesService.getOrders(user.id, organization.id, type);
}
@Get('/account')
async getAccount(@GetUserFromRequest() user: User) {
const { account, marketplace, connectedAccount, name, picture, audience } =
await this._userService.getUserByEmail(user.email);
return {
account,
marketplace,
connectedAccount,
fullname: name,
audience,
picture,
};
}
@Post('/offer')
async createOffer(
@GetUserFromRequest() user: User,
@Body() body: CreateOfferDto
) {
return this._messagesService.createOffer(user.id, body);
}
@Get('/posts/:id')
async post(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string
) {
const getPost = await this._messagesService.getPost(
user.id,
organization.id,
id
);
if (!getPost) {
return;
}
return {
...(await this._postsService.getPost(getPost.organizationId, id)),
providerId: getPost.integration.providerIdentifier,
};
}
@Post('/posts/:id/revision')
async revision(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string,
@Body('message') message: string
) {
return this._messagesService.requestRevision(
user.id,
organization.id,
id,
message
);
}
@Post('/posts/:id/approve')
async approve(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string,
@Body('message') message: string
) {
return this._messagesService.requestApproved(
user.id,
organization.id,
id,
message
);
}
@Post('/posts/:id/cancel')
async cancel(
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string
) {
return this._messagesService.requestCancel(organization.id, id);
}
@Post('/offer/:id/complete')
async completeOrder(
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string
) {
const order = await this._messagesService.completeOrderAndPay(
organization.id,
id
);
if (!order) {
return;
}
try {
await this._stripeService.payout(
id,
order.charge,
order.account,
order.price
);
} catch (e) {
await this._messagesService.payoutProblem(
id,
order.sellerId,
order.price
);
}
await this._messagesService.completeOrder(id);
}
@Post('/orders/:id/payment')
async payOrder(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string
) {
const orderDetails = await this._messagesService.getOrderDetails(
user.id,
organization.id,
id
);
const payment = await this._stripeService.payAccountStepOne(
user.id,
organization,
orderDetails.seller,
orderDetails.order.id,
orderDetails.order.ordersItems.map((p) => ({
quantity: p.quantity,
integrationType: p.integration.providerIdentifier,
price: p.price,
})),
orderDetails.order.messageGroupId
);
return payment;
}
}

View file

@ -0,0 +1,40 @@
import {
Body,
Controller,
HttpException,
Param,
Post,
Sse,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
@ApiTags('Mcp')
@Controller('/mcp')
export class McpController {
constructor(
private _mcpService: McpService,
private _organizationService: OrganizationService
) {}
@Sse('/:api/sse')
async sse(@Param('api') api: string) {
const apiModel = await this._organizationService.getOrgByApiKey(api);
if (!apiModel) {
throw new HttpException('Invalid url', 400);
}
return await this._mcpService.runServer(api, apiModel.id);
}
@Post('/:api/messages')
async post(@Param('api') api: string, @Body() body: any) {
const apiModel = await this._organizationService.getOrgByApiKey(api);
if (!apiModel) {
throw new HttpException('Invalid url', 400);
}
return this._mcpService.processPostBody(apiModel.id, body);
}
}

View file

@ -91,13 +91,11 @@ export class MediaController {
@GetOrgFromRequest() org: Organization,
@UploadedFile() file: Express.Multer.File
) {
const originalName = file?.originalname || '';
const uploadedFile = await this.storage.uploadFile(file);
return this._mediaService.saveFile(
org.id,
uploadedFile.originalname,
uploadedFile.path,
originalName
uploadedFile.path
);
}
@ -105,8 +103,7 @@ export class MediaController {
async saveMedia(
@GetOrgFromRequest() org: Organization,
@Req() req: Request,
@Body('name') name: string,
@Body('originalName') originalName: string
@Body('name') name: string
) {
if (!name) {
return false;
@ -114,8 +111,7 @@ export class MediaController {
return this._mediaService.saveFile(
org.id,
name,
process.env.CLOUDFLARE_BUCKET_URL + '/' + name,
originalName || undefined
process.env.CLOUDFLARE_BUCKET_URL + '/' + name
);
}
@ -129,13 +125,11 @@ export class MediaController {
@Post('/upload-simple')
@UseInterceptors(FileInterceptor('file'))
@UsePipes(new CustomFileValidationPipe())
async uploadSimple(
@GetOrgFromRequest() org: Organization,
@UploadedFile('file') file: Express.Multer.File,
@Body('preventSave') preventSave: string = 'false'
) {
const originalName = file.originalname;
const getFile = await this.storage.uploadFile(file);
if (preventSave === 'true') {
@ -146,8 +140,7 @@ export class MediaController {
return this._mediaService.saveFile(
org.id,
getFile.originalname,
getFile.path,
originalName
getFile.path
);
}
@ -165,14 +158,12 @@ export class MediaController {
// @ts-ignore
const name = upload.Location.split('/').pop();
const originalName = req.body?.file?.name;
const saveFile = await this._mediaService.saveFile(
org.id,
name,
// @ts-ignore
upload.Location,
originalName || undefined
upload.Location
);
res.status(200).json({ ...upload, saved: saveFile });
@ -181,10 +172,9 @@ export class MediaController {
@Get('/')
getMedia(
@GetOrgFromRequest() org: Organization,
@Query('page') page: number,
@Query('search') search?: string
@Query('page') page: number
) {
return this._mediaService.getMedia(org.id, page, search);
return this._mediaService.getMedia(org.id, page);
}
@Get('/video-options')

View file

@ -0,0 +1,50 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { Organization, User } from '@prisma/client';
import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
@ApiTags('Messages')
@Controller('/messages')
export class MessagesController {
constructor(private _messagesService: MessagesService) {}
@Get('/')
getMessagesGroup(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization
) {
return this._messagesService.getMessagesGroup(user.id, organization.id);
}
@Get('/:groupId/:page')
getMessages(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('groupId') groupId: string,
@Param('page') page: string
) {
return this._messagesService.getMessages(
user.id,
organization.id,
groupId,
+page
);
}
@Post('/:groupId')
createMessage(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('groupId') groupId: string,
@Body() message: AddMessageDto
) {
return this._messagesService.createMessage(
user.id,
organization.id,
groupId,
message
);
}
}

View file

@ -1,14 +1,30 @@
import { Controller, Get, Param } from '@nestjs/common';
import { Controller, Get, HttpException, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@ApiTags('Monitor')
@Controller('/monitor')
export class MonitorController {
constructor(private _workerServiceProducer: BullMqClient) {}
@Get('/queue/:name')
async getMessagesGroup(@Param('name') name: string) {
return {
status: 'success',
message: `Queue ${name} is healthy.`,
};
const { valid } =
await this._workerServiceProducer.checkForStuckWaitingJobs(name);
if (valid) {
return {
status: 'success',
message: `Queue ${name} is healthy.`,
};
}
throw new HttpException(
{
status: 'error',
message: `Queue ${name} has stuck waiting jobs.`,
},
503
);
}
}

View file

@ -1,395 +0,0 @@
import {
Body,
Controller,
Get,
HttpException,
Param,
Post,
UseFilters,
} from '@nestjs/common';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import { ApiTags } from '@nestjs/swagger';
import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { NotEnoughScopes } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
@ApiTags('Integrations')
@Controller('/integrations')
export class NoAuthIntegrationsController {
constructor(
private _integrationManager: IntegrationManager,
private _integrationService: IntegrationService,
private _refreshIntegrationService: RefreshIntegrationService,
private _organizationService: OrganizationService
) {}
@Get('/')
getIntegrations() {
return this._integrationManager.getAllIntegrations();
}
@Post('/social-connect/:integration')
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
@UseFilters(new NotEnoughScopesFilter())
async connectSocialMedia(
@Param('integration') integration: string,
@Body() body: ConnectIntegrationDto
) {
if (
!this._integrationManager
.getAllowedSocialsIntegrations()
.includes(integration)
) {
throw new Error('Integration not allowed');
}
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
const getCodeVerifier = integrationProvider.customFields
? 'none'
: await ioRedis.get(`login:${body.state}`);
if (!getCodeVerifier) {
throw new Error('Invalid state');
}
const organization = await ioRedis.get(`organization:${body.state}`);
if (!organization) {
throw new Error('Organization not found');
}
const org = await this._organizationService.getOrgById(organization);
if (!integrationProvider.customFields) {
await ioRedis.del(`login:${body.state}`);
}
const details = integrationProvider.externalUrl
? await ioRedis.get(`external:${body.state}`)
: undefined;
if (details) {
await ioRedis.del(`external:${body.state}`);
}
const refresh = await ioRedis.get(`refresh:${body.state}`);
if (refresh) {
await ioRedis.del(`refresh:${body.state}`);
}
const onboarding = await ioRedis.get(`onboarding:${body.state}`);
if (onboarding) {
await ioRedis.del(`onboarding:${body.state}`);
}
const {
error,
accessToken,
expiresIn,
refreshToken,
id,
name,
picture,
username,
additionalSettings,
// eslint-disable-next-line no-async-promise-executor
} = await new Promise<AuthTokenDetails>(async (res) => {
try {
const auth = await integrationProvider.authenticate(
{
code: body.code,
codeVerifier: getCodeVerifier,
refresh: body.refresh,
},
details ? JSON.parse(details) : undefined
);
if (typeof auth === 'string') {
return res({
error: auth,
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
additionalSettings: [],
});
}
if (refresh && integrationProvider.reConnect) {
console.log('reconnect');
try {
const newAuth = await integrationProvider.reConnect(
auth.id,
refresh,
auth.accessToken
);
return res({ ...newAuth, refreshToken: body.refresh });
} catch (err: any) {
return res({
error: err.message,
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
additionalSettings: [],
});
}
}
return res(auth);
} catch (err) {
if (err instanceof NotEnoughScopes) {
return res({
error: err.message,
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
additionalSettings: [],
});
}
return res({
error: 'Authentication failed',
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
additionalSettings: [],
});
}
});
if (error) {
throw new NotEnoughScopes(error);
}
if (!id) {
throw new NotEnoughScopes('Invalid API key');
}
if (refresh && String(id) !== String(refresh)) {
throw new NotEnoughScopes(
'Please refresh the channel that needs to be refreshed'
);
}
let validName = name;
if (!validName) {
if (username) {
validName = username.split('.')[0] ?? username;
} else {
validName = `Channel_${String(id).slice(0, 8)}`;
}
}
if (
process.env.STRIPE_PUBLISHABLE_KEY &&
org.isTrailing &&
(await this._integrationService.checkPreviousConnections(
org.id,
String(id)
))
) {
throw new HttpException('', 412);
}
const createUpdate =
await this._integrationService.createOrUpdateIntegration(
additionalSettings,
!!integrationProvider.oneTimeToken,
org.id,
validName.trim(),
picture,
'social',
String(id),
integration,
accessToken,
refreshToken,
expiresIn,
username,
refresh ? false : integrationProvider.isBetweenSteps,
body.refresh,
+body.timezone,
details
? AuthService.fixedEncryption(details)
: integrationProvider.customFields
? AuthService.fixedEncryption(
Buffer.from(body.code, 'base64').toString()
)
: integrationProvider.isChromeExtension
? AuthService.signJWT(
JSON.parse(Buffer.from(body.code, 'base64').toString())
)
: undefined
);
this._refreshIntegrationService
.startRefreshWorkflow(org.id, createUpdate.id, integrationProvider)
.catch((err) => {
console.log(err);
});
// Fetch pages if this is a two-step provider and not a refresh
let pages: any[] = [];
if (integrationProvider.isBetweenSteps && !refresh) {
try {
// Check which method the provider uses (pages or companies)
const fetchMethod =
'pages' in integrationProvider
? 'pages'
: 'companies' in integrationProvider
? 'companies'
: null;
if (fetchMethod) {
// @ts-ignore - dynamic method call
pages = await integrationProvider[fetchMethod](accessToken);
}
} catch (err) {
console.log('Failed to fetch pages:', err);
}
}
const webhookUrl = await ioRedis.get(`webhookUrl:${body.state}`);
if (webhookUrl) {
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
params: AuthService.signJWT({
apiKey: org.apiKey,
}),
}),
});
} catch (err) {}
await ioRedis.del(`webhookUrl:${body.state}`);
}
const returnURL = await ioRedis.get(`redirect:${body.state}`);
if (returnURL) {
await ioRedis.del(`redirect:${body.state}`);
}
const extensionToken = integrationProvider.isChromeExtension
? AuthService.signJWT({
integrationId: createUpdate.id,
organizationId: org.id,
internalId: String(id),
provider: integration,
})
: undefined;
return {
...createUpdate,
onboarding: onboarding === 'true',
pages,
...(returnURL ? { returnURL } : {}),
...(extensionToken ? { extensionToken } : {}),
};
}
@Post('/public/provider/:id/connect')
async saveProviderPage(@Param('id') id: string, @Body() body: any) {
if (!body.state) {
throw new Error('Invalid state');
}
const organization = await ioRedis.get(`organization:${body.state}`);
if (!organization) {
throw new Error('Organization not found');
}
const org = await this._organizationService.getOrgById(organization);
return this._integrationService.saveProviderPage(org.id, id, body);
}
@Post('/extension-refresh')
async extensionRefreshCookies(
@Body() body: { jwt: string; cookies: string }
) {
let payload: any;
try {
payload = AuthService.verifyJWT(body.jwt);
} catch {
throw new HttpException('Invalid token', 401);
}
const { integrationId, organizationId, internalId, provider } = payload;
if (!integrationId || !organizationId || !internalId || !provider) {
throw new HttpException('Invalid token payload', 400);
}
const integration = await this._integrationService.getIntegrationById(
organizationId,
integrationId
);
if (!integration || integration.internalId !== internalId) {
throw new HttpException('Integration not found', 404);
}
const integrationProvider =
this._integrationManager.getSocialIntegration(provider);
if (!integrationProvider?.isChromeExtension) {
throw new HttpException('Not a Chrome extension integration', 400);
}
const authResult = await integrationProvider.authenticate({
code: body.cookies,
codeVerifier: '',
});
if (typeof authResult === 'string') {
throw new HttpException(authResult, 400);
}
if (String(authResult.id) !== String(integration.internalId)) {
await this._integrationService.refreshNeeded(
organizationId,
integrationId
);
return { success: false, reason: 'account_mismatch' };
}
await this._integrationService.createOrUpdateIntegration(
undefined,
false,
organizationId,
integration.name,
undefined,
'social',
integration.internalId,
integration.providerIdentifier,
authResult.accessToken,
'',
authResult.expiresIn,
undefined,
false,
undefined,
undefined,
AuthService.signJWT(
JSON.parse(Buffer.from(body.cookies, 'base64').toString())
)
);
return { success: true };
}
}

View file

@ -1,54 +0,0 @@
import { Body, Controller, Delete, Get, Post, Put } from '@nestjs/common';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { CreateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/create-oauth-app.dto';
import { UpdateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/update-oauth-app.dto';
@ApiTags('OAuth App')
@Controller('/user/oauth-app')
export class OAuthAppController {
constructor(private _oauthService: OAuthService) {}
@Get('/')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getApp(@GetOrgFromRequest() org: Organization) {
return this._oauthService.getApp(org.id);
}
@Post('/')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async createApp(
@GetOrgFromRequest() org: Organization,
@Body() body: CreateOAuthAppDto
) {
return this._oauthService.createApp(org.id, body);
}
@Put('/')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async updateApp(
@GetOrgFromRequest() org: Organization,
@Body() body: UpdateOAuthAppDto
) {
return this._oauthService.updateApp(org.id, body);
}
@Delete('/')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async deleteApp(@GetOrgFromRequest() org: Organization) {
return this._oauthService.deleteApp(org.id);
}
@Post('/rotate-secret')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async rotateSecret(@GetOrgFromRequest() org: Organization) {
return this._oauthService.rotateSecret(org.id);
}
}

View file

@ -1,95 +0,0 @@
import {
Body,
Controller,
Get,
HttpException,
HttpStatus,
Post,
Query,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { User, Organization } from '@prisma/client';
import { AuthorizeOAuthQueryDto, ApproveOAuthDto } from '@gitroom/nestjs-libraries/dtos/oauth/authorize-oauth.dto';
import { TokenExchangeDto } from '@gitroom/nestjs-libraries/dtos/oauth/token-exchange.dto';
@ApiTags('OAuth')
@Controller('/oauth')
export class OAuthController {
constructor(private _oauthService: OAuthService) {}
@Get('/authorize')
async authorize(@Query() query: AuthorizeOAuthQueryDto) {
const app = await this._oauthService.validateAuthorizationRequest(
query.client_id
);
return {
app: {
name: app.name,
description: app.description,
picture: app.picture,
clientId: app.clientId,
redirectUrl: app.redirectUrl,
},
state: query.state,
};
}
@Post('/token')
async token(@Body() body: TokenExchangeDto) {
if (body.grant_type !== 'authorization_code') {
throw new HttpException(
{ error: 'unsupported_grant_type' },
HttpStatus.BAD_REQUEST
);
}
return this._oauthService.exchangeCodeForToken(
body.code,
body.client_id,
body.client_secret
);
}
}
@ApiTags('OAuth')
@Controller('/oauth')
export class OAuthAuthorizedController {
constructor(private _oauthService: OAuthService) {}
@Post('/authorize')
async approveOrDeny(
@Body() body: ApproveOAuthDto,
@GetUserFromRequest() user: User,
@GetOrgFromRequest() org: Organization
) {
const app = await this._oauthService.validateAuthorizationRequest(
body.client_id
);
if (body.action === 'deny') {
const redirectUrl = new URL(app.redirectUrl);
redirectUrl.searchParams.set('error', 'access_denied');
if (body.state) {
redirectUrl.searchParams.set('state', body.state);
}
return { redirect: redirectUrl.toString() };
}
const code = await this._oauthService.createAuthorizationCode(
app.id,
user.id,
org.id
);
const redirectUrl = new URL(app.redirectUrl);
redirectUrl.searchParams.set('code', code);
if (body.state) {
redirectUrl.searchParams.set('state', body.state);
}
return { redirect: redirectUrl.toString() };
}
}

View file

@ -3,7 +3,6 @@ import {
Controller,
Delete,
Get,
HttpException,
Param,
Post,
Put,
@ -14,9 +13,10 @@ import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/po
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization, User } from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import { GetPostsListDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.list.dto';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import { ApiTags } from '@nestjs/swagger';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto';
import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto';
import { AgentGraphService } from '@gitroom/nestjs-libraries/agent/agent.graph.service';
@ -24,16 +24,15 @@ import { Response } from 'express';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service';
import { CreateTagDto } from '@gitroom/nestjs-libraries/dtos/posts/create.tag.dto';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/permissions/permission.exception.class';
@ApiTags('Posts')
@Controller('/posts')
export class PostsController {
constructor(
private _postsService: PostsService,
private _starsService: StarsService,
private _messagesService: MessagesService,
private _agentGraphService: AgentGraphService,
private _shortLinkService: ShortLinkService
) {}
@ -46,28 +45,19 @@ export class PostsController {
return this._postsService.getStatistics(org.id, id);
}
@Get('/:id/missing')
async getMissingContent(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this._postsService.getMissingContent(org.id, id);
}
@Put('/:id/release-id')
async updateReleaseId(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body('releaseId') releaseId: string
) {
return this._postsService.updateReleaseId(org.id, id, releaseId);
}
@Post('/should-shortlink')
async shouldShortlink(@Body() body: { messages: string[] }) {
return { ask: this._shortLinkService.askShortLinkedin(body.messages) };
}
@Get('/marketplace/:id')
async getMarketplacePosts(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this._messagesService.getMarketplaceAvailableOffers(org.id, id);
}
@Post('/:id/comments')
async createComment(
@GetOrgFromRequest() org: Organization,
@ -100,20 +90,16 @@ export class PostsController {
return this._postsService.editTag(id, org.id, body);
}
@Delete('/tags/:id')
async deleteTag(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this._postsService.deleteTag(id, org.id);
}
@Get('/')
async getPosts(
@GetOrgFromRequest() org: Organization,
@Query() query: GetPostsDto
) {
return this._postsService.getPostsMinified(org.id, query);
const posts = await this._postsService.getPosts(org.id, query);
return {
posts,
};
}
@Get('/find-slot')
@ -129,12 +115,9 @@ export class PostsController {
return { date: await this._postsService.findFreeDateTime(org.id, id) };
}
@Get('/list')
async getPostsList(
@GetOrgFromRequest() org: Organization,
@Query() query: GetPostsListDto
) {
return this._postsService.getPostsList(org.id, query);
@Get('/predict-trending')
predictTrending() {
return this._starsService.predictTrending();
}
@Get('/old')
@ -145,23 +128,6 @@ export class PostsController {
return this._postsService.getOldPosts(org.id, date);
}
@Get('/group/:group/debug-export')
async getPostGroupDebugExport(
@GetOrgFromRequest() org: Organization,
@GetUserFromRequest() user: User,
@Param('group') group: string
) {
if (!user.isSuperAdmin) {
throw new HttpException('Forbidden', 403);
}
return this._postsService.getPostGroupDebugExport(org.id, group);
}
@Get('/group/:group')
getPostsByGroup(@GetOrgFromRequest() org: Organization, @Param('group') group: string) {
return this._postsService.getPostsByGroup(org.id, group);
}
@Get('/:id')
getPost(@GetOrgFromRequest() org: Organization, @Param('id') id: string) {
return this._postsService.getPost(org.id, id);
@ -175,7 +141,7 @@ export class PostsController {
) {
console.log(JSON.stringify(rawBody, null, 2));
const body = await this._postsService.mapTypeToPost(rawBody, org.id);
return this._postsService.createPost(org.id, body, 'WEB');
return this._postsService.createPost(org.id, body);
}
@Post('/generator/draft')
@ -214,10 +180,9 @@ export class PostsController {
changeDate(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body('date') date: string,
@Body('action') action: 'schedule' | 'update' = 'schedule'
@Body('date') date: string
) {
return this._postsService.changeDate(org.id, id, date, action);
return this._postsService.changeDate(org.id, id, date);
}
@Post('/separate-posts')

View file

@ -10,6 +10,7 @@ import {
StreamableFile,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AgenciesService } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.service';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { TrackService } from '@gitroom/nestjs-libraries/track/track.service';
import { RealIP } from 'nestjs-real-ip';
@ -20,14 +21,8 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { AgentGraphInsertService } from '@gitroom/nestjs-libraries/agent/agent.graph.insert.service';
import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import { Readable, pipeline } from 'stream';
import { promisify } from 'util';
import { OnlyURL } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto';
import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator';
import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
const pump = promisify(pipeline);
@ -35,11 +30,11 @@ const pump = promisify(pipeline);
@Controller('/public')
export class PublicController {
constructor(
private _agenciesService: AgenciesService,
private _trackService: TrackService,
private _agentGraphInsertService: AgentGraphInsertService,
private _postsService: PostsService,
private _nowpayments: Nowpayments,
private _subscriptionService: SubscriptionService
private _nowpayments: Nowpayments
) {}
@Post('/agent')
async createAgent(@Body() body: { text: string; apiKey: string }) {
@ -53,6 +48,26 @@ export class PublicController {
return this._agentGraphInsertService.newPost(body.text);
}
@Get('/agencies-list')
async getAgencyByUser() {
return this._agenciesService.getAllAgencies();
}
@Get('/agencies-list-slug')
async getAgencySlug() {
return this._agenciesService.getAllAgenciesSlug();
}
@Get('/agencies-information/:agency')
async getAgencyInformation(@Param('agency') agency: string) {
return this._agenciesService.getAgencyInformation(agency);
}
@Get('/agencies-list-count')
async getAgenciesCount() {
return this._agenciesService.getCount();
}
@Get(`/posts/:id`)
async getPreview(@Param('id') id: string) {
return (await this._postsService.getPostsRecursively(id, true)).map(
@ -130,32 +145,6 @@ export class PublicController {
});
}
@Post('/modify-subscription')
async modifySubscription(@Body('params') params: string) {
try {
const load = AuthService.verifyJWT(params) as {
orgId: string;
billing: 'FREE' | 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE';
};
if (!load || !load.orgId || !load.billing || !pricing[load.billing]) {
return { success: false };
}
const totalChannels = pricing[load.billing].channel || 0;
await this._subscriptionService.modifySubscriptionByOrg(
load.orgId,
totalChannels,
load.billing
);
return { success: true };
} catch (err) {
return { success: false };
}
}
@Post('/crypto/:path')
async cryptoPost(@Body() body: any, @Param('path') path: string) {
console.log('cryptoPost', body, path);
@ -164,11 +153,10 @@ export class PublicController {
@Get('/stream')
async streamFile(
@Query() query: OnlyURL,
@Query('url') url: string,
@Res() res: Response,
@Req() req: Request
) {
const { url } = query;
if (!url.endsWith('mp4')) {
return res.status(400).send('Invalid video URL');
}
@ -178,47 +166,7 @@ export class PublicController {
req.on('aborted', onClose);
res.on('close', onClose);
// Manually follow redirects so every hop is re-validated against
// the SSRF blocklist (see GHSA-34w8-5j2v-h6ww). `fetch` defaults to
// `redirect: 'follow'`, which bypasses the DTO-level URL check.
const MAX_REDIRECTS = 5;
let currentUrl = url;
let r: globalThis.Response | undefined;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
if (!(await isSafePublicHttpsUrl(currentUrl))) {
return res.status(400).send('Blocked URL');
}
r = await fetch(currentUrl, {
signal: ac.signal,
redirect: 'manual',
// @ts-ignore — undici option, not in lib.dom fetch types
dispatcher: ssrfSafeDispatcher,
});
if (r.status >= 300 && r.status < 400) {
const location = r.headers.get('location');
if (!location) {
return res.status(502).send('Redirect without Location');
}
try {
currentUrl = new URL(location, currentUrl).toString();
} catch {
return res.status(400).send('Invalid redirect target');
}
continue;
}
break;
}
if (!r) {
return res.status(502).send('No upstream response');
}
if (r.status >= 300 && r.status < 400) {
return res.status(508).send('Too many redirects');
}
const r = await fetch(url, { signal: ac.signal });
if (!r.ok && r.status !== 206) {
res.status(r.status);
@ -241,6 +189,7 @@ export class PublicController {
try {
await pump(Readable.fromWeb(r.body as any), res);
} catch (err) {}
} catch (err) {
}
}
}

View file

@ -1,10 +1,10 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { AddTeamMemberDto } from '@gitroom/nestjs-libraries/dtos/settings/add.team.member.dto';
import { ShortlinkPreferenceDto } from '@gitroom/nestjs-libraries/dtos/settings/shortlink-preference.dto';
import { ApiTags } from '@nestjs/swagger';
import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/permissions/permission.exception.class';
@ -12,9 +12,95 @@ import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/p
@Controller('/settings')
export class SettingsController {
constructor(
private _starsService: StarsService,
private _organizationService: OrganizationService
) {}
@Get('/github')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getConnectedGithubAccounts(@GetOrgFromRequest() org: Organization) {
return {
github: (
await this._starsService.getGitHubRepositoriesByOrgId(org.id)
).map((repo) => ({
id: repo.id,
login: repo.login,
})),
};
}
@Post('/github')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async addGitHub(
@GetOrgFromRequest() org: Organization,
@Body('code') code: string
) {
if (!code) {
throw new Error('No code provided');
}
await this._starsService.addGitHub(org.id, code);
}
@Get('/github/url')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
authUrl() {
return {
url: `https://github.com/login/oauth/authorize?client_id=${
process.env.GITHUB_CLIENT_ID
}&scope=${encodeURIComponent(
'user:email'
)}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/settings`
)}`,
};
}
@Get('/organizations/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getOrganizations(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return {
organizations: await this._starsService.getOrganizations(org.id, id),
};
}
@Get('/organizations/:id/:github')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getRepositories(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Param('github') github: string
) {
return {
repositories: await this._starsService.getRepositoriesOfOrganization(
org.id,
id,
github
),
};
}
@Post('/organizations/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async updateGitHubLogin(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body('login') login: string
) {
return this._starsService.updateGitHubLogin(org.id, id, login);
}
@Delete('/repository/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async deleteRepository(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this._starsService.deleteRepository(org.id, id);
}
@Get('/team')
@CheckPolicies(
[AuthorizationActions.Create, Sections.TEAM_MEMBERS],
@ -47,21 +133,4 @@ export class SettingsController {
) {
return this._organizationService.deleteTeamMember(org, id);
}
@Get('/shortlink')
async getShortlinkPreference(@GetOrgFromRequest() org: Organization) {
return this._organizationService.getShortlinkPreference(org.id);
}
@Post('/shortlink')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async updateShortlinkPreference(
@GetOrgFromRequest() org: Organization,
@Body() body: ShortlinkPreferenceDto
) {
return this._organizationService.updateShortlinkPreference(
org.id,
body.shortlink
);
}
}

View file

@ -1,19 +1,47 @@
import {
Controller,
Get,
Header,
HttpException,
Param,
Post,
RawBodyRequest,
Req,
} from '@nestjs/common';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { ApiTags } from '@nestjs/swagger';
import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service';
@ApiTags('Stripe')
@Controller('/stripe')
export class StripeController {
constructor(
private readonly _stripeService: StripeService,
private readonly _codesService: CodesService
) {}
@Post('/connect')
stripeConnect(@Req() req: RawBodyRequest<Request>) {
const event = this._stripeService.validateRequest(
req.rawBody,
// @ts-ignore
req.headers['stripe-signature'],
process.env.STRIPE_SIGNING_KEY_CONNECT
);
// Maybe it comes from another stripe webhook
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (event?.data?.object?.metadata?.service !== 'gitroom') {
return { ok: true };
}
switch (event.type) {
case 'account.updated':
return this._stripeService.updateAccount(event);
default:
return { ok: true };
}
}
@Post('/')
stripe(@Req() req: RawBodyRequest<Request>) {
@ -38,6 +66,8 @@ export class StripeController {
switch (event.type) {
case 'invoice.payment_succeeded':
return this._stripeService.paymentSucceeded(event);
case 'account.updated':
return this._stripeService.updateAccount(event);
case 'customer.subscription.created':
return this._stripeService.createSubscription(event);
case 'customer.subscription.updated':
@ -51,4 +81,11 @@ export class StripeController {
throw new HttpException(e, 500);
}
}
@Get('/lifetime-deal-codes/:provider')
@Header('Content-disposition', 'attachment; filename=codes.csv')
@Header('Content-type', 'text/csv')
async getStripeCodes(@Param('provider') providerToken: string) {
return this._codesService.generateCodes(providerToken);
}
}

View file

@ -14,7 +14,6 @@ import { Organization } from '@prisma/client';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
import { ImportMediaDto } from '@gitroom/nestjs-libraries/dtos/third-party/import-media.dto';
@ApiTags('Third Party')
@Controller('/third-party')
@ -122,52 +121,6 @@ export class ThirdPartyController {
);
}
@Post('/:id/import')
async importMedia(
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string,
@Body() body: ImportMediaDto
) {
const thirdParty = await this._thirdPartyManager.getIntegrationById(
organization.id,
id
);
if (!thirdParty) {
throw new HttpException('Integration not found', 404);
}
const thirdPartyInstance = this._thirdPartyManager.getThirdPartyByName(
thirdParty.identifier
);
if (!thirdPartyInstance) {
throw new HttpException('Invalid identifier', 400);
}
const downloadUrls = await thirdPartyInstance?.instance?.['importMedia']?.(
AuthService.fixedDecryption(thirdParty.apiKey),
body.items
);
if (!downloadUrls || !Array.isArray(downloadUrls)) {
throw new HttpException('Import not supported', 400);
}
const results = [];
for (const item of downloadUrls) {
const file = await this.storage.uploadSimple(item.url);
const saved = await this._mediaService.saveFile(
organization.id,
item.name || file.split('/').pop(),
file
);
results.push(saved);
}
return results;
}
@Post('/:identifier')
async addApiKey(
@GetOrgFromRequest() organization: Organization,

View file

@ -9,7 +9,6 @@ import {
Res,
} from '@nestjs/common';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { sign } from 'jsonwebtoken';
import { Organization, User } from '@prisma/client';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
@ -23,7 +22,6 @@ import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions
import { ApiTags } from '@nestjs/swagger';
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
import { RealIP } from 'nestjs-real-ip';
import { UserAgent } from '@gitroom/nestjs-libraries/user/user.agent';
@ -43,23 +41,6 @@ export class UsersController {
private _userService: UsersService,
private _trackService: TrackService
) {}
@Get('/agent-media-sso')
async getAgentMediaSsoUrl(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization
) {
if (!process.env.AGENT_MEDIA_SSO_KEY) {
throw new HttpException('Agent Media SSO is not configured', 400);
}
const token = sign(
{ id: organization.id, displayName: organization.name },
process.env.AGENT_MEDIA_SSO_KEY
);
return { url: `https://agent-media.ai/sso/${token}` };
}
@Get('/self')
async getSelf(
@GetUserFromRequest() user: User,
@ -87,14 +68,13 @@ export class UsersController {
impersonate: !!impersonate,
isTrailing: !process.env.STRIPE_PUBLISHABLE_KEY ? false : organization?.isTrailing,
allowTrial: organization?.allowTrial,
streakSince: organization?.streakSince || null,
// @ts-ignore
publicApi: organization?.users[0]?.role === 'SUPERADMIN' || organization?.users[0]?.role === 'ADMIN' ? organization?.apiKey : '',
};
}
@Get('/personal')
async getPersonalInformation(@GetUserFromRequest() user: User) {
async getPersonal(@GetUserFromRequest() user: User) {
return this._userService.getPersonal(user.id);
}
@ -145,25 +125,6 @@ export class UsersController {
return this._userService.changePersonal(user.id, body);
}
@Get('/email-notifications')
async getEmailNotifications(@GetUserFromRequest() user: User) {
return this._userService.getEmailNotifications(user.id);
}
@Post('/email-notifications')
async updateEmailNotifications(
@GetUserFromRequest() user: User,
@Body() body: EmailNotificationsDto
) {
return this._userService.updateEmailNotifications(user.id, body);
}
@Post('/api-key/rotate')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async rotateApiKey(@GetOrgFromRequest() organization: Organization) {
return this._orgService.updateApiKey(organization.id);
}
@Get('/subscription')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getSubscription(@GetOrgFromRequest() organization: Organization) {
@ -238,7 +199,6 @@ export class UsersController {
@Post('/logout')
logout(@Res({ passthrough: true }) response: Response) {
response.header('logout', 'true');
response.cookie('auth', '', {
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
...(!process.env.NOT_SECURED

View file

@ -14,7 +14,8 @@ import { ApiTags } from '@nestjs/swagger';
import { WebhooksService } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import {
OnlyURL, UpdateDto, WebhooksDto
UpdateDto,
WebhooksDto,
} from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto';
import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/permissions/permission.exception.class';
@ -54,9 +55,9 @@ export class WebhookController {
}
@Post('/send')
async sendWebhook(@Body() body: any, @Query() query: OnlyURL) {
async sendWebhook(@Body() body: any, @Query('url') url: string) {
try {
await fetch(query.url, {
await fetch(url, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },

View file

@ -3,44 +3,35 @@ import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/databa
import { ApiModule } from '@gitroom/backend/api/api.module';
import { APP_GUARD } from '@nestjs/core';
import { PoliciesGuard } from '@gitroom/backend/services/auth/permissions/permissions.guard';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { PublicApiModule } from '@gitroom/backend/public-api/public.api.module';
import { ThrottlerBehindProxyGuard } from '@gitroom/nestjs-libraries/throttler/throttler.provider';
import { ThrottlerModule } from '@nestjs/throttler';
import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module';
import { McpModule } from '@gitroom/backend/mcp/mcp.module';
import { ThirdPartyModule } from '@gitroom/nestjs-libraries/3rdparties/thirdparty.module';
import { VideoModule } from '@gitroom/nestjs-libraries/videos/video.module';
import { SentryModule } from '@sentry/nestjs/setup';
import { SentryModule } from "@sentry/nestjs/setup";
import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
import { ChatModule } from '@gitroom/nestjs-libraries/chat/chat.module';
import { getTemporalModule } from '@gitroom/nestjs-libraries/temporal/temporal.module';
import { TemporalRegisterMissingSearchAttributesModule } from '@gitroom/nestjs-libraries/temporal/temporal.register';
import { InfiniteWorkflowRegisterModule } from '@gitroom/nestjs-libraries/temporal/infinite.workflow.register';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
@Global()
@Module({
imports: [
SentryModule.forRoot(),
BullMqModule,
DatabaseModule,
ApiModule,
PublicApiModule,
AgentModule,
McpModule,
ThirdPartyModule,
VideoModule,
ChatModule,
getTemporalModule(false),
TemporalRegisterMissingSearchAttributesModule,
InfiniteWorkflowRegisterModule,
ThrottlerModule.forRoot({
throttlers: [
{
ttl: 3600000,
limit: process.env.API_LIMIT ? Number(process.env.API_LIMIT) : 90,
},
],
storage: new ThrottlerStorageRedisService(ioRedis),
}),
ThrottlerModule.forRoot([
{
ttl: 3600000,
limit: process.env.API_LIMIT ? Number(process.env.API_LIMIT) : 30,
},
]),
],
controllers: [],
providers: [
@ -52,15 +43,16 @@ import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
{
provide: APP_GUARD,
useClass: PoliciesGuard,
},
}
],
exports: [
BullMqModule,
DatabaseModule,
ApiModule,
PublicApiModule,
AgentModule,
McpModule,
ThrottlerModule,
ChatModule,
],
})
export class AppModule {}

View file

@ -1,11 +1,4 @@
import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry';
initializeSentry('backend', true);
import compression from 'compression';
import { loadSwagger } from '@gitroom/helpers/swagger/load.swagger';
import { json } from 'express';
import { Runtime } from '@temporalio/worker';
Runtime.install({ shutdownSignals: [] });
process.env.TZ = 'UTC';
@ -14,53 +7,38 @@ import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry';
initializeSentry('backend', true);
import { SubscriptionExceptionFilter } from '@gitroom/backend/services/auth/permissions/subscription.exception';
import { HttpExceptionFilter } from '@gitroom/nestjs-libraries/services/exception.filter';
import { ConfigurationChecker } from '@gitroom/helpers/configuration/configuration.checker';
import { startMcp } from '@gitroom/nestjs-libraries/chat/start.mcp';
async function start() {
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true,
cors: {
...(!process.env.NOT_SECURED ? { credentials: true } : {}),
allowedHeaders: [
'Content-Type',
'Authorization',
'auth',
'showorg',
'impersonate',
'x-copilotkit-runtime-client-gql-version',
],
exposedHeaders: [
'reload',
'onboarding',
'activate',
'x-copilotkit-runtime-client-gql-version',
...(process.env.NOT_SECURED ? ['auth', 'showorg', 'impersonate'] : []),
],
origin: [
process.env.FRONTEND_URL,
'http://localhost:6274',
...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []),
],
},
});
await startMcp(app);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
})
);
app.use(['/copilot/{*splat}', '/posts'], (req: any, res: any, next: any) => {
json({ limit: '50mb' })(req, res, next);
});
app.use(cookieParser());
app.use(compression());
app.useGlobalFilters(new SubscriptionExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
@ -70,7 +48,6 @@ async function start() {
try {
await app.listen(port);
console.log('Backend started successfully on port ' + port);
checkConfiguration(); // Do this last, so that users will see obvious issues at the end of the startup log without having to scroll up.
@ -92,8 +69,8 @@ function checkConfiguration() {
Logger.warn('Configuration issues found: ' + checker.getIssuesCount());
} else {
Logger.log('Configuration check completed without any issues');
Logger.log('Configuration check completed without any issues.');
}
}
start();
bootstrap();

View file

@ -0,0 +1,113 @@
import { Injectable } from '@nestjs/common';
import { McpTool } from '@gitroom/nestjs-libraries/mcp/mcp.tool';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { string, array, enum as eenum, object, boolean } from 'zod';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import dayjs from 'dayjs';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
@Injectable()
export class MainMcp {
constructor(
private _integrationService: IntegrationService,
private _postsService: PostsService,
private _openAiService: OpenaiService
) {}
@McpTool({ toolName: 'POSTIZ_GET_CONFIG_ID' })
async preRun() {
return [
{
type: 'text',
text: `id: ${makeId(10)} Today date is ${dayjs.utc().format()}`,
},
];
}
@McpTool({ toolName: 'POSTIZ_PROVIDERS_LIST' })
async listOfProviders(organization: string) {
const list = (
await this._integrationService.getIntegrationsList(organization)
).map((org) => ({
id: org.id,
name: org.name,
identifier: org.providerIdentifier,
picture: org.picture,
disabled: org.disabled,
profile: org.profile,
customer: org.customer
? {
id: org.customer.id,
name: org.customer.name,
}
: undefined,
}));
return [{ type: 'text', text: JSON.stringify(list) }];
}
@McpTool({
toolName: 'POSTIZ_SCHEDULE_POST',
zod: {
type: eenum(['draft', 'schedule']),
configId: string(),
generatePictures: boolean(),
date: string().describe('UTC TIME'),
providerId: string().describe('Use POSTIZ_PROVIDERS_LIST to get the id'),
posts: array(object({ text: string(), images: array(string()) })),
},
})
async schedulePost(
organization: string,
obj: {
type: 'draft' | 'schedule';
generatePictures: boolean;
date: string;
providerId: string;
posts: { text: string }[];
}
) {
const create = await this._postsService.createPost(organization, {
date: obj.date,
type: obj.type,
tags: [],
shortLink: false,
posts: [
{
group: makeId(10),
value: await Promise.all(
obj.posts.map(async (post) => ({
content: post.text,
id: makeId(10),
image: !obj.generatePictures
? []
: [
{
id: makeId(10),
path: await this._openAiService.generateImage(
post.text,
true
),
},
],
}))
),
settings: {
__type: 'any' as any,
},
integration: {
id: obj.providerId,
},
},
],
});
return [
{
type: 'text',
text: `Post created successfully, check it here: ${process.env.FRONTEND_URL}/p/${create[0].postId}`,
},
];
}
}

View file

@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { MainMcp } from '@gitroom/backend/mcp/main.mcp';
@Global()
@Module({
imports: [],
controllers: [],
providers: [MainMcp],
get exports() {
return [...this.providers];
},
})
export class McpModule {}

View file

@ -6,13 +6,10 @@ import {
HttpException,
Param,
Post,
Put,
Query,
UploadedFile,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { CustomFileValidationPipe } from '@gitroom/nestjs-libraries/upload/custom.upload.validation';
import { ApiTags } from '@nestjs/swagger';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
@ -23,41 +20,12 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import { ChangePostStatusDto } from '@gitroom/nestjs-libraries/dtos/posts/change.post.status.dto';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto';
import { VideoFunctionDto } from '@gitroom/nestjs-libraries/dtos/videos/video.function.dto';
import { UploadDto } from '@gitroom/nestjs-libraries/dtos/media/upload.dto';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { GetNotificationsDto } from '@gitroom/nestjs-libraries/dtos/notifications/get.notifications.dto';
import { Readable } from 'stream';
import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { fromBuffer } = require('file-type');
const PUBLIC_API_ALLOWED_MIME = new Set<string>([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/tiff',
'video/mp4',
]);
import * as Sentry from '@sentry/nestjs';
import {
socialIntegrationList,
IntegrationManager,
} from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { getValidationSchemas } from '@gitroom/nestjs-libraries/chat/validation.schemas.helper';
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { timer } from '@gitroom/helpers/utils/timer';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
@ApiTags('Public API')
@Controller('/public/v1')
@ -67,20 +35,15 @@ export class PublicIntegrationsController {
constructor(
private _integrationService: IntegrationService,
private _postsService: PostsService,
private _mediaService: MediaService,
private _notificationService: NotificationService,
private _integrationManager: IntegrationManager,
private _refreshIntegrationService: RefreshIntegrationService
private _mediaService: MediaService
) {}
@Post('/upload')
@UseInterceptors(FileInterceptor('file'))
@UsePipes(new CustomFileValidationPipe())
async uploadSimple(
@GetOrgFromRequest() org: Organization,
@UploadedFile('file') file: Express.Multer.File
) {
Sentry.metrics.count('public_api-request', 1);
if (!file) {
throw new HttpException({ msg: 'No file provided' }, 400);
}
@ -93,62 +56,11 @@ export class PublicIntegrationsController {
);
}
@Post('/upload-from-url')
async uploadsFromUrl(
@GetOrgFromRequest() org: Organization,
@Body() body: UploadDto
) {
Sentry.metrics.count('public_api-request', 1);
const response = await fetch(body.url, {
// @ts-ignore — undici option, not in lib.dom fetch types
dispatcher: ssrfSafeDispatcher,
});
if (!response.ok) {
throw new HttpException({ msg: 'Failed to fetch URL' }, 400);
}
const buffer = Buffer.from(await response.arrayBuffer());
const detected = await fromBuffer(buffer);
if (!detected || !PUBLIC_API_ALLOWED_MIME.has(detected.mime)) {
throw new HttpException({ msg: 'Unsupported file type.' }, 400);
}
const mimetype = detected.mime;
const ext = detected.ext;
const getFile = await this.storage.uploadFile({
buffer,
mimetype,
size: buffer.length,
path: '',
fieldname: '',
destination: '',
stream: new Readable(),
filename: '',
originalname: `upload.${ext}`,
encoding: '',
});
return this._mediaService.saveFile(
org.id,
getFile.originalname,
getFile.path
);
}
@Get('/find-slot/:id')
async findSlotIntegration(
@GetOrgFromRequest() org: Organization,
@Param('id') id?: string
) {
Sentry.metrics.count('public_api-request', 1);
return { date: await this._postsService.findFreeDateTime(org.id, id) };
}
@Get('/posts')
async getPosts(
@GetOrgFromRequest() org: Organization,
@Query() query: GetPostsDto
) {
Sentry.metrics.count('public_api-request', 1);
const posts = await this._postsService.getPosts(org.id, query);
return {
posts,
@ -162,7 +74,6 @@ export class PublicIntegrationsController {
@GetOrgFromRequest() org: Organization,
@Body() rawBody: any
) {
Sentry.metrics.count('public_api-request', 1);
const body = await this._postsService.mapTypeToPost(
rawBody,
org.id,
@ -170,63 +81,26 @@ export class PublicIntegrationsController {
);
body.type = rawBody.type;
if (
process.env.RESTRICT_UPLOAD_DOMAINS &&
body.posts.some((p) =>
p.value.some((a) =>
a.image.some(
(i) => i.path.indexOf(process.env.RESTRICT_UPLOAD_DOMAINS) === -1
)
)
)
) {
throw new HttpException(
{
msg: `All media must be uploaded through our upload API route and contain the domain: ${process.env.RESTRICT_UPLOAD_DOMAINS}`,
},
400
);
}
const allowedCreationMethods = ['CLI', 'API'] as const;
const creationMethod = allowedCreationMethods.includes(
rawBody.creationMethod
)
? (rawBody.creationMethod as 'CLI' | 'API')
: 'API';
console.log(JSON.stringify(body, null, 2));
return this._postsService.createPost(org.id, body, creationMethod);
return this._postsService.createPost(org.id, body);
}
@Delete('/posts/:id')
async deletePost(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
@Param() body: { id: string }
) {
Sentry.metrics.count('public_api-request', 1);
const getPostById = await this._postsService.getPost(org.id, id);
const getPostById = await this._postsService.getPost(org.id, body.id);
return this._postsService.deletePost(org.id, getPostById.group);
}
@Delete('/posts/group/:group')
deletePostByGroup(
@GetOrgFromRequest() org: Organization,
@Param('group') group: string
) {
Sentry.metrics.count('public_api-request', 1);
return this._postsService.deletePost(org.id, group);
}
@Get('/is-connected')
async getActiveIntegrations(@GetOrgFromRequest() org: Organization) {
Sentry.metrics.count('public_api-request', 1);
return { connected: true };
}
@Get('/integrations')
async listIntegration(@GetOrgFromRequest() org: Organization) {
Sentry.metrics.count('public_api-request', 1);
return (await this._integrationService.getIntegrationsList(org.id)).map(
(org) => ({
id: org.id,
@ -245,271 +119,18 @@ export class PublicIntegrationsController {
);
}
@Get('/social/:integration')
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
async getIntegrationUrl(
@Param('integration') integration: string,
@Query('refresh') refresh: string,
@GetOrgFromRequest() org: Organization
) {
Sentry.metrics.count('public_api-request', 1);
if (
!this._integrationManager
.getAllowedSocialsIntegrations()
.includes(integration)
) {
throw new HttpException({ msg: 'Integration not allowed' }, 400);
}
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
if (integrationProvider.externalUrl) {
throw new HttpException(
{
msg: 'This integration requires an external URL and is not supported via the public API',
},
400
);
}
try {
const { codeVerifier, state, url } =
await integrationProvider.generateAuthUrl();
if (refresh) {
await ioRedis.set(`refresh:${state}`, refresh, 'EX', 3600);
}
await ioRedis.set(`organization:${state}`, org.id, 'EX', 3600);
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 3600);
return { url };
} catch (err) {
throw new HttpException({ msg: 'Failed to generate auth URL' }, 500);
}
}
@Get('/notifications')
async getNotifications(
@GetOrgFromRequest() org: Organization,
@Query() query: GetNotificationsDto
) {
Sentry.metrics.count('public_api-request', 1);
return this._notificationService.getNotificationsPaginated(
org.id,
query.page ?? 0
);
}
@Post('/generate-video')
generateVideo(
@GetOrgFromRequest() org: Organization,
@Body() body: VideoDto
) {
Sentry.metrics.count('public_api-request', 1);
return this._mediaService.generateVideo(org, body);
}
@Post('/video/function')
videoFunction(@Body() body: VideoFunctionDto) {
Sentry.metrics.count('public_api-request', 1);
return this._mediaService.videoFunction(
body.identifier,
body.functionName,
body.params
);
}
@Delete('/integrations/:id')
async deleteChannel(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
videoFunction(
@Body() body: VideoFunctionDto
) {
Sentry.metrics.count('public_api-request', 1);
const isTherePosts = await this._integrationService.getPostsForChannel(
org.id,
id
);
if (isTherePosts.length) {
for (const post of isTherePosts) {
this._postsService.deletePost(org.id, post.group).catch(() => {});
}
}
return this._integrationService.deleteChannel(org.id, id);
}
@Get('/integration-settings/:id')
async getIntegrationSettings(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
Sentry.metrics.count('public_api-request', 1);
const loadIntegration = await this._integrationService.getIntegrationById(
org.id,
id
);
const verified =
JSON.parse(loadIntegration.additionalSettings || '[]')?.find(
(p: any) => p?.title === 'Verified'
)?.value || false;
const integration = socialIntegrationList.find(
(p) => p.identifier === loadIntegration.providerIdentifier
)!;
if (!integration) {
return {
output: { rules: '', maxLength: 0, settings: {}, tools: [] as any[] },
};
}
const maxLength = integration.maxLength(verified);
const schemas = !integration.dto
? false
: getValidationSchemas()[integration.dto.name];
const tools = this._integrationManager.getAllTools();
const rules = this._integrationManager.getAllRulesDescription();
return {
output: {
rules: rules[integration.identifier],
maxLength,
settings: !schemas ? 'No additional settings required' : schemas,
tools: tools[integration.identifier],
},
};
}
@Get('/posts/:id/missing')
async getMissingContent(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
Sentry.metrics.count('public_api-request', 1);
return this._postsService.getMissingContent(org.id, id);
}
@Put('/posts/:id/status')
async changePostStatus(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body() body: ChangePostStatusDto
) {
Sentry.metrics.count('public_api-request', 1);
return this._postsService.changePostStatus(org.id, id, body.status);
}
@Put('/posts/:id/release-id')
async updateReleaseId(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body('releaseId') releaseId: string
) {
Sentry.metrics.count('public_api-request', 1);
return this._postsService.updateReleaseId(org.id, id, releaseId);
}
@Get('/analytics/:integration')
async getAnalytics(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@Query('date') date: string
) {
Sentry.metrics.count('public_api-request', 1);
return this._integrationService.checkAnalytics(org, integration, date);
}
@Get('/analytics/post/:postId')
async getPostAnalytics(
@GetOrgFromRequest() org: Organization,
@Param('postId') postId: string,
@Query('date') date: string
) {
Sentry.metrics.count('public_api-request', 1);
return this._postsService.checkPostAnalytics(org.id, postId, +date);
}
@Post('/integration-trigger/:id')
async triggerIntegrationTool(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body() body: { methodName: string; data: Record<string, string> }
) {
Sentry.metrics.count('public_api-request', 1);
const getIntegration = await this._integrationService.getIntegrationById(
org.id,
id
);
if (!getIntegration) {
throw new HttpException({ msg: 'Integration not found' }, 404);
}
const integrationProvider = socialIntegrationList.find(
(p) => p.identifier === getIntegration.providerIdentifier
)!;
if (!integrationProvider) {
throw new HttpException({ msg: 'Integration provider not found' }, 404);
}
const tools = this._integrationManager.getAllTools();
if (
// @ts-ignore
!tools[integrationProvider.identifier]?.some(
(p: any) => p.methodName === body.methodName
) ||
// @ts-ignore
!integrationProvider[body.methodName]
) {
throw new HttpException({ msg: 'Tool not found' }, 404);
}
while (true) {
try {
// @ts-ignore
const result = await integrationProvider[body.methodName](
getIntegration.token,
body.data || {},
getIntegration.internalId,
getIntegration
);
return { output: result };
} catch (err) {
if (err instanceof RefreshToken) {
const data = await this._refreshIntegrationService.refresh(
getIntegration
);
if (!data) {
await this._integrationService.disconnectChannel(
org.id,
getIntegration
);
throw new HttpException(
{ msg: 'Channel disconnected due to expired token' },
401
);
}
const { accessToken } = data;
if (accessToken) {
getIntegration.token = accessToken;
if (integrationProvider.refreshWait) {
await timer(10000);
}
continue;
}
}
throw new HttpException({ msg: 'Unexpected error' }, 500);
}
}
return this._mediaService.videoFunction(body.identifier, body.functionName, body.params);
}
}

View file

@ -6,7 +6,6 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';
export const removeAuth = (res: Response) => {
res.cookie('auth', '', {
@ -21,6 +20,7 @@ export const removeAuth = (res: Response) => {
expires: new Date(0),
maxAge: -1,
});
res.header('logout', 'true');
};

View file

@ -5,7 +5,7 @@ import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service';
import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager';
import { ProvidersFactory } from '@gitroom/backend/services/auth/providers/providers.factory';
import dayjs from 'dayjs';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto';
@ -18,14 +18,10 @@ export class AuthService {
private _userService: UsersService,
private _organizationService: OrganizationService,
private _notificationService: NotificationService,
private _emailService: EmailService,
private _providerManager: AuthProviderManager
private _emailService: EmailService
) {}
async canRegister(provider: string) {
if (
process.env.DISABLE_REGISTRATION !== 'true' ||
provider === Provider.GENERIC
) {
if (process.env.DISABLE_REGISTRATION !== 'true' || provider === Provider.GENERIC) {
return true;
}
@ -43,9 +39,6 @@ export class AuthService {
if (process.env.DISALLOW_PLUS && body.email.includes('+')) {
throw new Error('Email with plus sign is not allowed');
}
if (body instanceof CreateOrgUserDto) {
body.email = body.email.toLowerCase();
}
const user = await this._userService.getUserByEmail(body.email);
if (body instanceof CreateOrgUserDto) {
if (user) {
@ -76,8 +69,7 @@ export class AuthService {
await this._emailService.sendEmail(
body.email,
'Activate your account',
`Click <a href="${process.env.FRONTEND_URL}/auth/activate/${obj.jwt}">here</a> to activate your account`,
'top'
`Click <a href="${process.env.FRONTEND_URL}/auth/activate/${obj.jwt}">here</a> to activate your account`
);
return obj;
}
@ -140,7 +132,7 @@ export class AuthService {
ip: string,
userAgent: string
) {
const providerInstance = this._providerManager.getProvider(provider);
const providerInstance = ProvidersFactory.loadProvider(provider);
const providerUser = await providerInstance.getUser(body.providerToken);
if (!providerUser) {
@ -166,54 +158,16 @@ export class AuthService {
password: '',
provider,
providerId: providerUser.id,
datafast_visitor_id: body.datafast_visitor_id,
},
ip,
userAgent
);
this._track('register', providerUser.email, body.datafast_visitor_id).catch(
(err) => {}
);
await NewsletterService.register(providerUser.email);
try {
if (providerInstance?.postRegistration) {
await providerInstance.postRegistration(body.providerToken, create.id);
}
} catch (err) {
// Don't fail registration if postRegistration fails
}
return create.users[0].user;
}
private async _track(
name: string,
email: string,
datafast_visitor_id: string
) {
if (email && datafast_visitor_id && process.env.DATAFAST_API_KEY) {
try {
await fetch('https://datafa.st/api/v1/goals', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DATAFAST_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
datafast_visitor_id: datafast_visitor_id,
name: name,
metadata: {
email,
},
}),
});
} catch (err) {}
}
}
async forgot(email: string) {
const user = await this._userService.getUserByEmail(email);
if (!user || user.providerName !== Provider.LOCAL) {
@ -244,7 +198,7 @@ export class AuthService {
return this._userService.updatePassword(user.id, body.password);
}
async activate(code: string, tracking: string) {
async activate(code: string) {
const user = AuthChecker.verifyJWT(code) as {
id: string;
activated: boolean;
@ -257,7 +211,6 @@ export class AuthService {
}
await this._userService.activateUser(user.id);
user.activated = true;
this._track('register', user.email, tracking).catch((err) => {});
await NewsletterService.register(user.email);
return this.jwt(user as any);
}
@ -265,37 +218,18 @@ export class AuthService {
return false;
}
async resendActivationEmail(email: string) {
const user = await this._userService.getUserByEmail(email);
if (!user) {
throw new Error('User not found');
}
if (user.activated) {
throw new Error('Account is already activated');
}
const jwt = await this.jwt(user);
await this._emailService.sendEmail(
user.email,
'Activate your account',
`Click <a href="${process.env.FRONTEND_URL}/auth/activate/${jwt}">here</a> to activate your account`,
'top'
);
return true;
}
oauthLink(provider: string, query?: any) {
const providerInstance = this._providerManager.getProvider(provider);
const providerInstance = ProvidersFactory.loadProvider(
provider as Provider
);
return providerInstance.generateLink(query);
}
async checkExists(provider: string, code: string, redirectUri?: string) {
const providerInstance = this._providerManager.getProvider(provider);
const token = await providerInstance.getToken(code, redirectUri);
async checkExists(provider: string, code: string) {
const providerInstance = ProvidersFactory.loadProvider(
provider as Provider
);
const token = await providerInstance.getToken(code);
const user = await providerInstance.getUser(token);
if (!user) {
throw new Error('Invalid user');
@ -312,9 +246,6 @@ export class AuthService {
}
private async jwt(user: User) {
if (user.password) {
delete user.password;
}
return AuthChecker.signJWT(user);
}
}

View file

@ -23,9 +23,7 @@ export class PoliciesGuard implements CanActivate {
const request: Request = context.switchToHttp().getRequest();
if (
request.path.indexOf('/auth') > -1 ||
request.path.indexOf('/auth') > -1 ||
request.path.indexOf('/integrations/social-connect') > -1 ||
request.path.indexOf('/integrations/provider') > -1
request.path.indexOf('/stripe') > -1
) {
return true;
}
@ -44,10 +42,8 @@ export class PoliciesGuard implements CanActivate {
// @ts-expect-error
const { org }: { org: Organization } = request;
const refreshChannelId = typeof request.query?.refresh === 'string' ? request.query.refresh : undefined;
// @ts-ignore
const ability = await this._authorizationService.check(org.id, org.createdAt, org.users[0].role, policyHandlers, refreshChannelId);
const ability = await this._authorizationService.check(org.id, org.createdAt, org.users[0].role, policyHandlers);
const item = policyHandlers.find(
(handler) => !this.execPolicyHandler(handler, ability)

View file

@ -40,8 +40,7 @@ export class PermissionsService {
orgId: string,
created_at: Date,
permission: 'USER' | 'ADMIN' | 'SUPERADMIN',
requestedPermission: Array<[AuthorizationActions, Sections]>,
refreshChannelId?: string
requestedPermission: Array<[AuthorizationActions, Sections]>
) {
const { can, build } = new AbilityBuilder<
Ability<[AuthorizationActions, Sections]>
@ -66,20 +65,6 @@ export class PermissionsService {
for (const [action, section] of requestedPermission) {
// check for the amount of channels
if (section === Sections.CHANNEL) {
// Refreshing an existing channel doesn't add a new one, so skip the limit check
// but only if the channel actually belongs to this org
if (refreshChannelId) {
const existingIntegration =
await this._integrationService.getIntegrationById(
orgId,
refreshChannelId
);
if (existingIntegration) {
can(action, section);
continue;
}
}
const totalChannels = (
await this._integrationService.getIntegrationsList(orgId)
).filter((f) => !f.refreshNeeded).length;

View file

@ -1,34 +1,7 @@
import { Injectable } from '@nestjs/common';
export abstract class AuthProviderAbstract {
abstract generateLink(query?: any): Promise<string> | string;
abstract getToken(code: string, redirectUri?: string): Promise<string>;
abstract getUser(
export interface ProvidersInterface {
generateLink(query?: any): Promise<string> | string;
getToken(code: string): Promise<string>;
getUser(
providerToken: string
): Promise<{ email: string; id: string }> | false;
async postRegistration(
providerToken: string,
orgId: string
): Promise<void> {}
}
export interface AuthProviderParams {
provider: string;
}
export function AuthProvider(params: AuthProviderParams) {
return function (target: any) {
Injectable()(target);
const existingMetadata =
Reflect.getMetadata('auth-provider', AuthProviderAbstract) || [];
existingMetadata.push({ target, provider: params.provider });
Reflect.defineMetadata(
'auth-provider',
existingMetadata,
AuthProviderAbstract
);
};
}

View file

@ -1,20 +1,16 @@
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
const client = new NeynarAPIClient({
apiKey: process.env.NEYNAR_SECRET_KEY || '00000000-000-0000-000-000000000000',
});
@AuthProvider({ provider: 'FARCASTER' })
export class FarcasterProvider extends AuthProviderAbstract {
export class FarcasterProvider implements ProvidersInterface {
generateLink() {
return '';
}
async getToken(code: string, _redirectUri?: string) {
async getToken(code: string) {
const data = JSON.parse(Buffer.from(code, 'base64').toString());
const status = await client.lookupSigner({ signerUuid: data.signer_uuid });
if (status.status === 'approved') {
@ -33,6 +29,11 @@ export class FarcasterProvider extends AuthProviderAbstract {
};
}
// const { client, oauth2 } = clientAndYoutube();
// client.setCredentials({ access_token: providerToken });
// const user = oauth2(client);
// const { data } = await user.userinfo.get();
return {
id: String('farcaster_' + status.fid),
email: String('farcaster_' + status.fid),

View file

@ -1,10 +1,6 @@
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
@AuthProvider({ provider: 'GITHUB' })
export class GithubProvider extends AuthProviderAbstract {
export class GithubProvider implements ProvidersInterface {
generateLink(): string {
return `https://github.com/login/oauth/authorize?client_id=${
process.env.GITHUB_CLIENT_ID
@ -13,7 +9,7 @@ export class GithubProvider extends AuthProviderAbstract {
)}`;
}
async getToken(code: string, _redirectUri?: string): Promise<string> {
async getToken(code: string): Promise<string> {
const { access_token } = await (
await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',

View file

@ -1,28 +1,45 @@
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { google } from 'googleapis';
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
const defaultRedirect = () =>
`${process.env.FRONTEND_URL}/integrations/social/youtube`;
const makeClient = (redirectUri: string) =>
new google.auth.OAuth2({
const clientAndYoutube = () => {
const client = new google.auth.OAuth2({
clientId: process.env.YOUTUBE_CLIENT_ID,
clientSecret: process.env.YOUTUBE_CLIENT_SECRET,
redirectUri,
redirectUri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
});
@AuthProvider({ provider: 'GOOGLE' })
export class GoogleProvider extends AuthProviderAbstract {
generateLink(query?: { redirect_uri?: string }) {
const redirectUri = query?.redirect_uri || defaultRedirect();
return makeClient(redirectUri).generateAuthUrl({
const youtube = (newClient: OAuth2Client) =>
google.youtube({
version: 'v3',
auth: newClient,
});
const youtubeAnalytics = (newClient: OAuth2Client) =>
google.youtubeAnalytics({
version: 'v2',
auth: newClient,
});
const oauth2 = (newClient: OAuth2Client) =>
google.oauth2({
version: 'v2',
auth: newClient,
});
return { client, youtube, oauth2, youtubeAnalytics };
};
export class GoogleProvider implements ProvidersInterface {
generateLink() {
const state = makeId(7);
const { client } = clientAndYoutube();
return client.generateAuthUrl({
access_type: 'online',
prompt: 'consent',
state: 'login',
redirect_uri: redirectUri,
state,
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
scope: [
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
@ -30,22 +47,21 @@ export class GoogleProvider extends AuthProviderAbstract {
});
}
async getToken(code: string, redirectUri?: string) {
const client = makeClient(redirectUri || defaultRedirect());
async getToken(code: string) {
const { client, oauth2 } = clientAndYoutube();
const { tokens } = await client.getToken(code);
return tokens.access_token!;
return tokens.access_token;
}
async getUser(providerToken: string) {
const client = makeClient(defaultRedirect());
const { client, oauth2 } = clientAndYoutube();
client.setCredentials({ access_token: providerToken });
const { data } = await google
.oauth2({ version: 'v2', auth: client })
.userinfo.get();
const user = oauth2(client);
const { data } = await user.userinfo.get();
return {
id: data.id!,
email: data.email!,
email: data.email,
};
}
}

View file

@ -1,56 +1,66 @@
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
@AuthProvider({ provider: 'GENERIC' })
export class OauthProvider extends AuthProviderAbstract {
private getConfig() {
export class OauthProvider implements ProvidersInterface {
private readonly authUrl: string;
private readonly baseUrl: string;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly frontendUrl: string;
private readonly tokenUrl: string;
private readonly userInfoUrl: string;
constructor() {
const {
POSTIZ_OAUTH_AUTH_URL,
POSTIZ_OAUTH_CLIENT_ID,
POSTIZ_OAUTH_CLIENT_SECRET,
POSTIZ_OAUTH_TOKEN_URL,
POSTIZ_OAUTH_URL,
POSTIZ_OAUTH_USERINFO_URL,
FRONTEND_URL,
} = process.env;
if (
!POSTIZ_OAUTH_USERINFO_URL ||
!POSTIZ_OAUTH_TOKEN_URL ||
!POSTIZ_OAUTH_CLIENT_ID ||
!POSTIZ_OAUTH_CLIENT_SECRET ||
!POSTIZ_OAUTH_AUTH_URL ||
!FRONTEND_URL
) {
throw new Error('POSTIZ_OAUTH environment variables are not set');
}
if (!POSTIZ_OAUTH_USERINFO_URL)
throw new Error(
'POSTIZ_OAUTH_USERINFO_URL environment variable is not set'
);
if (!POSTIZ_OAUTH_URL)
throw new Error('POSTIZ_OAUTH_URL environment variable is not set');
if (!POSTIZ_OAUTH_TOKEN_URL)
throw new Error('POSTIZ_OAUTH_TOKEN_URL environment variable is not set');
if (!POSTIZ_OAUTH_CLIENT_ID)
throw new Error('POSTIZ_OAUTH_CLIENT_ID environment variable is not set');
if (!POSTIZ_OAUTH_CLIENT_SECRET)
throw new Error(
'POSTIZ_OAUTH_CLIENT_SECRET environment variable is not set'
);
if (!POSTIZ_OAUTH_AUTH_URL)
throw new Error('POSTIZ_OAUTH_AUTH_URL environment variable is not set');
if (!FRONTEND_URL)
throw new Error('FRONTEND_URL environment variable is not set');
return {
authUrl: POSTIZ_OAUTH_AUTH_URL,
clientId: POSTIZ_OAUTH_CLIENT_ID,
clientSecret: POSTIZ_OAUTH_CLIENT_SECRET,
tokenUrl: POSTIZ_OAUTH_TOKEN_URL,
userInfoUrl: POSTIZ_OAUTH_USERINFO_URL,
frontendUrl: FRONTEND_URL,
};
this.authUrl = POSTIZ_OAUTH_AUTH_URL;
this.baseUrl = POSTIZ_OAUTH_URL;
this.clientId = POSTIZ_OAUTH_CLIENT_ID;
this.clientSecret = POSTIZ_OAUTH_CLIENT_SECRET;
this.frontendUrl = FRONTEND_URL;
this.tokenUrl = POSTIZ_OAUTH_TOKEN_URL;
this.userInfoUrl = POSTIZ_OAUTH_USERINFO_URL;
}
generateLink(): string {
const { authUrl, clientId, frontendUrl } = this.getConfig();
const params = new URLSearchParams({
client_id: clientId,
client_id: this.clientId,
scope: 'openid profile email',
response_type: 'code',
redirect_uri: `${frontendUrl}/settings`,
redirect_uri: `${this.frontendUrl}/settings`,
});
return `${authUrl}?${params.toString()}`;
return `${this.authUrl}/?${params.toString()}`;
}
async getToken(code: string, _redirectUri?: string): Promise<string> {
const { tokenUrl, clientId, clientSecret, frontendUrl } = this.getConfig();
const response = await fetch(`${tokenUrl}`, {
async getToken(code: string): Promise<string> {
const response = await fetch(`${this.tokenUrl}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@ -58,10 +68,10 @@ export class OauthProvider extends AuthProviderAbstract {
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
client_id: this.clientId,
client_secret: this.clientSecret,
code,
redirect_uri: `${frontendUrl}/settings`,
redirect_uri: `${this.frontendUrl}/settings`,
}),
});
@ -75,8 +85,7 @@ export class OauthProvider extends AuthProviderAbstract {
}
async getUser(access_token: string): Promise<{ email: string; id: string }> {
const { userInfoUrl } = this.getConfig();
const response = await fetch(`${userInfoUrl}`, {
const response = await fetch(`${this.userInfoUrl}`, {
headers: {
Authorization: `Bearer ${access_token}`,
Accept: 'application/json',

View file

@ -0,0 +1,24 @@
import { Provider } from '@prisma/client';
import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider';
import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider';
import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider';
import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider';
export class ProvidersFactory {
static loadProvider(provider: Provider): ProvidersInterface {
switch (provider) {
case Provider.GITHUB:
return new GithubProvider();
case Provider.GOOGLE:
return new GoogleProvider();
case Provider.FARCASTER:
return new FarcasterProvider();
case Provider.WALLET:
return new WalletProvider();
case Provider.GENERIC:
return new OauthProvider();
}
}
}

View file

@ -1,23 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { AuthProviderAbstract } from '@gitroom/backend/services/auth/providers.interface';
@Injectable()
export class AuthProviderManager {
constructor(private _moduleRef: ModuleRef) {}
getProvider(provider: string): AuthProviderAbstract {
const metadata =
Reflect.getMetadata('auth-provider', AuthProviderAbstract) || [];
const found = metadata.find(
(m: any) => m.provider === provider
);
if (!found) {
throw new Error(`Auth provider ${provider} not found`);
}
return this._moduleRef.get(found.target, { strict: false });
}
}

View file

@ -1,17 +1,16 @@
import {
AuthProvider,
AuthProviderAbstract,
} from '@gitroom/backend/services/auth/providers.interface';
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import { randomBytes } from 'crypto';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import bs58 from 'bs58';
import nacl from 'tweetnacl';
function hexToUint8Array(hex) {
// Remove any potential "0x" prefix
if (hex.startsWith('0x')) {
hex = hex.slice(2);
}
// Ensure the hex string has an even length
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string. It must have an even length.');
}
@ -20,15 +19,16 @@ function hexToUint8Array(hex) {
const uint8Array = new Uint8Array(byteLength);
for (let i = 0; i < byteLength; i++) {
// Get two characters from the hex string
const byteHex = hex.substr(i * 2, 2);
// Parse the two characters as a hexadecimal number
uint8Array[i] = parseInt(byteHex, 16);
}
return uint8Array;
}
@AuthProvider({ provider: 'WALLET' })
export class WalletProvider extends AuthProviderAbstract {
export class WalletProvider implements ProvidersInterface {
async generateLink(params: { publicKey: string }) {
if (!params.publicKey) {
return;
@ -40,7 +40,7 @@ export class WalletProvider extends AuthProviderAbstract {
return challenge;
}
async getToken(code: string, _redirectUri?: string) {
async getToken(code: string) {
const { publicKey, challenge, signature } = JSON.parse(
Buffer.from(code, 'base64').toString()
);

View file

@ -1,15 +1,11 @@
import { HttpStatus, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
@Injectable()
export class PublicAuthMiddleware implements NestMiddleware {
constructor(
private _organizationService: OrganizationService,
private _oauthService: OAuthService
) {}
constructor(private _organizationService: OrganizationService) {}
async use(req: Request, res: Response, next: NextFunction) {
const auth = (req.headers.authorization ||
req.headers.Authorization) as string;
@ -18,44 +14,21 @@ export class PublicAuthMiddleware implements NestMiddleware {
return;
}
try {
if (auth.startsWith('pos_')) {
const authorization = await this._oauthService.getOrgByOAuthToken(auth);
if (!authorization) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'Invalid OAuth token' });
return;
}
const org = authorization.organization;
if (!!process.env.STRIPE_SECRET_KEY && !org.subscription) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'No subscription found' });
return;
}
// @ts-ignore
req.org = { ...org, users: [{ users: { role: 'SUPERADMIN' } }] };
} else {
const org = await this._organizationService.getOrgByApiKey(auth);
if (!org) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'Invalid API key' });
return;
}
if (!!process.env.STRIPE_SECRET_KEY && !org.subscription) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'No subscription found' });
return;
}
// @ts-ignore
req.org = { ...org, users: [{ users: { role: 'SUPERADMIN' } }] };
const org = await this._organizationService.getOrgByApiKey(auth);
if (!org) {
res.status(HttpStatus.UNAUTHORIZED).json({ msg: 'Invalid API key' });
return;
}
if (!!process.env.STRIPE_SECRET_KEY && !org.subscription) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'No subscription found' });
return;
}
// @ts-ignore
req.org = { ...org, users: [{ users: { role: 'SUPERADMIN' } }] };
} catch (err) {
throw new HttpForbiddenException();
}

View file

@ -1,15 +1,17 @@
import { Module } from '@nestjs/common';
import { CommandModule as ExternalCommandModule } from 'nestjs-command';
import { CheckStars } from './tasks/check.stars';
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { RefreshTokens } from './tasks/refresh.tokens';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { ConfigurationTask } from './tasks/configuration';
import { AgentRun } from './tasks/agent.run';
import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module';
@Module({
imports: [ExternalCommandModule, DatabaseModule, AgentModule],
imports: [ExternalCommandModule, DatabaseModule, BullMqModule, AgentModule],
controllers: [],
providers: [RefreshTokens, ConfigurationTask, AgentRun],
providers: [CheckStars, RefreshTokens, ConfigurationTask, AgentRun],
get exports() {
return [...this.imports, ...this.providers];
},

View file

@ -0,0 +1,52 @@
import { Command, Positional } from 'nestjs-command';
import { Injectable } from '@nestjs/common';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@Injectable()
export class CheckStars {
constructor(private _workerServiceProducer: BullMqClient) {}
@Command({
command: 'sync:stars <login>',
describe: 'Sync stars for a login',
})
async create(
@Positional({
name: 'login',
describe: 'login {owner}/{repo}',
type: 'string',
})
login: string
) {
this._workerServiceProducer
.emit('check_stars', { payload: { login } })
.subscribe();
return true;
}
@Command({
command: 'sync:all_stars <login>',
describe: 'Sync all stars for a login',
})
async syncAllStars(
@Positional({
name: 'login',
describe: 'login {owner}/{repo}',
type: 'string',
})
login: string
) {
this._workerServiceProducer
.emit('sync_all_stars', { payload: { login } })
.subscribe();
return true;
}
@Command({
command: 'sync:trending',
describe: 'Sync trending',
})
async syncTrending() {
this._workerServiceProducer.emit('sync_trending', {}).subscribe();
return true;
}
}

View file

@ -3,7 +3,7 @@
"collection": "@nestjs/schematics",
"monorepo": false,
"sourceRoot": "src",
"entryFile": "../../dist/orchestrator/apps/orchestrator/src/main",
"entryFile": "../../dist/cron/apps/cron/src/main",
"language": "ts",
"generateOptions": {
"spec": false

View file

@ -1,12 +1,12 @@
{
"name": "postiz-orchestrator",
"name": "postiz-cron",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/orchestrator/src/main",
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/cron/src/main",
"build": "cross-env NODE_ENV=production nest build",
"start": "dotenv -e ../../.env -- node --experimental-require-module ./dist/apps/orchestrator/src/main.js",
"pm2": "pm2 start pnpm --name orchestrator -- start"
"start": "dotenv -e ../../.env -- node ./dist/apps/cron/src/main.js",
"pm2": "pm2 start pnpm --name cron -- start"
},
"keywords": [],
"author": "",

View file

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { SentryModule } from '@sentry/nestjs/setup';
import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
import { CheckMissingQueues } from '@gitroom/cron/tasks/check.missing.queues';
import { PostNowPendingQueues } from '@gitroom/cron/tasks/post.now.pending.queues';
@Module({
imports: [
SentryModule.forRoot(),
DatabaseModule,
ScheduleModule.forRoot(),
BullMqModule,
],
controllers: [],
providers: [FILTER, CheckMissingQueues, PostNowPendingQueues],
})
export class CronModule {}

12
apps/cron/src/main.ts Normal file
View file

@ -0,0 +1,12 @@
import { NestFactory } from '@nestjs/core';
import { CronModule } from './cron.module';
import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry';
initializeSentry('cron');
async function bootstrap() {
// some comment again
await NestFactory.createApplicationContext(CronModule);
}
bootstrap();

View file

View file

@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import dayjs from 'dayjs';
@Injectable()
export class CheckMissingQueues {
constructor(
private _postService: PostsService,
private _workerServiceProducer: BullMqClient
) {}
@Cron('0 * * * *')
async handleCron() {
const list = await this._postService.searchForMissingThreeHoursPosts();
const notExists = (
await Promise.all(
list.map(async (p) => ({
id: p.id,
publishDate: p.publishDate,
isJob:
['delayed', 'waiting'].indexOf(
await this._workerServiceProducer
.getQueue('post')
.getJobState(p.id)
) > -1,
}))
)
).filter((p) => !p.isJob);
for (const job of notExists) {
this._workerServiceProducer.emit('post', {
id: job.id,
options: {
delay: dayjs(job.publishDate).diff(dayjs(), 'millisecond'),
},
payload: {
id: job.id,
delay: dayjs(job.publishDate).diff(dayjs(), 'millisecond'),
},
});
}
}
}

View file

@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@Injectable()
export class PostNowPendingQueues {
constructor(
private _postService: PostsService,
private _workerServiceProducer: BullMqClient
) {}
@Cron('*/16 * * * *')
async handleCron() {
const list = await this._postService.checkPending15minutesBack();
const notExists = (
await Promise.all(
list.map(async (p) => ({
id: p.id,
publishDate: p.publishDate,
isJob:
['delayed', 'waiting'].indexOf(
await this._workerServiceProducer
.getQueue('post')
.getJobState(p.id)
) > -1,
}))
)
).filter((p) => !p.isJob);
for (const job of notExists) {
this._workerServiceProducer.emit('post', {
id: job.id,
options: {
delay: 0,
},
payload: {
id: job.id,
delay: 0,
},
});
}
}
}

13
apps/cron/tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"noLib": false,
"target": "ES2021",
"sourceMap": true,
"esModuleInterop": true,
}
}

37
apps/extension/manifest.dev.json Normal file → Executable file
View file

@ -1,30 +1,15 @@
{
"manifest_version": 3,
"name": "Postiz",
"version": "2.0.0",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtH6qclAsfFf6qbUKfPmhBbfycGrt13+0h6ti/olniCGnjQjhkVVTnURfLFz+v+842Ee+pAS5HBEXo57dQ9xUtwFGXnavVR+myjN+Un9NIfFyYmYEBvLrinclsMJBwWMM8JkhxKuaOagxp1hqGgNAO4C0bzE3YN/SPoTjNpGU8TGm/ENZ/TDUneZyyVM5HEEmOTZEmjmy9FJaxbzGmZ2rixNO45pkjXMFp8+/XrFSNiCqNZt6LQNIqL5SfVIRUKGBjE3OG/gtahVToBdlXi5yzP1uYE0Qs4grJ/T1rUUzTXFAQa7heWA9mskf0xAMEtTSED4N9bZ4sF8cf5J+SGGlwIDAQAB",
"description": "Postiz browser extension for social media scheduling",
"action": {
"default_icon": "public/dev-icon-32.png",
"default_popup": "src/pages/popup/index.html"
},
"icons": {
"32": "icon-32.png",
"128": "icon-128.png"
"128": "public/dev-icon-128.png"
},
"permissions": [
"cookies",
"alarms",
"storage"
],
"host_permissions": [
"*://*.skool.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"externally_connectable": {
"matches": [
"http://localhost/*",
"https://localhost/*",
"https://*.postiz.com/*"
]
}
"web_accessible_resources": [
{
"resources": ["contentStyle.css", "dev-icon-128.png", "dev-icon-32.png"],
"matches": []
}
]
}

View file

@ -1,29 +1,31 @@
{
"manifest_version": 3,
"name": "Postiz",
"version": "2.0.0",
"description": "Postiz browser extension for social media scheduling",
"description": "Your ultimate social media scheduling tool",
"options_ui": {
"page": "src/pages/options/index.html"
},
"action": {
"default_popup": "src/pages/popup/index.html",
"default_icon": {
"32": "icon-32.png"
}
},
"icons": {
"32": "icon-32.png",
"128": "icon-128.png"
},
"permissions": [
"cookies",
"alarms",
"storage"
"permissions": ["activeTab", "cookies", "tabs"],
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*", "<all_urls>"],
"js": ["src/pages/content/index.tsx"],
"css": ["contentStyle.css"]
}
],
"host_permissions": [
"*://*.skool.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"externally_connectable": {
"matches": [
"http://localhost/*",
"https://localhost/*",
"https://*.postiz.com/*"
]
}
"web_accessible_resources": [
{
"resources": ["contentStyle.css", "icon-128.png", "icon-32.png"],
"matches": []
}
]
}

View file

@ -0,0 +1,16 @@
{
"env": {
"__DEV__": "true"
},
"watch": [
"src",
"utils",
"vite.config.base.ts",
"vite.config.chrome.ts",
"manifest.json",
"manifest.dev.json"
],
"ext": "tsx,css,html,ts,json",
"ignore": ["src/**/*.spec.ts"],
"exec": "vite build --config vite.config.chrome.ts --mode development"
}

View file

@ -0,0 +1,16 @@
{
"env": {
"__DEV__": "true"
},
"watch": [
"src",
"utils",
"vite.config.base.ts",
"vite.config.firefox.ts",
"manifest.json",
"manifest.dev.json"
],
"ext": "tsx,css,html,ts,json",
"ignore": ["src/**/*.spec.ts"],
"exec": "vite build --config vite.config.firefox.ts --mode development"
}

View file

@ -1,10 +1,14 @@
{
"name": "postiz-extension",
"version": "2.0.0",
"description": "Postiz browser extension for cookie-based platform authentication",
"version": "1.0.3",
"description": "A simple chrome & firefox extension template with Vite, React, TypeScript and Tailwind CSS.",
"scripts": {
"build": "rm -rf dist && vite build && cp manifest.json dist/manifest.json && cd dist && zip -r ../extension.zip .",
"dev": "rm -rf dist && HOT_RELOAD_EXTENSION_VITE_PORT=8081 NODE_ENV=development dotenv -e ../../.env -- vite build --config vite.config.chrome.ts --mode development --watch"
"build": "rm -rf dist && vite build --config vite.config.chrome.ts && zip -r extension.zip dist",
"build:chrome": "vite build --config vite.config.chrome.ts",
"build:firefox": "vite build --config vite.config.firefox.ts",
"dev": "rm -rf dist && dotenv -e ../../.env -- vite build --config vite.config.chrome.ts --mode development --watch",
"dev:chrome": "nodemon --config nodemon.chrome.json",
"dev:firefox": "nodemon --config nodemon.firefox.json"
},
"type": "module"
}

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@theme {
--animate-spin-slow: spin 20s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
}

View file

@ -1,209 +0,0 @@
import { ExtensionRequest, GetCookiesResponse, ProviderInfo, StoredRefreshEntry } from './types/messages';
import { getAllProviders, getProvider } from './providers/provider.registry';
import { CookieProvider } from './providers/cookie-provider.interface';
const EXTENSION_VERSION = '2.0.0';
const REFRESH_ALARM_NAME = 'cookie-refresh';
const STORAGE_KEY = 'refreshEntries';
const ALLOWED_ORIGIN_PATTERNS = [
/^https?:\/\/localhost(:\d+)?$/,
/^https?:\/\/([a-z0-9-]+\.)*postiz\.com$/,
];
function isOriginAllowed(origin: string | undefined): boolean {
if (!origin) return false;
return ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin));
}
async function extractCookies(provider: CookieProvider): Promise<GetCookiesResponse> {
const allCookies = await chrome.cookies.getAll({ url: provider.url });
const extracted: Record<string, string> = {};
const missingRequired: string[] = [];
for (const def of provider.cookies) {
const found = allCookies.find((c) => c.name === def.name);
if (found) {
extracted[def.name] = found.value;
} else if (def.required) {
missingRequired.push(def.name);
}
}
if (missingRequired.length > 0) {
return {
success: false,
provider: provider.identifier,
error: `Missing required cookies: ${missingRequired.join(', ')}. User may need to log in to ${provider.name}.`,
missingCookies: missingRequired,
};
}
return {
success: true,
provider: provider.identifier,
cookies: extracted,
};
}
// --- Refresh Token Storage Helpers ---
async function getStoredEntries(): Promise<Record<string, StoredRefreshEntry>> {
const result = await chrome.storage.local.get(STORAGE_KEY);
return result[STORAGE_KEY] || {};
}
async function setStoredEntries(entries: Record<string, StoredRefreshEntry>): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEY]: entries });
}
async function ensureAlarm(): Promise<void> {
const existing = await chrome.alarms.get(REFRESH_ALARM_NAME);
if (!existing) {
chrome.alarms.create(REFRESH_ALARM_NAME, { periodInMinutes: 1440 });
}
}
async function clearAlarmIfEmpty(): Promise<void> {
const entries = await getStoredEntries();
if (Object.keys(entries).length === 0) {
await chrome.alarms.clear(REFRESH_ALARM_NAME);
}
}
// --- Background Cookie Refresh ---
async function refreshAllCookies(): Promise<void> {
const entries = await getStoredEntries();
for (const [integrationId, entry] of Object.entries(entries)) {
try {
const provider = getProvider(entry.provider);
if (!provider) continue;
const cookieResult = await extractCookies(provider);
if (!cookieResult.success) continue;
const base64Cookies = btoa(JSON.stringify(cookieResult.cookies));
await fetch(`${entry.backendUrl}/integrations/extension-refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jwt: entry.jwt, cookies: base64Cookies }),
});
} catch {
// Silently skip — will retry next cycle
}
}
}
// --- Alarm Listener ---
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === REFRESH_ALARM_NAME) {
refreshAllCookies();
}
});
// --- Ensure alarm on startup ---
(async () => {
const entries = await getStoredEntries();
if (Object.keys(entries).length > 0) {
await ensureAlarm();
}
})();
// --- Message Listener ---
chrome.runtime.onMessageExternal.addListener(
(
message: ExtensionRequest,
sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void
) => {
const origin = sender.origin ?? sender.url;
if (!isOriginAllowed(origin)) {
sendResponse({ error: 'Unauthorized origin' });
return true;
}
switch (message.type) {
case 'PING': {
sendResponse({ status: 'ok', version: EXTENSION_VERSION });
break;
}
case 'GET_PROVIDERS': {
const providers = getAllProviders();
const providerInfos: ProviderInfo[] = providers.map((p) => ({
identifier: p.identifier,
name: p.name,
url: p.url,
cookieNames: p.cookies.map((c) => c.name),
}));
sendResponse({ providers: providerInfos });
break;
}
case 'GET_COOKIES': {
const provider = getProvider(message.provider);
if (!provider) {
sendResponse({
success: false,
provider: message.provider,
error: `Unknown provider: ${message.provider}`,
});
break;
}
extractCookies(provider)
.then((result) => sendResponse(result))
.catch((err) =>
sendResponse({
success: false,
provider: message.provider,
error: `Failed to extract cookies: ${err.message}`,
})
);
return true;
}
case 'STORE_REFRESH_TOKEN': {
(async () => {
const entries = await getStoredEntries();
entries[message.integrationId] = {
jwt: message.jwt,
backendUrl: message.backendUrl,
provider: message.provider,
};
await setStoredEntries(entries);
await ensureAlarm();
sendResponse({ success: true });
})().catch(() => sendResponse({ success: false }));
return true;
}
case 'REMOVE_REFRESH_TOKEN': {
(async () => {
const entries = await getStoredEntries();
delete entries[message.integrationId];
await setStoredEntries(entries);
await clearAlarmIfEmpty();
sendResponse({ success: true });
})().catch(() => sendResponse({ success: false }));
return true;
}
default: {
sendResponse({ error: `Unknown message type: ${(message as any).type}` });
break;
}
}
return true;
}
);

11
apps/extension/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare module '*.svg' {
import React = require('react');
export const ReactComponent: React.SFC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}
declare module '*.json' {
const content: string;
export default content;
}

View file

@ -0,0 +1,10 @@
{
"extName": {
"message": "name in src/locales/en/messages.json",
"description": "Extension name"
},
"extDescription": {
"message": "description in src/locales/en/messages.json",
"description": "Extension description"
}
}

View file

@ -0,0 +1,37 @@
import { fetchRequestUtil } from '@gitroom/extension/utils/request.util';
const isDevelopment = process.env.NODE_ENV === 'development';
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
if (request.action === 'makeHttpRequest') {
fetchRequestUtil(request).then((response) => {
sendResponse(response);
});
}
if (request.action === 'loadStorage') {
chrome.storage.local.get([request.key], function (storage) {
sendResponse(storage[request.key]);
});
}
if (request.action === 'saveStorage') {
chrome.storage.local.set({ [request.key]: request.value }, function () {
sendResponse({ success: true });
});
}
if (request.action === 'loadCookie') {
chrome.cookies.get(
{
url: import.meta.env?.FRONTEND_URL || process?.env?.FRONTEND_URL,
name: request.cookieName,
},
function (cookies) {
sendResponse(cookies?.value);
}
);
}
return true;
});

View file

@ -0,0 +1,115 @@
import { FC, memo, useCallback, useEffect, useState } from 'react';
import { ProviderInterface } from '@gitroom/extension/providers/provider.interface';
import { fetchCookie } from '@gitroom/extension/utils/load.cookie';
const Comp: FC<{ removeModal: () => void; platform: string; style: string }> = (
props
) => {
const load = async () => {
const cookie = await fetchCookie(`auth`);
if (document.querySelector('iframe#modal-postiz')) {
return;
}
const div = document.createElement('div');
div.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
div.style.position = 'fixed';
div.style.top = '0';
div.style.left = '0';
div.style.zIndex = '9999';
div.style.width = '100%';
div.style.height = '100%';
div.style.border = 'none';
div.style.overflow = 'hidden';
document.body.appendChild(div);
const iframe = document.createElement('iframe');
iframe.style.backgroundColor = 'transparent';
// @ts-ignore
iframe.allowTransparency = 'true';
iframe.src =
(import.meta.env?.FRONTEND_URL || process?.env?.FRONTEND_URL) +
`/modal/${props.style}/${props.platform}?loggedAuth=${cookie}`;
iframe.id = 'modal-postiz';
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.position = 'fixed';
iframe.style.top = '0';
iframe.style.left = '0';
iframe.style.zIndex = '9999';
iframe.style.border = 'none';
div.appendChild(iframe);
window.addEventListener('message', (event) => {
if (event.data.action === 'closeIframe') {
const iframe = document.querySelector('iframe#modal-postiz');
if (iframe) {
props.removeModal();
div.remove();
}
}
});
};
useEffect(() => {
load();
}, []);
return <></>;
};
export const ActionComponent: FC<{
target: Node;
keyIndex: number;
actionType: string;
provider: ProviderInterface;
wrap: boolean;
selector: string;
}> = memo((props) => {
const { wrap, provider, selector, target, actionType } = props;
const [modal, showModal] = useState(false);
const handle = useCallback(async (e: any) => {
showModal(true);
e.preventDefault();
e.stopPropagation();
}, []);
useEffect(() => {
const blockingDiv = document.createElement('div');
if (document.querySelector(`.${selector}`)) {
console.log('already exists');
return;
}
setTimeout(() => {
// @ts-ignore
const targetInformation = target.getBoundingClientRect();
blockingDiv.style.position = 'absolute';
blockingDiv.id = 'blockingDiv';
blockingDiv.style.cursor = 'pointer';
blockingDiv.style.top = `${targetInformation.top}px`;
blockingDiv.style.left = `${targetInformation.left}px`;
blockingDiv.style.width = `${targetInformation.width}px`;
blockingDiv.style.height = `${targetInformation.height}px`;
blockingDiv.style.zIndex = '9999';
blockingDiv.className = selector;
document.body.appendChild(blockingDiv);
blockingDiv.addEventListener('click', handle);
}, 1000);
return () => {
blockingDiv.removeEventListener('click', handle);
blockingDiv.remove();
};
}, []);
return (
<div className="g-wrapper" style={{ position: 'relative' }}>
<div className="absolute start-0 top-0 z-[9999] w-full h-full" />
{modal && (
<Comp
platform={provider.identifier}
style={provider.style}
removeModal={() => showModal(false)}
/>
)}
</div>
);
});

View file

@ -0,0 +1,11 @@
import { createRoot } from 'react-dom/client';
import './style.css';
import { MainContent } from '@gitroom/extension/pages/content/main.content';
const div = document.createElement('div');
div.id = '__root';
document.body.appendChild(div);
const rootContainer = document.querySelector('#__root');
if (!rootContainer) throw new Error("Can't find Content root element");
const root = createRoot(rootContainer);
root.render(<MainContent />);

View file

@ -0,0 +1,191 @@
import {
FC,
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ProviderList } from '@gitroom/extension/providers/provider.list';
import { createPortal } from 'react-dom';
import { ActionComponent } from '@gitroom/extension/pages/content/elements/action.component';
// Define a type to track elements with their action types
interface ActionElement {
element: HTMLElement;
actionType: string;
}
export const MainContent: FC = () => {
return <MainContentInner />;
};
export const MainContentInner: FC = (props) => {
const [actionElements, setActionElements] = useState<ActionElement[]>([]);
const actionSetRef = useRef(new Map<HTMLElement, string>());
const provider = useMemo(() => {
return ProviderList.find((p) => {
return p.baseUrl.indexOf(new URL(window.location.href).hostname) > -1;
});
}, []);
useEffect(() => {
if (!provider) return;
// Helper to scan DOM for existing matching elements
const scanDOMForExistingMatches = () => {
const action = { selector: provider.element, type: 'post' };
const matches = document.querySelectorAll(action.selector);
matches.forEach((match) => {
const htmlMatch = match as HTMLElement;
if (!actionSetRef.current.has(htmlMatch)) {
actionSetRef.current.set(htmlMatch, action.type);
}
});
// Update state
const elements: ActionElement[] = [];
actionSetRef.current.forEach((actionType, element) => {
elements.push({ element, actionType });
});
setActionElements(elements);
};
// Initial scan before observing
scanDOMForExistingMatches();
const observer = new MutationObserver((mutationsList) => {
let addedSomething = false;
let removedSomething = false;
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
const action = { selector: provider.element, type: 'post' };
if (
el.matches?.(action.selector) &&
!actionSetRef.current.has(el)
) {
actionSetRef.current.set(el, action.type);
addedSomething = true;
}
if (el.querySelectorAll) {
const matches = el.querySelectorAll(action.selector);
matches.forEach((match) => {
const htmlMatch = match as HTMLElement;
if (!actionSetRef.current.has(htmlMatch)) {
actionSetRef.current.set(htmlMatch, action.type);
addedSomething = true;
}
});
}
}
}
for (const node of mutation.removedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (actionSetRef.current.has(el)) {
actionSetRef.current.delete(el);
removedSomething = true;
}
const action = { selector: provider.element, type: 'post' };
if (el.querySelectorAll) {
const matches = el.querySelectorAll(action.selector);
matches.forEach((match) => {
const htmlMatch = match as HTMLElement;
if (actionSetRef.current.has(htmlMatch)) {
actionSetRef.current.delete(htmlMatch);
removedSomething = true;
}
});
}
}
}
}
if (mutation.type === 'attributes') {
const el = mutation.target;
if (el instanceof HTMLElement) {
const action = { selector: provider.element, type: 'post' };
const matchesNow = el.matches(action.selector);
const wasTracked = actionSetRef.current.has(el);
if (matchesNow && !wasTracked) {
actionSetRef.current.set(el, action.type);
addedSomething = true;
} else if (!matchesNow && wasTracked) {
actionSetRef.current.delete(el);
removedSomething = true;
}
}
}
}
if (addedSomething || removedSomething) {
const elements: ActionElement[] = [];
actionSetRef.current.forEach((actionType, element) => {
elements.push({ element, actionType });
});
setActionElements(elements);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeOldValue: true,
});
return () => observer.disconnect();
}, []);
return actionElements.map((actionEl, index) => (
<Fragment key={index}>
{createPortal(
<ActionComponent
target={actionEl.element}
keyIndex={index}
actionType={actionEl.actionType}
provider={provider}
wrap={true}
selector={stringToABC(
provider.element
.split(',')
.map((z) => z.trim())
.find((p) => actionEl.element.matches(p)) || ''
)}
/>,
actionEl.element
)}
</Fragment>
));
};
function stringToABC(text: string, length = 8) {
// Simple DJB2-like hash (non-cryptographic!)
let hash = 5381;
for (let i = 0; i < text.length; i++) {
hash = (hash * 33) ^ text.charCodeAt(i);
}
hash = Math.abs(hash);
// Convert to base-26 string using az
const alphabet = 'abcdefghijklmnopqrstuvwxyz';
let result = '';
while (result.length < length) {
result = alphabet[hash % 26] + result;
hash = Math.floor(hash / 26);
}
return result;
}

View file

@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.my-wrapper {
left: 0 !important;
top: 0 !important;
position: fixed !important;
width: 100% !important;
height: 100% !important;
z-index: 999999 !important;
display: flex !important;
justify-content: center !important;
background: rgba(0, 0, 0, 0.5) !important;
}
.my-wrapper > div {
background: white !important;
width: 600px !important;
height: 300px !important;
border-radius: 10px !important;
display: flex !important;
flex-direction: column !important;
justify-items: center !important;
margin-top: 100px !important;
color: black !important;
}

View file

@ -0,0 +1,8 @@
.container {
width: 100%;
height: 50vh;
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -0,0 +1,6 @@
import React from 'react';
import '@gitroom/extension/pages/options/Options.css';
export default function Options() {
return <div className="container">Options</div>;
}

Some files were not shown because too many files have changed in this diff Show more