Merge branch 'main' into feature/media-uploader
This commit is contained in:
commit
77d38a4f52
|
|
@ -64,6 +64,14 @@ PINTEREST_CLIENT_ID=""
|
|||
PINTEREST_CLIENT_SECRET=""
|
||||
DRIBBBLE_CLIENT_ID=""
|
||||
DRIBBBLE_CLIENT_SECRET=""
|
||||
DISCORD_CLIENT_ID=""
|
||||
DISCORD_CLIENT_SECRET=""
|
||||
DISCORD_BOT_TOKEN_ID=""
|
||||
SLACK_ID=""
|
||||
SLACK_SECRET=""
|
||||
SLACK_SIGNING_SECRET=""
|
||||
MASTODON_CLIENT_ID=""
|
||||
MASTODON_CLIENT_SECRET=""
|
||||
|
||||
# Misc Settings
|
||||
OPENAI_API_KEY=""
|
||||
|
|
|
|||
|
|
@ -4,8 +4,16 @@ eg: Bug fix, feature, docs update, ...
|
|||
|
||||
# Why was this change needed?
|
||||
|
||||
Please link to related issues when possible.
|
||||
Please link to related issues when possible, and explain WHY you changed things, not WHAT you changed.
|
||||
|
||||
# Other information:
|
||||
|
||||
eg: Did you discuss this change with anybody before working on it (not required, but can be a good idea for bigger changes). Any plans for the future, etc?
|
||||
eg: Did you discuss this change with anybody before working on it (not required, but can be a good idea for bigger changes). Any plans for the future, etc?
|
||||
|
||||
# Checklist:
|
||||
|
||||
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 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.
|
||||
|
|
|
|||
|
|
@ -8,8 +8,27 @@ on:
|
|||
- '*'
|
||||
|
||||
jobs:
|
||||
build-containers:
|
||||
build-containers-common:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
containerver: ${{ steps.getcontainerver.outputs.containerver }}
|
||||
steps:
|
||||
- name: Get Container Version
|
||||
id: getcontainerver
|
||||
run: |
|
||||
echo "containerver=$(date +'%s')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-containers:
|
||||
needs: build-containers-common
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runnertags: ubuntu-latest
|
||||
arch: amd64
|
||||
- runnertags: [self-hosted, ARM64]
|
||||
arch: arm64
|
||||
|
||||
runs-on: ${{ matrix.runnertags }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
|
@ -26,24 +45,41 @@ jobs:
|
|||
- name: docker build
|
||||
run: ./var/docker/docker-build.sh
|
||||
|
||||
- name: Get date
|
||||
run: |
|
||||
echo "DATE=$(date +'%s')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Print post-build debug info
|
||||
run: |
|
||||
docker images
|
||||
|
||||
- name: docker tag
|
||||
env:
|
||||
CONTAINERVER: ${{ needs.build-containers-common.outputs.containerver }}
|
||||
run: |
|
||||
docker tag localhost/postiz ghcr.io/gitroomhq/postiz-app:${{ env.DATE }}
|
||||
docker push ghcr.io/gitroomhq/postiz-app:${{ env.DATE }}
|
||||
docker tag localhost/postiz ghcr.io/gitroomhq/postiz-app:${{ matrix.arch }}-${{ env.CONTAINERVER }}
|
||||
docker push ghcr.io/gitroomhq/postiz-app:${{ matrix.arch }}-${{ env.CONTAINERVER }}
|
||||
|
||||
docker tag ghcr.io/gitroomhq/postiz-app:${{ env.DATE }} ghcr.io/gitroomhq/postiz-app:latest
|
||||
docker push ghcr.io/gitroomhq/postiz-app:latest
|
||||
docker tag localhost/postiz-devcontainer ghcr.io/gitroomhq/postiz-devcontainer:${{ env.CONTAINERVER }}
|
||||
docker push ghcr.io/gitroomhq/postiz-devcontainer:${{ env.CONTAINERVER }}
|
||||
|
||||
docker tag localhost/postiz-devcontainer ghcr.io/gitroomhq/postiz-devcontainer:${{ env.DATE }}
|
||||
docker push ghcr.io/gitroomhq/postiz-devcontainer:${{ env.DATE }}
|
||||
|
||||
docker tag ghcr.io/gitroomhq/postiz-devcontainer:${{ env.DATE }} ghcr.io/gitroomhq/postiz-devcontainer:latest
|
||||
docker tag ghcr.io/gitroomhq/postiz-devcontainer:${{ env.CONTAINERVER }} ghcr.io/gitroomhq/postiz-devcontainer:latest
|
||||
docker push ghcr.io/gitroomhq/postiz-devcontainer:latest
|
||||
|
||||
build-container-manifest:
|
||||
needs: [build-containers, build-containers-common]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Login to ghcr
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Create Docker Manifest
|
||||
env:
|
||||
CONTAINERVER: ${{ needs.build-containers-common.outputs.containerver }}
|
||||
run: |
|
||||
docker manifest create \
|
||||
ghcr.io/gitroomhq/postiz-app:latest \
|
||||
ghcr.io/gitroomhq/postiz-app:amd64-${{ env.CONTAINERVER }} \
|
||||
ghcr.io/gitroomhq/postiz-app:arm64-${{ env.CONTAINERVER }}
|
||||
|
||||
docker manifest push ghcr.io/gitroomhq/postiz-app:latest
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
# Contributing
|
||||
|
||||
Contributions are welcome - code, docs, whatever it might be! If this is your first contribution to an Open Source project or you're a core maintainer of multiple projects, your time and interest in contributing to this project is most welcome.
|
||||
|
||||
## 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:
|
||||
|
||||
- **GitHub issues**: To discuss more slowly, or longer-written messages.
|
||||
- **[Discord chat](https://discord.postiz.com)**: To chat with people [Discord chat](https://discord.postiz.com/) and a quicker feedback.
|
||||
|
||||
As a general rule;
|
||||
|
||||
- **If a change is less than 3 lines**: You're probably safe just to submit the change without a discussion. This includes typos, dependency changes, and quick fixes, etc.
|
||||
- **If a change is more than 3 lines**: It's probably best to discuss the change in an issue or on discord first. This is simply because you might not be aware of the roadmap for the project, or understand the impact this change might have. We're just trying to save you time here, and importantly, avoid you being disappointed if your change isn't accepted.
|
||||
|
||||
## Types of Contributions
|
||||
|
||||
Contributions can include:
|
||||
- **Code improvements:** Fixing bugs or adding new features.
|
||||
- **Documentation updates:** Enhancing clarity or adding missing information.
|
||||
- **Feature requests:** Suggesting new capabilities or integrations.
|
||||
- **Bug reports:** Identifying and reporting issues.
|
||||
|
||||
## 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:
|
||||
|
||||
1. **Fork the project:** Create a personal copy of the repository on your GitHub account.
|
||||
2. **Clone your fork:** Bring a copy of your fork to your local machine.
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/postiz.git
|
||||
```
|
||||
3. **Create a new branch**: Start a new branch for your changes
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
6. **Make your changes**: Implement the changes you wish to contribute.
|
||||
7. **Push your changes**: Upload your changes to your fork.
|
||||
```bash
|
||||
git push -u origin feature/your-feature-name
|
||||
```
|
||||
9. **Create a pull request**: Propose your changes to the main project.
|
||||
|
||||
|
||||
## Setting up your development environment
|
||||
To contribute effectively, you’ll need to set up your development environment. Follow the instructions on this page: https://docs.postiz.com/installation/development
|
||||
|
||||
|
||||
Need Help?
|
||||
If you encounter any issues, please visit our [support page](https://docs.postiz.com/support) or check the community forums. Your contributions help make Postiz better!
|
||||
|
||||
|
||||
|
||||
|
|
@ -31,6 +31,7 @@ COPY var/docker/Caddyfile /app/Caddyfile
|
|||
COPY .env.example /config/postiz.env
|
||||
|
||||
VOLUME /config
|
||||
VOLUME /uploads
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/gitroomhq/postiz-app
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ COPY libraries /app/libraries/
|
|||
RUN npm ci --no-fund && npx nx run-many --target=build --projects=frontend,backend,workers,cron
|
||||
|
||||
VOLUME /config
|
||||
VOLUME /uploads
|
||||
|
||||
LABEL org.opencontainers.image.title="Postiz App (DevContainer)"
|
||||
|
||||
|
|
@ -70,6 +72,7 @@ COPY --from=devcontainer /app/libraries/ /app/libraries/
|
|||
COPY package.json nx.json /app/
|
||||
|
||||
VOLUME /config
|
||||
VOLUME /uploads
|
||||
|
||||
## Labels at the bottom, because CI will eventually add dates, commit hashes, etc.
|
||||
LABEL org.opencontainers.image.title="Postiz App (Production)"
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -1,17 +1,3 @@
|
|||
|
||||
<p align="center">
|
||||
<a href="https://x.com/intent/follow?screen_name=nevodavid" target="_blank">
|
||||
<img alt="Follow me" src="https://github.com/user-attachments/assets/1562c93f-95c6-4307-8a85-e62003e26348" />
|
||||
</a>
|
||||
<br />
|
||||
|
||||
<a href="https://devfest.ai" target="_blank">
|
||||
<img alt="DevFest" src="https://github.com/user-attachments/assets/cab9a4e5-e88e-4a28-be7d-28ed749e537a" width="850" />
|
||||
</a>
|
||||
|
||||
<br /><br />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://postiz.com" target="_blank">
|
||||
<picture>
|
||||
|
|
@ -49,6 +35,10 @@
|
|||
<img alt="Pinterest" src="https://postiz.com/svgs/socials/Pinterest.svg" width="32">
|
||||
<img alt="Threads" src="https://postiz.com/svgs/socials/Threads.svg" width="32">
|
||||
<img alt="X" src="https://postiz.com/svgs/socials/X.svg" width="32">
|
||||
<img alt="X" src="https://postiz.com/svgs/socials/Slack.svg" width="32">
|
||||
<img alt="X" src="https://postiz.com/svgs/socials/Discord.svg" width="32">
|
||||
<img alt="X" src="https://postiz.com/svgs/socials/Mastodon.svg" width="32">
|
||||
<img alt="X" src="https://postiz.com/svgs/socials/Bluesky.svg" width="32">
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -68,6 +58,22 @@
|
|||
|
||||
<br />
|
||||
|
||||
|
||||
<p align="center">
|
||||
<br /><br /><br />
|
||||
<h1>We participate in Hacktoberfest 2024! 🎉🎊</h1>
|
||||
<p align="left">We are sending a t-shirt for every merged PR! (max 1 per person)</p>
|
||||
<p align="left"><strong>Rules:</strong></p>
|
||||
<ul align="left">
|
||||
<li>You must create an issue before making a pull request.</li>
|
||||
<li>You can also ask to be assigned to an issue. During Hacktoberfest, each issue can have multiple assignees.</li>
|
||||
<li>We have to approve the issue and add a "hacktoberfest" tag.</li>
|
||||
<li>We encourage everybody to contribute to all types of issues. We will only send swag for issues with features and bug fixes (no typos, sorry).</li>
|
||||
</ul>
|
||||
<p align="center"><img align="center" width="400" src="https://github.com/user-attachments/assets/3ceffccc-e4b3-4098-b9ba-44a94cf01294" /></p>
|
||||
<br /><br /><br />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/05436a01-19c8-4827-b57f-05a5e7637a67" width="100%" />
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
# Security Policy
|
||||
|
||||
## Introduction
|
||||
|
||||
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.
|
||||
|
||||
## Reporting Security Vulnerabilities
|
||||
|
||||
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
|
||||
* @jamesread ([email](mailto:contact@jread.com))
|
||||
* @jonathan-irvin ([email](mailto:offendingcommit@gmail.com))
|
||||
|
||||
When reporting a security vulnerability, please provide as much detail as possible, including:
|
||||
|
||||
* A clear description of the vulnerability
|
||||
* Steps to reproduce the vulnerability
|
||||
* Any relevant code or configuration files
|
||||
|
||||
## 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.
|
||||
|
||||
## Disclosure Guidelines
|
||||
|
||||
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.
|
||||
|
||||
## Security Vulnerability Response Process
|
||||
|
||||
We take security vulnerabilities seriously and will respond promptly to reports of vulnerabilities. Our response process includes:
|
||||
|
||||
* Investigating the report and verifying the vulnerability.
|
||||
* Developing a patch or fix for the vulnerability.
|
||||
* Releasing the patch or fix as soon as possible.
|
||||
* Notifying users of the vulnerability and the patch or fix.
|
||||
|
||||
## Template Attribution
|
||||
|
||||
This SECURITY.md file is based on the [GitHub Security Policy Template](https://github.com/github/security/blob/master/SECURITY.md).
|
||||
|
||||
Thank you for helping to keep the `postiz-app` secure!
|
||||
|
|
@ -11,7 +11,6 @@ import { PermissionsService } from '@gitroom/backend/services/auth/permissions/p
|
|||
import { IntegrationsController } from '@gitroom/backend/api/routes/integrations.controller';
|
||||
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
|
||||
import { SettingsController } from '@gitroom/backend/api/routes/settings.controller';
|
||||
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
|
||||
import { PostsController } from '@gitroom/backend/api/routes/posts.controller';
|
||||
import { MediaController } from '@gitroom/backend/api/routes/media.controller';
|
||||
import { UploadModule } from '@gitroom/nestjs-libraries/upload/upload.module';
|
||||
|
|
@ -27,6 +26,7 @@ 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';
|
||||
|
||||
const authenticatedController = [
|
||||
UsersController,
|
||||
|
|
@ -60,6 +60,7 @@ const authenticatedController = [
|
|||
: []),
|
||||
],
|
||||
controllers: [
|
||||
RootController,
|
||||
StripeController,
|
||||
AuthController,
|
||||
PublicController,
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/for
|
|||
import { ForgotPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot.password.dto';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
|
||||
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('/auth')
|
||||
export class AuthController {
|
||||
constructor(private _authService: AuthService) {}
|
||||
constructor(
|
||||
private _authService: AuthService,
|
||||
private _emailService: EmailService
|
||||
) {}
|
||||
@Post('/register')
|
||||
async register(
|
||||
@Req() req: Request,
|
||||
|
|
@ -30,7 +34,9 @@ export class AuthController {
|
|||
getOrgFromCookie
|
||||
);
|
||||
|
||||
if (body.provider === 'LOCAL') {
|
||||
const activationRequired = body.provider === 'LOCAL' && this._emailService.hasProvider();
|
||||
|
||||
if (activationRequired) {
|
||||
response.header('activate', 'true');
|
||||
response.status(200).json({ activate: true });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.req
|
|||
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';
|
||||
|
||||
@ApiTags('Integrations')
|
||||
@Controller('/integrations')
|
||||
|
|
@ -46,21 +47,68 @@ export class IntegrationsController {
|
|||
return {
|
||||
integrations: (
|
||||
await this._integrationService.getIntegrationsList(org.id)
|
||||
).map((p) => ({
|
||||
name: p.name,
|
||||
id: p.id,
|
||||
internalId: p.internalId,
|
||||
disabled: p.disabled,
|
||||
picture: p.picture,
|
||||
identifier: p.providerIdentifier,
|
||||
inBetweenSteps: p.inBetweenSteps,
|
||||
refreshNeeded: p.refreshNeeded,
|
||||
type: p.type,
|
||||
time: JSON.parse(p.postingTimes)
|
||||
})),
|
||||
).map((p) => {
|
||||
const findIntegration = this._integrationManager.getSocialIntegration(
|
||||
p.providerIdentifier
|
||||
);
|
||||
return {
|
||||
name: p.name,
|
||||
id: p.id,
|
||||
internalId: p.internalId,
|
||||
disabled: p.disabled,
|
||||
picture: p.picture,
|
||||
identifier: p.providerIdentifier,
|
||||
inBetweenSteps: p.inBetweenSteps,
|
||||
refreshNeeded: p.refreshNeeded,
|
||||
type: p.type,
|
||||
time: JSON.parse(p.postingTimes),
|
||||
changeProfilePicture: !!findIntegration?.changeProfilePicture,
|
||||
changeNickName: !!findIntegration?.changeNickname,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/:id/nickname')
|
||||
async setNickname(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name: string; picture: string }
|
||||
) {
|
||||
const integration = await this._integrationService.getIntegrationById(
|
||||
org.id,
|
||||
id
|
||||
);
|
||||
if (!integration) {
|
||||
throw new Error('Invalid integration');
|
||||
}
|
||||
|
||||
const manager = this._integrationManager.getSocialIntegration(
|
||||
integration.providerIdentifier
|
||||
);
|
||||
if (!manager.changeProfilePicture && !manager.changeNickname) {
|
||||
throw new Error('Invalid integration');
|
||||
}
|
||||
|
||||
const { url } = manager.changeProfilePicture
|
||||
? await manager.changeProfilePicture(
|
||||
integration.internalId,
|
||||
integration.token,
|
||||
body.picture
|
||||
)
|
||||
: { url: '' };
|
||||
|
||||
const { name } = manager.changeNickname
|
||||
? await manager.changeNickname(
|
||||
integration.internalId,
|
||||
integration.token,
|
||||
body.name
|
||||
)
|
||||
: { name: '' };
|
||||
|
||||
return this._integrationService.updateNameAndUrl(id, name, url);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
getSingleIntegration(
|
||||
@Param('id') id: string,
|
||||
|
|
@ -80,7 +128,8 @@ export class IntegrationsController {
|
|||
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
|
||||
async getIntegrationUrl(
|
||||
@Param('integration') integration: string,
|
||||
@Query('refresh') refresh: string
|
||||
@Query('refresh') refresh: string,
|
||||
@Query('externalUrl') externalUrl: string
|
||||
) {
|
||||
if (
|
||||
!this._integrationManager
|
||||
|
|
@ -92,11 +141,33 @@ export class IntegrationsController {
|
|||
|
||||
const integrationProvider =
|
||||
this._integrationManager.getSocialIntegration(integration);
|
||||
const { codeVerifier, state, url } =
|
||||
await integrationProvider.generateAuthUrl(refresh);
|
||||
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
|
||||
|
||||
return { url };
|
||||
if (integrationProvider.externalUrl && !externalUrl) {
|
||||
throw new Error('Missing external url');
|
||||
}
|
||||
|
||||
try {
|
||||
const getExternalUrl = integrationProvider.externalUrl
|
||||
? {
|
||||
...(await integrationProvider.externalUrl(externalUrl)),
|
||||
instanceUrl: externalUrl,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { codeVerifier, state, url } =
|
||||
await integrationProvider.generateAuthUrl(refresh, getExternalUrl);
|
||||
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
|
||||
await ioRedis.set(
|
||||
`external:${state}`,
|
||||
JSON.stringify(getExternalUrl),
|
||||
'EX',
|
||||
300
|
||||
);
|
||||
|
||||
return { url };
|
||||
} catch (err) {
|
||||
return { err: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/:id/time')
|
||||
|
|
@ -129,7 +200,11 @@ export class IntegrationsController {
|
|||
}
|
||||
|
||||
if (integrationProvider[body.name]) {
|
||||
return integrationProvider[body.name](getIntegration.token, body.data);
|
||||
return integrationProvider[body.name](
|
||||
getIntegration.token,
|
||||
body.data,
|
||||
getIntegration.internalId
|
||||
);
|
||||
}
|
||||
throw new Error('Function not found');
|
||||
}
|
||||
|
|
@ -144,7 +219,11 @@ export class IntegrationsController {
|
|||
}
|
||||
|
||||
if (integrationProvider[body.name]) {
|
||||
return integrationProvider[body.name](getIntegration.token, body.data);
|
||||
return integrationProvider[body.name](
|
||||
getIntegration.token,
|
||||
body.data,
|
||||
getIntegration.internalId
|
||||
);
|
||||
}
|
||||
throw new Error('Function not found');
|
||||
}
|
||||
|
|
@ -209,15 +288,28 @@ export class IntegrationsController {
|
|||
throw new Error('Integration not allowed');
|
||||
}
|
||||
|
||||
const getCodeVerifier = await ioRedis.get(`login:${body.state}`);
|
||||
const integrationProvider =
|
||||
this._integrationManager.getSocialIntegration(integration);
|
||||
|
||||
const getCodeVerifier = integrationProvider.customFields
|
||||
? 'none'
|
||||
: await ioRedis.get(`login:${body.state}`);
|
||||
if (!getCodeVerifier) {
|
||||
throw new Error('Invalid state');
|
||||
}
|
||||
|
||||
await ioRedis.del(`login:${body.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 integrationProvider =
|
||||
this._integrationManager.getSocialIntegration(integration);
|
||||
const {
|
||||
accessToken,
|
||||
expiresIn,
|
||||
|
|
@ -226,11 +318,14 @@ export class IntegrationsController {
|
|||
name,
|
||||
picture,
|
||||
username,
|
||||
} = await integrationProvider.authenticate({
|
||||
code: body.code,
|
||||
codeVerifier: getCodeVerifier,
|
||||
refresh: body.refresh,
|
||||
});
|
||||
} = await integrationProvider.authenticate(
|
||||
{
|
||||
code: body.code,
|
||||
codeVerifier: getCodeVerifier,
|
||||
refresh: body.refresh,
|
||||
},
|
||||
details ? JSON.parse(details) : undefined
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Invalid api key');
|
||||
|
|
@ -249,7 +344,14 @@ export class IntegrationsController {
|
|||
username,
|
||||
integrationProvider.isBetweenSteps,
|
||||
body.refresh,
|
||||
+body.timezone
|
||||
+body.timezone,
|
||||
details
|
||||
? AuthService.fixedEncryption(details)
|
||||
: integrationProvider.customFields
|
||||
? AuthService.fixedEncryption(
|
||||
Buffer.from(body.code, 'base64').toString()
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
@Controller('/')
|
||||
export class RootController {
|
||||
@Get('/')
|
||||
getRoot(): string {
|
||||
return 'App is running!';
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
AuthorizationActions,
|
||||
Sections,
|
||||
} from '@gitroom/backend/services/auth/permissions/permissions.service';
|
||||
import { removeSubdomain } from '@gitroom/helpers/subdomain/subdomain.management';
|
||||
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
|
||||
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
|
||||
|
|
@ -92,8 +92,7 @@ export class UsersController {
|
|||
}
|
||||
|
||||
response.cookie('impersonate', id, {
|
||||
domain:
|
||||
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
|
||||
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'none',
|
||||
|
|
@ -163,8 +162,7 @@ export class UsersController {
|
|||
@Res({ passthrough: true }) response: Response
|
||||
) {
|
||||
response.cookie('showorg', id, {
|
||||
domain:
|
||||
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
|
||||
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'none',
|
||||
|
|
@ -177,8 +175,7 @@ export class UsersController {
|
|||
@Post('/logout')
|
||||
logout(@Res({ passthrough: true }) response: Response) {
|
||||
response.cookie('auth', '', {
|
||||
domain:
|
||||
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
|
||||
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
maxAge: -1,
|
||||
|
|
@ -187,8 +184,7 @@ export class UsersController {
|
|||
});
|
||||
|
||||
response.cookie('showorg', '', {
|
||||
domain:
|
||||
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
|
||||
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
maxAge: -1,
|
||||
|
|
@ -197,8 +193,7 @@ export class UsersController {
|
|||
});
|
||||
|
||||
response.cookie('impersonate', '', {
|
||||
domain:
|
||||
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
|
||||
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
maxAge: -1,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { NestFactory } from '@nestjs/core';
|
|||
import { AppModule } from './app.module';
|
||||
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';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
|
|
@ -38,6 +39,8 @@ async function bootstrap() {
|
|||
|
||||
try {
|
||||
await app.listen(port);
|
||||
|
||||
checkConfiguration() // Do this last, so that users will see obvious issues at the end of the startup log without having to scroll up.
|
||||
|
||||
Logger.log(`🚀 Backend is running on: http://localhost:${port}`);
|
||||
} catch (e) {
|
||||
|
|
@ -45,4 +48,20 @@ async function bootstrap() {
|
|||
}
|
||||
}
|
||||
|
||||
function checkConfiguration() {
|
||||
const checker = new ConfigurationChecker();
|
||||
checker.readEnvFromProcess()
|
||||
checker.check()
|
||||
|
||||
if (checker.hasIssues()) {
|
||||
for (const issue of checker.getIssues()) {
|
||||
Logger.warn(issue, 'Configuration issue')
|
||||
}
|
||||
|
||||
Logger.warn("Configuration issues found: " + checker.getIssuesCount())
|
||||
} else {
|
||||
Logger.log("Configuration check completed without any issues.")
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ 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';
|
||||
|
||||
@Module({
|
||||
imports: [ExternalCommandModule, DatabaseModule, BullMqModule],
|
||||
controllers: [],
|
||||
providers: [CheckStars, RefreshTokens],
|
||||
providers: [CheckStars, RefreshTokens, ConfigurationTask],
|
||||
get exports() {
|
||||
return [...this.imports, ...this.providers];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { Command } from 'nestjs-command';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigurationChecker } from '@gitroom/helpers/configuration/configuration.checker';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigurationTask {
|
||||
@Command({
|
||||
command: 'config:check',
|
||||
describe: 'Checks your configuration (.env) file for issues.',
|
||||
})
|
||||
create() {
|
||||
const checker = new ConfigurationChecker();
|
||||
checker.readEnvFromProcess()
|
||||
checker.check()
|
||||
|
||||
if (checker.hasIssues()) {
|
||||
for (const issue of checker.getIssues()) {
|
||||
console.warn("Configuration issue:", issue)
|
||||
}
|
||||
|
||||
console.error("Configuration check complete, issues: ", checker.getIssuesCount())
|
||||
} else {
|
||||
console.log("Configuration check complete, no issues found.")
|
||||
}
|
||||
|
||||
console.log("Press Ctrl+C to exit.");
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -8,6 +8,6 @@ import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bu
|
|||
@Module({
|
||||
imports: [DatabaseModule, ScheduleModule.forRoot(), BullMqModule],
|
||||
controllers: [],
|
||||
providers: [CheckStars, SyncTrending],
|
||||
providers: [...(!process.env.IS_GENERAL ? [CheckStars, SyncTrending] : [])],
|
||||
})
|
||||
export class CronModule {}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -4,6 +4,7 @@ export const dynamic = 'force-dynamic';
|
|||
|
||||
import { internalFetch } from '@gitroom/helpers/utils/internal.fetch';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Redirect } from '@gitroom/frontend/components/layout/redirect';
|
||||
|
||||
export default async function Page({
|
||||
params: { provider },
|
||||
|
|
@ -30,6 +31,22 @@ export default async function Page({
|
|||
return redirect(`/launches?scope=missing`);
|
||||
}
|
||||
|
||||
if (
|
||||
data.status !== HttpStatusCode.Ok &&
|
||||
data.status !== HttpStatusCode.Created
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-[50px] text-[50px]">
|
||||
Could not add provider.
|
||||
<br />
|
||||
You are being redirected back
|
||||
</div>
|
||||
<Redirect url="/launches" delay={3000} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { inBetweenSteps, id } = await data.json();
|
||||
|
||||
if (inBetweenSteps && !searchParams.refresh) {
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ html {
|
|||
|
||||
.uppy-ProgressBar-percentage {
|
||||
position: absolute;
|
||||
color: red;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
|
@ -383,4 +384,4 @@ div div .set-font-family {
|
|||
font-stretch: 100% !important;
|
||||
font-style: normal !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function Login() {
|
|||
</div>
|
||||
<div className="text-center mt-6">
|
||||
<div className="w-full flex">
|
||||
<Button type="submit" className="flex-1" loading={loading}>
|
||||
<Button type="submit" className="flex-1 rounded-[4px]" loading={loading}>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,17 @@ export function Register() {
|
|||
);
|
||||
}
|
||||
|
||||
function getHelpfulReasonForRegistrationFailure(httpCode: number) {
|
||||
switch (httpCode) {
|
||||
case 400:
|
||||
return 'Email already exists';
|
||||
case 404:
|
||||
return 'Your browser got a 404 when trying to contact the API, the most likely reasons for this are the NEXT_PUBLIC_BACKEND_URL is set incorrectly, or the backend is not running.';
|
||||
}
|
||||
|
||||
return 'Unhandled error: ' + httpCode;
|
||||
}
|
||||
|
||||
export function RegisterAfter({
|
||||
token,
|
||||
provider,
|
||||
|
|
@ -97,23 +108,31 @@ export function RegisterAfter({
|
|||
|
||||
const onSubmit: SubmitHandler<Inputs> = async (data) => {
|
||||
setLoading(true);
|
||||
const register = await fetchData('/auth/register', {
|
||||
|
||||
await fetchData('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...data }),
|
||||
});
|
||||
if (register.status === 400) {
|
||||
form.setError('email', {
|
||||
message: 'Email already exists',
|
||||
});
|
||||
|
||||
}).then((response) => {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
fireEvents('register');
|
||||
if (response.status === 200) {
|
||||
fireEvents('register')
|
||||
|
||||
if (register.headers.get('activate')) {
|
||||
router.push('/auth/activate');
|
||||
}
|
||||
if (response.headers.get('activate') === "true") {
|
||||
router.push('/auth/activate');
|
||||
} else {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
} else {
|
||||
form.setError('email', {
|
||||
message: getHelpfulReasonForRegistrationFailure(response.status),
|
||||
});
|
||||
}
|
||||
}).catch(e => {
|
||||
form.setError("email", {
|
||||
message: 'General error: ' + e.toString() + '. Please check your browser console.',
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -179,7 +198,7 @@ export function RegisterAfter({
|
|||
</div>
|
||||
<div className="text-center mt-6">
|
||||
<div className="w-full flex">
|
||||
<Button type="submit" className="flex-1" loading={loading}>
|
||||
<Button type="submit" className="flex-1 rounded-[4px]" loading={loading}>
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const useFaqList = () => {
|
|||
return [
|
||||
{
|
||||
title: `Can I trust ${isGeneral ? 'Postiz' : 'Gitroom'}?`,
|
||||
description: `${isGeneral ? 'Postiz' : 'Gitroom'} is proudly open-source! We believe in an ethical and transparent culture, meaning Postiz will live forever. You can check the entire code / or use it for your personal use. You can check the open-source repository click here.`,
|
||||
description: `${isGeneral ? 'Postiz' : 'Gitroom'} is proudly open-source! We believe in an ethical and transparent culture, meaning that ${isGeneral ? 'Postiz' : 'Gitroom'} will live forever. You can check out the entire code or use it for personal projects. To view the open-source repository, <a href="https://github.com/gitroomhq/postiz-app" target="_blank" style="text-decoration: underline;">click here</a>.`,
|
||||
},
|
||||
{
|
||||
title: 'What are channels?',
|
||||
|
|
@ -18,7 +18,7 @@ const useFaqList = () => {
|
|||
isGeneral ? 'Postiz' : 'Gitroom'
|
||||
} allows you to schedule your posts between different channels.
|
||||
A channel is a publishing platform where you can schedule your posts.
|
||||
For example, you can schedule your posts on Twitter, Linkedin, DEV and Hashnode`,
|
||||
For example, you can schedule your posts on X, Facebook, Instagram, TikTok, YouTube, Reddit, Linkedin, Dribbble, Threads and Pinterest.`,
|
||||
},
|
||||
{
|
||||
title: 'What are team members?',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useModals } from '@mantine/modals';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import React, { FC, useCallback, useMemo } from 'react';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { Input } from '@gitroom/react/form/input';
|
||||
import { FieldValues, FormProvider, useForm } from 'react-hook-form';
|
||||
|
|
@ -11,6 +11,9 @@ import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.d
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
|
||||
import { useVariables } from '@gitroom/react/helpers/variable.context';
|
||||
import { useToaster } from '@gitroom/react/toaster/toaster';
|
||||
import { object, string } from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
const resolver = classValidatorResolver(ApiKeyDto);
|
||||
|
||||
|
|
@ -127,24 +130,246 @@ export const ApiModal: FC<{
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UrlModal: FC<{
|
||||
gotoUrl(url: string): void;
|
||||
}> = (props) => {
|
||||
const { gotoUrl } = props;
|
||||
const methods = useForm({
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const submit = useCallback(async (data: FieldValues) => {
|
||||
gotoUrl(data.url);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-[4px] border border-customColor6 bg-sixth px-[16px] pb-[16px] relative">
|
||||
<TopTitle title={`Instance URL`} />
|
||||
<button
|
||||
onClick={close}
|
||||
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className="gap-[8px] flex flex-col"
|
||||
onSubmit={methods.handleSubmit(submit)}
|
||||
>
|
||||
<div className="pt-[10px]">
|
||||
<Input label="URL" name="url" />
|
||||
</div>
|
||||
<div>
|
||||
<Button type="submit">Connect</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomVariables: FC<{
|
||||
variables: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
defaultValue?: string;
|
||||
validation: string;
|
||||
type: 'text' | 'password';
|
||||
}>;
|
||||
identifier: string;
|
||||
gotoUrl(url: string): void;
|
||||
}> = (props) => {
|
||||
const { gotoUrl, identifier, variables } = props;
|
||||
const modals = useModals();
|
||||
const schema = useMemo(() => {
|
||||
return object({
|
||||
...variables.reduce((aIcc, item) => {
|
||||
const splitter = item.validation.split('/');
|
||||
const regex = new RegExp(
|
||||
splitter.slice(1, -1).join('/'),
|
||||
splitter.pop()
|
||||
);
|
||||
return {
|
||||
...aIcc,
|
||||
[item.key]: string()
|
||||
.matches(regex, `${item.label} is invalid`)
|
||||
.required(),
|
||||
};
|
||||
}, {}),
|
||||
});
|
||||
}, [variables]);
|
||||
|
||||
const methods = useForm({
|
||||
mode: 'onChange',
|
||||
resolver: yupResolver(schema),
|
||||
values: variables.reduce(
|
||||
(acc, item) => ({
|
||||
...acc,
|
||||
...(item.defaultValue ? { [item.key]: item.defaultValue } : {}),
|
||||
}),
|
||||
{}
|
||||
),
|
||||
});
|
||||
|
||||
const submit = useCallback(
|
||||
async (data: FieldValues) => {
|
||||
gotoUrl(
|
||||
`/integrations/social/${identifier}?state=nostate&code=${Buffer.from(
|
||||
JSON.stringify(data)
|
||||
).toString('base64')}`
|
||||
);
|
||||
},
|
||||
[variables]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-[4px] border border-customColor6 bg-sixth px-[16px] pb-[16px] relative">
|
||||
<TopTitle title={`Custom URL`} />
|
||||
<button
|
||||
onClick={modals.closeAll}
|
||||
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className="gap-[8px] flex flex-col pt-[10px]"
|
||||
onSubmit={methods.handleSubmit(submit)}
|
||||
>
|
||||
{variables.map((variable) => (
|
||||
<div key={variable.key}>
|
||||
<Input
|
||||
label={variable.label}
|
||||
name={variable.key}
|
||||
type={variable.type == 'text' ? 'text' : 'password'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button type="submit">Connect</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddProviderComponent: FC<{
|
||||
social: Array<{ identifier: string; name: string }>;
|
||||
social: Array<{
|
||||
identifier: string;
|
||||
name: string;
|
||||
isExternal: boolean;
|
||||
customFields?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
validation: string;
|
||||
type: 'text' | 'password';
|
||||
}>;
|
||||
}>;
|
||||
article: Array<{ identifier: string; name: string }>;
|
||||
update?: () => void;
|
||||
}> = (props) => {
|
||||
const { update } = props;
|
||||
const {isGeneral} = useVariables();
|
||||
|
||||
const { update, social, article } = props;
|
||||
const { isGeneral } = useVariables();
|
||||
const toaster = useToaster();
|
||||
const router = useRouter();
|
||||
const fetch = useFetch();
|
||||
const modal = useModals();
|
||||
const { social, article } = props;
|
||||
const getSocialLink = useCallback(
|
||||
(identifier: string) => async () => {
|
||||
const { url } = await (
|
||||
await fetch('/integrations/social/' + identifier)
|
||||
).json();
|
||||
window.location.href = url;
|
||||
},
|
||||
(
|
||||
identifier: string,
|
||||
isExternal: boolean,
|
||||
customFields?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
validation: string;
|
||||
defaultValue?: string;
|
||||
type: 'text' | 'password';
|
||||
}>
|
||||
) =>
|
||||
async () => {
|
||||
const gotoIntegration = async (externalUrl?: string) => {
|
||||
const { url, err } = await (
|
||||
await fetch(
|
||||
`/integrations/social/${identifier}${
|
||||
externalUrl ? `?externalUrl=${externalUrl}` : ``
|
||||
}`
|
||||
)
|
||||
).json();
|
||||
|
||||
if (err) {
|
||||
toaster.show('Could not connect to the platform', 'warning');
|
||||
return;
|
||||
}
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
if (isExternal) {
|
||||
modal.closeAll();
|
||||
|
||||
modal.openModal({
|
||||
title: '',
|
||||
withCloseButton: false,
|
||||
classNames: {
|
||||
modal: 'bg-transparent text-textColor',
|
||||
},
|
||||
children: <UrlModal gotoUrl={gotoIntegration} />,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (customFields) {
|
||||
modal.closeAll();
|
||||
|
||||
modal.openModal({
|
||||
title: '',
|
||||
withCloseButton: false,
|
||||
classNames: {
|
||||
modal: 'bg-transparent text-textColor',
|
||||
},
|
||||
children: (
|
||||
<CustomVariables
|
||||
identifier={identifier}
|
||||
gotoUrl={(url: string) => router.push(url)}
|
||||
variables={customFields}
|
||||
/>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await gotoIntegration();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
|
|
@ -196,7 +421,11 @@ export const AddProviderComponent: FC<{
|
|||
{social.map((item) => (
|
||||
<div
|
||||
key={item.identifier}
|
||||
onClick={getSocialLink(item.identifier)}
|
||||
onClick={getSocialLink(
|
||||
item.identifier,
|
||||
item.isExternal,
|
||||
item.customFields
|
||||
)}
|
||||
className={
|
||||
'w-[120px] h-[100px] bg-input text-textColor justify-center items-center flex flex-col gap-[10px] cursor-pointer'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
|
||||
import React, { FC, FormEventHandler, useCallback, useState } from 'react';
|
||||
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { Input } from '@gitroom/react/form/input';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { useToaster } from '@gitroom/react/toaster/toaster';
|
||||
import {
|
||||
MediaComponent,
|
||||
showMediaBox,
|
||||
} from '@gitroom/frontend/components/media/media.component';
|
||||
|
||||
export const BotPicture: FC<{
|
||||
integration: Integrations;
|
||||
canChangeProfilePicture: boolean;
|
||||
canChangeNickName: boolean;
|
||||
mutate: () => void;
|
||||
}> = (props) => {
|
||||
const modal = useModals();
|
||||
const toast = useToaster();
|
||||
const [nick, setNickname] = useState(props.integration.name);
|
||||
const [picture, setPicture] = useState(props.integration.picture);
|
||||
|
||||
const fetch = useFetch();
|
||||
const submitForm: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
async (e) => {
|
||||
e.preventDefault();
|
||||
await fetch(`/integrations/${props.integration.id}/nickname`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: nick, picture }),
|
||||
});
|
||||
|
||||
props.mutate();
|
||||
toast.show('Updated', 'success');
|
||||
modal.closeAll();
|
||||
},
|
||||
[nick, picture, props.mutate]
|
||||
);
|
||||
|
||||
const openMedia = useCallback(() => {
|
||||
showMediaBox((values) => {
|
||||
setPicture(values.path);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-[4px] border border-customColor6 bg-sixth px-[16px] pb-[16px] relative w-full">
|
||||
<TopTitle title={`Change Bot Picture`} />
|
||||
<button
|
||||
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
|
||||
type="button"
|
||||
onClick={() => modal.closeAll()}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="mt-[16px]">
|
||||
<form onSubmit={submitForm} className="gap-[50px] flex flex-col">
|
||||
{props.canChangeProfilePicture && (
|
||||
<div className="flex items-center gap-[20px]">
|
||||
<img
|
||||
src={picture}
|
||||
alt="Bot Picture"
|
||||
className="w-[100px] h-[100px] rounded-full"
|
||||
/>
|
||||
<Button type="button" onClick={openMedia}>Upload</Button>
|
||||
</div>
|
||||
)}
|
||||
{props.canChangeNickName && (
|
||||
<Input
|
||||
value={nick}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
name="Nickname"
|
||||
label="Nickname"
|
||||
placeholder=""
|
||||
disableForm={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-[50px]">
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -57,6 +57,8 @@ export interface Integrations {
|
|||
identifier: string;
|
||||
type: string;
|
||||
picture: string;
|
||||
changeProfilePicture: boolean;
|
||||
changeNickName: boolean;
|
||||
time: { time: number }[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export const Filters = () => {
|
|||
<div className="flex-1">{betweenDates}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'border border-tableBorder p-[10px]',
|
||||
'border border-tableBorder p-[10px] cursor-pointer',
|
||||
week.display === 'day' && 'bg-tableBorder'
|
||||
)}
|
||||
onClick={setDay}
|
||||
|
|
@ -197,7 +197,7 @@ export const Filters = () => {
|
|||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'border border-tableBorder p-[10px]',
|
||||
'border border-tableBorder p-[10px] cursor-pointer',
|
||||
week.display === 'week' && 'bg-tableBorder'
|
||||
)}
|
||||
onClick={setWeek}
|
||||
|
|
@ -206,7 +206,7 @@ export const Filters = () => {
|
|||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'border border-tableBorder p-[10px]',
|
||||
'border border-tableBorder p-[10px] cursor-pointer',
|
||||
week.display === 'month' && 'bg-tableBorder'
|
||||
)}
|
||||
onClick={setMonth}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export const LaunchesComponent = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return ;
|
||||
return;
|
||||
}
|
||||
if (search.get('scope') === 'missing') {
|
||||
toast.show('You have to approve all the channel permissions', 'warning');
|
||||
|
|
@ -117,7 +117,7 @@ export const LaunchesComponent = () => {
|
|||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 relative">
|
||||
<div className="outline-none w-full h-full grid grid-cols-[220px_minmax(0,1fr)] gap-[30px] scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary">
|
||||
<div className="w-[220px] bg-third p-[16px] flex flex-col gap-[24px] min-h-[100%]">
|
||||
<div className="w-[220px] bg-third p-[16px] flex flex-col gap-[24px] min-h-[100%]">
|
||||
<h2 className="text-[20px]">Channels</h2>
|
||||
<div className="gap-[16px] flex flex-col">
|
||||
{sortedIntegrations.length === 0 && (
|
||||
|
|
@ -196,6 +196,8 @@ export const LaunchesComponent = () => {
|
|||
{integration.name}
|
||||
</div>
|
||||
<Menu
|
||||
canChangeProfilePicture={integration.changeProfilePicture}
|
||||
canChangeNickName={integration.changeNickName}
|
||||
mutate={mutate}
|
||||
onChange={update}
|
||||
id={integration.id}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,26 @@ import interClass from '@gitroom/react/helpers/inter.font';
|
|||
import { useModals } from '@mantine/modals';
|
||||
import { TimeTable } from '@gitroom/frontend/components/launches/time.table';
|
||||
import { useCalendar } from '@gitroom/frontend/components/launches/calendar.context';
|
||||
import { BotPicture } from '@gitroom/frontend/components/launches/bot.picture';
|
||||
|
||||
export const Menu: FC<{
|
||||
canEnable: boolean;
|
||||
canDisable: boolean;
|
||||
canChangeProfilePicture: boolean;
|
||||
canChangeNickName: boolean;
|
||||
id: string;
|
||||
mutate: () => void,
|
||||
mutate: () => void;
|
||||
onChange: (shouldReload: boolean) => void;
|
||||
}> = (props) => {
|
||||
const { canEnable, canDisable, id, onChange, mutate } = props;
|
||||
const {
|
||||
canEnable,
|
||||
canDisable,
|
||||
id,
|
||||
onChange,
|
||||
mutate,
|
||||
canChangeProfilePicture,
|
||||
canChangeNickName,
|
||||
} = props;
|
||||
const fetch = useFetch();
|
||||
const { integrations } = useCalendar();
|
||||
const toast = useToaster();
|
||||
|
|
@ -98,8 +109,30 @@ export const Menu: FC<{
|
|||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
closeOnClickOutside: false,
|
||||
children: <TimeTable integration={findIntegration!} mutate={mutate} />,
|
||||
});
|
||||
setShow(false);
|
||||
}, [integrations]);
|
||||
|
||||
const changeBotPicture = useCallback(() => {
|
||||
const findIntegration = integrations.find(
|
||||
(integration) => integration.id === id
|
||||
);
|
||||
modal.openModal({
|
||||
classNames: {
|
||||
modal: 'w-[100%] max-w-[600px] bg-transparent text-textColor',
|
||||
},
|
||||
size: '100%',
|
||||
withCloseButton: false,
|
||||
closeOnEscape: true,
|
||||
closeOnClickOutside: true,
|
||||
children: (
|
||||
<TimeTable integration={findIntegration!} mutate={mutate} />
|
||||
<BotPicture
|
||||
canChangeProfilePicture={canChangeProfilePicture}
|
||||
canChangeNickName={canChangeNickName}
|
||||
integration={findIntegration!}
|
||||
mutate={mutate}
|
||||
/>
|
||||
),
|
||||
});
|
||||
setShow(false);
|
||||
|
|
@ -128,6 +161,36 @@ export const Menu: FC<{
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
className={`absolute top-[100%] left-0 p-[8px] px-[20px] bg-fifth flex flex-col gap-[16px] z-[100] rounded-[8px] border border-tableBorder ${interClass} text-nowrap`}
|
||||
>
|
||||
{(canChangeProfilePicture || canChangeNickName) && (
|
||||
<div
|
||||
className="flex gap-[12px] items-center"
|
||||
onClick={changeBotPicture}
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M26 4H10C9.46957 4 8.96086 4.21071 8.58579 4.58579C8.21071 4.96086 8 5.46957 8 6V8H6C5.46957 8 4.96086 8.21071 4.58579 8.58579C4.21071 8.96086 4 9.46957 4 10V26C4 26.5304 4.21071 27.0391 4.58579 27.4142C4.96086 27.7893 5.46957 28 6 28H22C22.5304 28 23.0391 27.7893 23.4142 27.4142C23.7893 27.0391 24 26.5304 24 26V24H26C26.5304 24 27.0391 23.7893 27.4142 23.4142C27.7893 23.0391 28 22.5304 28 22V6C28 5.46957 27.7893 4.96086 27.4142 4.58579C27.0391 4.21071 26.5304 4 26 4ZM10 6H26V14.6725L23.9125 12.585C23.5375 12.2102 23.029 11.9997 22.4988 11.9997C21.9685 11.9997 21.46 12.2102 21.085 12.585L11.6713 22H10V6ZM22 26H6V10H8V22C8 22.5304 8.21071 23.0391 8.58579 23.4142C8.96086 23.7893 9.46957 24 10 24H22V26ZM26 22H14.5L22.5 14L26 17.5V22ZM15 14C15.5933 14 16.1734 13.8241 16.6667 13.4944C17.1601 13.1648 17.5446 12.6962 17.7716 12.1481C17.9987 11.5999 18.0581 10.9967 17.9424 10.4147C17.8266 9.83279 17.5409 9.29824 17.1213 8.87868C16.7018 8.45912 16.1672 8.1734 15.5853 8.05764C15.0033 7.94189 14.4001 8.0013 13.8519 8.22836C13.3038 8.45542 12.8352 8.83994 12.5056 9.33329C12.1759 9.82664 12 10.4067 12 11C12 11.7956 12.3161 12.5587 12.8787 13.1213C13.4413 13.6839 14.2044 14 15 14ZM15 10C15.1978 10 15.3911 10.0586 15.5556 10.1685C15.72 10.2784 15.8482 10.4346 15.9239 10.6173C15.9996 10.8 16.0194 11.0011 15.9808 11.1951C15.9422 11.3891 15.847 11.5673 15.7071 11.7071C15.5673 11.847 15.3891 11.9422 15.1951 11.9808C15.0011 12.0194 14.8 11.9996 14.6173 11.9239C14.4346 11.8482 14.2784 11.72 14.1685 11.5556C14.0586 11.3911 14 11.1978 14 11C14 10.7348 14.1054 10.4804 14.2929 10.2929C14.4804 10.1054 14.7348 10 15 10Z"
|
||||
fill="lightgreen"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[12px]">
|
||||
Change Bot{' '}
|
||||
{[
|
||||
canChangeProfilePicture && 'Picture',
|
||||
canChangeNickName && 'Nickname',
|
||||
]
|
||||
.filter((f) => f)
|
||||
.join(' / ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-[12px] items-center" onClick={editTimeTable}>
|
||||
<div>
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { FC } from 'react';
|
||||
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
|
||||
|
||||
const Empty: FC = (props) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default withProvider(null, Empty, undefined, async (posts) => {
|
||||
if (posts.some((p) => p.length > 4)) {
|
||||
return 'There can be maximum 4 pictures in a post.';
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { FC, useEffect, useState } from 'react';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { Select } from '@gitroom/react/form/select';
|
||||
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
|
||||
export const DiscordChannelSelect: FC<{
|
||||
name: string;
|
||||
onChange: (event: { target: { value: string; name: string } }) => void;
|
||||
}> = (props) => {
|
||||
const { onChange, name } = props;
|
||||
const customFunc = useCustomProviderFunction();
|
||||
const [publications, setOrgs] = useState([]);
|
||||
const { getValues } = useSettings();
|
||||
const [currentMedia, setCurrentMedia] = useState<string|undefined>();
|
||||
|
||||
const onChangeInner = (event: { target: { value: string, name: string } }) => {
|
||||
setCurrentMedia(event.target.value);
|
||||
onChange(event);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
customFunc.get('channels').then((data) => setOrgs(data));
|
||||
const settings = getValues()[props.name];
|
||||
if (settings) {
|
||||
setCurrentMedia(settings);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
if (!publications.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select name={name} label="Select Channel" onChange={onChangeInner} value={currentMedia}>
|
||||
<option value="">--Select--</option>
|
||||
{publications.map((publication: any) => (
|
||||
<option key={publication.id} value={publication.id}>
|
||||
{publication.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
|
||||
import { FC } from 'react';
|
||||
import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto';
|
||||
import { DiscordChannelSelect } from '@gitroom/frontend/components/launches/providers/discord/discord.channel.select';
|
||||
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
|
||||
const Empty: FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const DiscordComponent: FC = () => {
|
||||
const form = useSettings();
|
||||
return (
|
||||
<div>
|
||||
<DiscordChannelSelect {...form.register('channel')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default withProvider(
|
||||
DiscordComponent,
|
||||
Empty,
|
||||
DiscordDto,
|
||||
undefined,
|
||||
280
|
||||
);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { FC } from 'react';
|
||||
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
|
||||
|
||||
const Empty: FC = (props) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default withProvider(null, Empty, undefined, undefined);
|
||||
|
|
@ -13,6 +13,10 @@ import TiktokProvider from '@gitroom/frontend/components/launches/providers/tikt
|
|||
import PinterestProvider from '@gitroom/frontend/components/launches/providers/pinterest/pinterest.provider';
|
||||
import DribbbleProvider from '@gitroom/frontend/components/launches/providers/dribbble/dribbble.provider';
|
||||
import ThreadsProvider from '@gitroom/frontend/components/launches/providers/threads/threads.provider';
|
||||
import DiscordProvider from '@gitroom/frontend/components/launches/providers/discord/discord.provider';
|
||||
import SlackProvider from '@gitroom/frontend/components/launches/providers/slack/slack.provider';
|
||||
import MastodonProvider from '@gitroom/frontend/components/launches/providers/mastodon/mastodon.provider';
|
||||
import BlueskyProvider from '@gitroom/frontend/components/launches/providers/bluesky/bluesky.provider';
|
||||
|
||||
export const Providers = [
|
||||
{identifier: 'devto', component: DevtoProvider},
|
||||
|
|
@ -29,6 +33,10 @@ export const Providers = [
|
|||
{identifier: 'pinterest', component: PinterestProvider},
|
||||
{identifier: 'dribbble', component: DribbbleProvider},
|
||||
{identifier: 'threads', component: ThreadsProvider},
|
||||
{identifier: 'discord', component: DiscordProvider},
|
||||
{identifier: 'slack', component: SlackProvider},
|
||||
{identifier: 'mastodon', component: MastodonProvider},
|
||||
{identifier: 'bluesky', component: BlueskyProvider},
|
||||
];
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { FC, useEffect, useState } from 'react';
|
||||
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
|
||||
import { Select } from '@gitroom/react/form/select';
|
||||
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
|
||||
export const SlackChannelSelect: FC<{
|
||||
name: string;
|
||||
onChange: (event: { target: { value: string; name: string } }) => void;
|
||||
}> = (props) => {
|
||||
const { onChange, name } = props;
|
||||
const customFunc = useCustomProviderFunction();
|
||||
const [publications, setOrgs] = useState([]);
|
||||
const { getValues } = useSettings();
|
||||
const [currentMedia, setCurrentMedia] = useState<string|undefined>();
|
||||
|
||||
const onChangeInner = (event: { target: { value: string, name: string } }) => {
|
||||
setCurrentMedia(event.target.value);
|
||||
onChange(event);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
customFunc.get('channels').then((data) => setOrgs(data));
|
||||
const settings = getValues()[props.name];
|
||||
if (settings) {
|
||||
setCurrentMedia(settings);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
if (!publications.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select name={name} label="Select Channel" onChange={onChangeInner} value={currentMedia}>
|
||||
<option value="">--Select--</option>
|
||||
{publications.map((publication: any) => (
|
||||
<option key={publication.id} value={publication.id}>
|
||||
{publication.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
|
||||
import { FC } from 'react';
|
||||
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
|
||||
import { SlackChannelSelect } from '@gitroom/frontend/components/launches/providers/slack/slack.channel.select';
|
||||
import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto';
|
||||
|
||||
const Empty: FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const SlackComponent: FC = () => {
|
||||
const form = useSettings();
|
||||
return (
|
||||
<div>
|
||||
<SlackChannelSelect {...form.register('channel')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default withProvider(
|
||||
SlackComponent,
|
||||
Empty,
|
||||
SlackDto,
|
||||
undefined,
|
||||
280
|
||||
);
|
||||
|
|
@ -102,4 +102,10 @@ const XPreview: FC = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default withProvider(null, XPreview, undefined, undefined, 280);
|
||||
export default withProvider(null, XPreview, undefined, async (posts) => {
|
||||
if (posts.some(p => p.length > 4)) {
|
||||
return 'There can be maximum 4 pictures in a post.';
|
||||
}
|
||||
|
||||
return true;
|
||||
}, 280);
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ export const ContinueProvider: FC = () => {
|
|||
name: '',
|
||||
picture: '',
|
||||
inBetweenSteps: true,
|
||||
changeNickName: false,
|
||||
changeProfilePicture: false,
|
||||
identifier: added,
|
||||
},
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import { FC, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export const Redirect: FC<{url: string, delay: number}> = (props) => {
|
||||
const { url, delay } = props;
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
router.push(url);
|
||||
}, delay);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -155,12 +155,12 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[12px]" onClick={openMedia}>
|
||||
<div className="text-[12px] text-white" onClick={openMedia}>
|
||||
Upload image
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="h-[24px] w-[88px] rounded-[4px] border-2 border-customColor21 flex justify-center items-center gap-[4px]"
|
||||
className="h-[24px] w-[88px] rounded-[4px] border-2 border-customColor21 hover:text-red-600 flex justify-center items-center gap-[4px]"
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
|
|
@ -173,11 +173,11 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
|
|||
>
|
||||
<path
|
||||
d="M11.8125 2.625H9.625V2.1875C9.625 1.8394 9.48672 1.50556 9.24058 1.25942C8.99444 1.01328 8.6606 0.875 8.3125 0.875H5.6875C5.3394 0.875 5.00556 1.01328 4.75942 1.25942C4.51328 1.50556 4.375 1.8394 4.375 2.1875V2.625H2.1875C2.07147 2.625 1.96019 2.67109 1.87814 2.75314C1.79609 2.83519 1.75 2.94647 1.75 3.0625C1.75 3.17853 1.79609 3.28981 1.87814 3.37186C1.96019 3.45391 2.07147 3.5 2.1875 3.5H2.625V11.375C2.625 11.6071 2.71719 11.8296 2.88128 11.9937C3.04538 12.1578 3.26794 12.25 3.5 12.25H10.5C10.7321 12.25 10.9546 12.1578 11.1187 11.9937C11.2828 11.8296 11.375 11.6071 11.375 11.375V3.5H11.8125C11.9285 3.5 12.0398 3.45391 12.1219 3.37186C12.2039 3.28981 12.25 3.17853 12.25 3.0625C12.25 2.94647 12.2039 2.83519 12.1219 2.75314C12.0398 2.67109 11.9285 2.625 11.8125 2.625ZM5.25 2.1875C5.25 2.07147 5.29609 1.96019 5.37814 1.87814C5.46019 1.79609 5.57147 1.75 5.6875 1.75H8.3125C8.42853 1.75 8.53981 1.79609 8.62186 1.87814C8.70391 1.96019 8.75 2.07147 8.75 2.1875V2.625H5.25V2.1875ZM10.5 11.375H3.5V3.5H10.5V11.375ZM6.125 5.6875V9.1875C6.125 9.30353 6.07891 9.41481 5.99686 9.49686C5.91481 9.57891 5.80353 9.625 5.6875 9.625C5.57147 9.625 5.46019 9.57891 5.37814 9.49686C5.29609 9.41481 5.25 9.30353 5.25 9.1875V5.6875C5.25 5.57147 5.29609 5.46019 5.37814 5.37814C5.46019 5.29609 5.57147 5.25 5.6875 5.25C5.80353 5.25 5.91481 5.29609 5.99686 5.37814C6.07891 5.46019 6.125 5.57147 6.125 5.6875ZM8.75 5.6875V9.1875C8.75 9.30353 8.70391 9.41481 8.62186 9.49686C8.53981 9.57891 8.42853 9.625 8.3125 9.625C8.19647 9.625 8.08519 9.57891 8.00314 9.49686C7.92109 9.41481 7.875 9.30353 7.875 9.1875V5.6875C7.875 5.57147 7.92109 5.46019 8.00314 5.37814C8.08519 5.29609 8.19647 5.25 8.3125 5.25C8.42853 5.25 8.53981 5.29609 8.62186 5.37814C8.70391 5.46019 8.75 5.57147 8.75 5.6875Z"
|
||||
fill="white"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[12px]" onClick={remove}>
|
||||
<div className="text-[12px] " onClick={remove}>
|
||||
Remove
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -191,7 +191,7 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
|
|||
</div>
|
||||
{!getRef && (
|
||||
<div className="justify-end flex">
|
||||
<Button type="submit">Save</Button>
|
||||
<Button type="submit" className='rounded-md'>Save</Button>
|
||||
</div>
|
||||
)}
|
||||
{!!user?.tier?.team_members && isGeneral && <TeamsComponent />}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,8 @@ export const ConnectChannels: FC = () => {
|
|||
{integration.name}
|
||||
</div>
|
||||
<Menu
|
||||
canChangeProfilePicture={integration.changeProfile}
|
||||
canChangeNickName={integration.changeNickName}
|
||||
mutate={mutate}
|
||||
onChange={update}
|
||||
id={integration.id}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { fetchBackend } from '@gitroom/helpers/utils/custom.fetch.func';
|
||||
import { removeSubdomain } from '@gitroom/helpers/subdomain/subdomain.management';
|
||||
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
|
||||
|
||||
// This function can be marked `async` if using `await` inside
|
||||
export async function middleware(request: NextRequest) {
|
||||
|
|
@ -19,8 +19,7 @@ export async function middleware(request: NextRequest) {
|
|||
httpOnly: true,
|
||||
secure: true,
|
||||
maxAge: -1,
|
||||
domain:
|
||||
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
|
||||
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
|
@ -30,9 +29,17 @@ export async function middleware(request: NextRequest) {
|
|||
|
||||
if (nextUrl.href.indexOf('/auth') === -1 && !authCookie) {
|
||||
const providers = ['google', 'settings'];
|
||||
const findIndex = providers.find(p => nextUrl.href.indexOf(p) > -1);
|
||||
const additional = !findIndex ? '' : (url.indexOf('?') > -1 ? '&' : '?') + `provider=${(findIndex === 'settings' ? 'github' : findIndex).toUpperCase()}`;
|
||||
return NextResponse.redirect(new URL(`/auth${url}${additional}`, nextUrl.href));
|
||||
const findIndex = providers.find((p) => nextUrl.href.indexOf(p) > -1);
|
||||
const additional = !findIndex
|
||||
? ''
|
||||
: (url.indexOf('?') > -1 ? '&' : '?') +
|
||||
`provider=${(findIndex === 'settings'
|
||||
? 'github'
|
||||
: findIndex
|
||||
).toUpperCase()}`;
|
||||
return NextResponse.redirect(
|
||||
new URL(`/auth${url}${additional}`, nextUrl.href)
|
||||
);
|
||||
}
|
||||
|
||||
// If the url is /auth and the cookie exists, redirect to /
|
||||
|
|
@ -49,8 +56,7 @@ export async function middleware(request: NextRequest) {
|
|||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: new Date(Date.now() + 15 * 60 * 1000),
|
||||
domain:
|
||||
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
|
||||
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
|
||||
});
|
||||
return redirect;
|
||||
}
|
||||
|
|
@ -81,8 +87,7 @@ export async function middleware(request: NextRequest) {
|
|||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: new Date(Date.now() + 15 * 60 * 1000),
|
||||
domain:
|
||||
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
|
||||
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +96,10 @@ export async function middleware(request: NextRequest) {
|
|||
|
||||
if (nextUrl.pathname === '/') {
|
||||
return NextResponse.redirect(
|
||||
new URL(!!process.env.IS_GENERAL ? '/launches' : `/analytics`, nextUrl.href)
|
||||
new URL(
|
||||
!!process.env.IS_GENERAL ? '/launches' : `/analytics`,
|
||||
nextUrl.href
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -109,8 +117,7 @@ export async function middleware(request: NextRequest) {
|
|||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: new Date(Date.now() + 15 * 60 * 1000),
|
||||
domain:
|
||||
'.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname,
|
||||
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bu
|
|||
|
||||
@Module({
|
||||
imports: [DatabaseModule, BullMqModule],
|
||||
controllers: [StarsController, PostsController],
|
||||
controllers: [...!process.env.IS_GENERAL ? [StarsController] : [], PostsController],
|
||||
providers: [TrendingService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import { readFileSync, existsSync } from 'fs'
|
||||
import * as dotenv from 'dotenv'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export class ConfigurationChecker {
|
||||
cfg: dotenv.DotenvParseOutput
|
||||
issues: string[] = []
|
||||
|
||||
readEnvFromFile () {
|
||||
const envFile = resolve(__dirname, '../../../.env')
|
||||
|
||||
if (!existsSync(envFile)) {
|
||||
console.error('Env file not found!: ', envFile)
|
||||
return
|
||||
}
|
||||
|
||||
const handle = readFileSync(envFile, 'utf-8')
|
||||
|
||||
this.cfg = dotenv.parse(handle)
|
||||
}
|
||||
|
||||
readEnvFromProcess () {
|
||||
this.cfg = process.env
|
||||
}
|
||||
|
||||
check () {
|
||||
this.checkDatabaseServers()
|
||||
this.checkNonEmpty('JWT_SECRET')
|
||||
this.checkIsValidUrl('MAIN_URL')
|
||||
this.checkIsValidUrl('FRONTEND_URL')
|
||||
this.checkIsValidUrl('NEXT_PUBLIC_BACKEND_URL')
|
||||
this.checkIsValidUrl('BACKEND_INTERNAL_URL')
|
||||
this.checkNonEmpty('CLOUDFLARE_ACCOUNT_ID', 'Needed to setup providers.')
|
||||
this.checkNonEmpty('CLOUDFLARE_ACCESS_KEY', 'Needed to setup providers.')
|
||||
this.checkNonEmpty('CLOUDFLARE_SECRET_ACCESS_KEY', 'Needed to setup providers.')
|
||||
this.checkNonEmpty('CLOUDFLARE_BUCKETNAME', 'Needed to setup providers.')
|
||||
this.checkNonEmpty('CLOUDFLARE_BUCKET_URL', 'Needed to setup providers.')
|
||||
this.checkNonEmpty('CLOUDFLARE_REGION', 'Needed to setup providers.')
|
||||
}
|
||||
|
||||
checkNonEmpty (key: string, description?: string): boolean {
|
||||
const v = this.get(key)
|
||||
|
||||
if (!description) {
|
||||
description = ''
|
||||
}
|
||||
|
||||
if (!v) {
|
||||
this.issues.push(key + ' not set. ' + description)
|
||||
return false
|
||||
}
|
||||
|
||||
if (v.length === 0) {
|
||||
this.issues.push(key + ' is empty.' + description)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
get(key: string): string | undefined {
|
||||
return this.cfg[key as keyof typeof this.cfg]
|
||||
}
|
||||
|
||||
checkDatabaseServers () {
|
||||
this.checkRedis()
|
||||
this.checkIsValidUrl('DATABASE_URL')
|
||||
}
|
||||
|
||||
checkRedis () {
|
||||
if (!this.cfg.REDIS_URL) {
|
||||
this.issues.push('REDIS_URL not set')
|
||||
}
|
||||
|
||||
try {
|
||||
const redisUrl = new URL(this.cfg.REDIS_URL)
|
||||
|
||||
if (redisUrl.protocol !== 'redis:') {
|
||||
this.issues.push('REDIS_URL must start with redis://')
|
||||
}
|
||||
} catch (error) {
|
||||
this.issues.push('REDIS_URL is not a valid URL')
|
||||
}
|
||||
}
|
||||
|
||||
checkIsValidUrl (key: string) {
|
||||
if (!this.checkNonEmpty(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
const urlString = this.get(key)
|
||||
|
||||
try {
|
||||
new URL(urlString)
|
||||
} catch (error) {
|
||||
this.issues.push(key + ' is not a valid URL')
|
||||
}
|
||||
|
||||
if (urlString.endsWith('/')) {
|
||||
this.issues.push(key + ' should not end with /')
|
||||
}
|
||||
}
|
||||
|
||||
hasIssues() {
|
||||
return this.issues.length > 0
|
||||
}
|
||||
|
||||
getIssues() {
|
||||
return this.issues
|
||||
}
|
||||
|
||||
getIssuesCount() {
|
||||
return this.issues.length
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +1,6 @@
|
|||
import {allTwoLevelSubdomain} from "./all.two.level.subdomain";
|
||||
const ipRegex = /^(https?:\/\/)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(:\d+)?$/;
|
||||
|
||||
export function removeSubdomain(domain: string) {
|
||||
// Check if the domain is an IP address with optional port
|
||||
if (ipRegex.test(domain)) {
|
||||
return domain; // Return the original domain if it's an IP address
|
||||
}
|
||||
// Split the domain into its parts
|
||||
const parts = domain.split('.');
|
||||
|
||||
// Check if there are at least two parts (e.g., 'example.com')
|
||||
if (parts.length < 2) {
|
||||
return domain; // Return the original domain if it's too short to have a subdomain
|
||||
}
|
||||
|
||||
if (parts.length > 2) {
|
||||
const lastTwo = parts.slice(-2).join('.');
|
||||
if (allTwoLevelSubdomain.includes(lastTwo)) {
|
||||
return 'https://' + parts.slice(-3).join('.'); // Return the last three parts for known second-level domains
|
||||
}
|
||||
}
|
||||
|
||||
// Return the last two parts for standard domains
|
||||
return 'https://' + parts.slice(-2).join('.');
|
||||
}
|
||||
|
||||
import { parse } from 'tldts';
|
||||
|
||||
export function getCookieUrlFromDomain(domain: string) {
|
||||
const url = removeSubdomain(domain);
|
||||
const urlObj = new URL(url);
|
||||
if (!ipRegex.test(domain)) {
|
||||
return '.' + urlObj.hostname
|
||||
}
|
||||
|
||||
return urlObj.hostname;
|
||||
const url = parse(domain);
|
||||
return url.domain! ? "." + url.domain! : url.hostname!;
|
||||
}
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
import {readFileSync} from "fs";
|
||||
import axios from 'axios';
|
||||
|
||||
export const readOrFetch = async (path: string) => {
|
||||
if (path.indexOf('http') === 0) {
|
||||
return (await fetch(path)).arrayBuffer();
|
||||
return (await axios({
|
||||
url: path,
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
})).data;
|
||||
}
|
||||
|
||||
return readFileSync(path);
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ export class IntegrationRepository {
|
|||
username?: string,
|
||||
isBetweenSteps = false,
|
||||
refresh?: string,
|
||||
timezone?: number
|
||||
timezone?: number,
|
||||
customInstanceDetails?: string
|
||||
) {
|
||||
const postTimes = timezone
|
||||
? {
|
||||
|
|
@ -113,6 +114,7 @@ export class IntegrationRepository {
|
|||
...postTimes,
|
||||
organizationId: org,
|
||||
refreshNeeded: false,
|
||||
...(customInstanceDetails ? { customInstanceDetails } : {}),
|
||||
},
|
||||
update: {
|
||||
type: type as any,
|
||||
|
|
@ -121,7 +123,6 @@ export class IntegrationRepository {
|
|||
inBetweenSteps: isBetweenSteps,
|
||||
}
|
||||
: {}),
|
||||
name,
|
||||
picture,
|
||||
profile: username,
|
||||
providerIdentifier: provider,
|
||||
|
|
@ -163,6 +164,18 @@ export class IntegrationRepository {
|
|||
});
|
||||
}
|
||||
|
||||
updateNameAndUrl(id: string, name: string, url: string) {
|
||||
return this._integration.model.integration.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
...(name ? { name } : {}),
|
||||
...(url ? { picture: url } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getIntegrationById(org: string, id: string) {
|
||||
return this._integration.model.integration.findFirst({
|
||||
where: {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ export class IntegrationService {
|
|||
username?: string,
|
||||
isBetweenSteps = false,
|
||||
refresh?: string,
|
||||
timezone?: number
|
||||
timezone?: number,
|
||||
customInstanceDetails?: string
|
||||
) {
|
||||
const loadImage = await axios.get(picture, { responseType: 'arraybuffer' });
|
||||
const uploadedPicture = await simpleUpload(
|
||||
|
|
@ -69,7 +70,8 @@ export class IntegrationService {
|
|||
username,
|
||||
isBetweenSteps,
|
||||
refresh,
|
||||
timezone
|
||||
timezone,
|
||||
customInstanceDetails
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +88,10 @@ export class IntegrationService {
|
|||
);
|
||||
}
|
||||
|
||||
updateNameAndUrl(id: string, name: string, url: string) {
|
||||
return this._integrationRepository.updateNameAndUrl(id, name, url);
|
||||
}
|
||||
|
||||
getIntegrationById(org: string, id: string) {
|
||||
return this._integrationRepository.getIntegrationById(org, id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,4 +40,8 @@ export class NotificationService {
|
|||
async sendEmail(to: string, subject: string, html: string) {
|
||||
await this._emailService.sendEmail(to, subject, html);
|
||||
}
|
||||
|
||||
hasEmailProvider() {
|
||||
return this._emailService.hasProvider();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,7 +177,8 @@ export class OrganizationRepository {
|
|||
}
|
||||
|
||||
async createOrgAndUser(
|
||||
body: Omit<CreateOrgUserDto, 'providerToken'> & { providerId?: string }
|
||||
body: Omit<CreateOrgUserDto, 'providerToken'> & { providerId?: string },
|
||||
hasEmail: boolean
|
||||
) {
|
||||
return this._organization.model.organization.create({
|
||||
data: {
|
||||
|
|
@ -187,7 +188,7 @@ export class OrganizationRepository {
|
|||
role: Role.SUPERADMIN,
|
||||
user: {
|
||||
create: {
|
||||
activated: body.provider !== 'LOCAL' || !process.env.RESEND_API_KEY,
|
||||
activated: body.provider !== 'LOCAL' || !hasEmail,
|
||||
email: body.email,
|
||||
password: body.password
|
||||
? AuthService.hashPassword(body.password)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export class OrganizationService {
|
|||
async createOrgAndUser(
|
||||
body: Omit<CreateOrgUserDto, 'providerToken'> & { providerId?: string }
|
||||
) {
|
||||
return this._organizationRepository.createOrgAndUser(body);
|
||||
return this._organizationRepository.createOrgAndUser(body, this._notificationsService.hasEmailProvider());
|
||||
}
|
||||
|
||||
addUserToOrg(
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ export class PostsService {
|
|||
`An error occurred while posting on ${firstPost.integration?.providerIdentifier}`,
|
||||
true
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +161,25 @@ export class PostsService {
|
|||
}`,
|
||||
true
|
||||
);
|
||||
|
||||
if (err instanceof BadBody) {
|
||||
console.error(
|
||||
'[Error] posting on',
|
||||
firstPost.integration?.providerIdentifier,
|
||||
err.identifier,
|
||||
err.json,
|
||||
err.body,
|
||||
err
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'[Error] posting on',
|
||||
firstPost.integration?.providerIdentifier,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,7 +278,8 @@ export class PostsService {
|
|||
? process.env.UPLOAD_DIRECTORY + m.path
|
||||
: m.path,
|
||||
})),
|
||||
}))
|
||||
})),
|
||||
integration
|
||||
);
|
||||
|
||||
for (const post of publishedPosts) {
|
||||
|
|
@ -287,17 +308,6 @@ export class PostsService {
|
|||
return this.postSocial(integration, posts, true);
|
||||
}
|
||||
|
||||
if (
|
||||
err instanceof BadBody &&
|
||||
process.env.EMAIL_FROM_ADDRESS === 'nevo@postiz.com'
|
||||
) {
|
||||
await this._notificationService.sendEmail(
|
||||
'nevo@positz.com',
|
||||
'Bad body',
|
||||
JSON.stringify(err.body)
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,27 +242,28 @@ model Subscription {
|
|||
}
|
||||
|
||||
model Integration {
|
||||
id String @id @default(cuid())
|
||||
internalId String
|
||||
organizationId String
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
picture String?
|
||||
providerIdentifier String
|
||||
type String
|
||||
token String
|
||||
disabled Boolean @default(false)
|
||||
tokenExpiration DateTime?
|
||||
refreshToken String?
|
||||
posts Post[]
|
||||
profile String?
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
orderItems OrderItems[]
|
||||
inBetweenSteps Boolean @default(false)
|
||||
refreshNeeded Boolean @default(false)
|
||||
postingTimes String @default("[{\"time\":120}, {\"time\":400}, {\"time\":700}]")
|
||||
id String @id @default(cuid())
|
||||
internalId String
|
||||
organizationId String
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
picture String?
|
||||
providerIdentifier String
|
||||
type String
|
||||
token String
|
||||
disabled Boolean @default(false)
|
||||
tokenExpiration DateTime?
|
||||
refreshToken String?
|
||||
posts Post[]
|
||||
profile String?
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
orderItems OrderItems[]
|
||||
inBetweenSteps Boolean @default(false)
|
||||
refreshNeeded Boolean @default(false)
|
||||
postingTimes String @default("[{\"time\":120}, {\"time\":400}, {\"time\":700}]")
|
||||
customInstanceDetails String?
|
||||
|
||||
@@index([updatedAt])
|
||||
@@index([deletedAt])
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/provide
|
|||
import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/pinterest.dto';
|
||||
import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto';
|
||||
import { TikTokDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tiktok.dto';
|
||||
import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto';
|
||||
import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto';
|
||||
|
||||
export class EmptySettings {}
|
||||
export class Integration {
|
||||
|
|
@ -68,6 +70,8 @@ export class Post {
|
|||
{ value: PinterestSettingsDto, name: 'pinterest' },
|
||||
{ value: DribbbleDto, name: 'dribbble' },
|
||||
{ value: TikTokDto, name: 'tiktok' },
|
||||
{ value: DiscordDto, name: 'discord' },
|
||||
{ value: SlackDto, name: 'slack' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { IsDefined, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class DiscordDto {
|
||||
@MinLength(1)
|
||||
@IsDefined()
|
||||
@IsString()
|
||||
channel: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { IsDefined, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class SlackDto {
|
||||
@MinLength(1)
|
||||
@IsDefined()
|
||||
@IsString()
|
||||
channel: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export interface EmailInterface {
|
||||
name: string;
|
||||
validateEnvKeys: string[];
|
||||
sendEmail(to: string, subject: string, html: string, emailFromName: string, emailFromAddress: string): Promise<any>;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { EmailInterface } from "./email.interface";
|
||||
|
||||
export class EmptyProvider implements EmailInterface {
|
||||
name = 'no provider';
|
||||
validateEnvKeys = [];
|
||||
async sendEmail(to: string, subject: string, html: string) {
|
||||
return `No email provider found, email was supposed to be sent to ${to} with subject: ${subject} and ${html}, html`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
import { EmailInterface } from '@gitroom/nestjs-libraries/emails/email.interface';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: +process.env.EMAIL_PORT!,
|
||||
secure: process.env.EMAIL_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
export class NodeMailerProvider implements EmailInterface {
|
||||
name = 'nodemailer';
|
||||
validateEnvKeys = [
|
||||
'EMAIL_HOST',
|
||||
'EMAIL_PORT',
|
||||
'EMAIL_SECURE',
|
||||
'EMAIL_USER',
|
||||
'EMAIL_PASS',
|
||||
];
|
||||
async sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
emailFromName: string,
|
||||
emailFromAddress: string
|
||||
) {
|
||||
const sends = await transporter.sendMail({
|
||||
from:`${emailFromName} <${emailFromAddress}>`, // sender address
|
||||
to: to, // list of receivers
|
||||
subject: subject, // Subject line
|
||||
text: html, // plain text body
|
||||
html: html, // html body
|
||||
});
|
||||
|
||||
return sends;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Resend } from 'resend';
|
||||
import { EmailInterface } from '@gitroom/nestjs-libraries/emails/email.interface';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY || 're_132');
|
||||
|
||||
export class ResendProvider implements EmailInterface {
|
||||
name = 'resend';
|
||||
validateEnvKeys = ['RESEND_API_KEY'];
|
||||
async sendEmail(to: string, subject: string, html: string, emailFromName: string, emailFromAddress: string) {
|
||||
const sends = await resend.emails.send({
|
||||
from: `${emailFromName} <${emailFromAddress}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
|
||||
return sends;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,8 +15,13 @@ import { PinterestProvider } from '@gitroom/nestjs-libraries/integrations/social
|
|||
import { DribbbleProvider } from '@gitroom/nestjs-libraries/integrations/social/dribbble.provider';
|
||||
import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider';
|
||||
import { ThreadsProvider } from '@gitroom/nestjs-libraries/integrations/social/threads.provider';
|
||||
import { DiscordProvider } from '@gitroom/nestjs-libraries/integrations/social/discord.provider';
|
||||
import { SlackProvider } from '@gitroom/nestjs-libraries/integrations/social/slack.provider';
|
||||
import { MastodonProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.provider';
|
||||
import { BlueskyProvider } from '@gitroom/nestjs-libraries/integrations/social/bluesky.provider';
|
||||
// import { MastodonCustomProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.custom.provider';
|
||||
|
||||
const socialIntegrationList = [
|
||||
const socialIntegrationList: SocialProvider[] = [
|
||||
new XProvider(),
|
||||
new LinkedinProvider(),
|
||||
new LinkedinPageProvider(),
|
||||
|
|
@ -28,6 +33,11 @@ const socialIntegrationList = [
|
|||
new TiktokProvider(),
|
||||
new PinterestProvider(),
|
||||
new DribbbleProvider(),
|
||||
new DiscordProvider(),
|
||||
new SlackProvider(),
|
||||
new MastodonProvider(),
|
||||
new BlueskyProvider(),
|
||||
// new MastodonCustomProvider(),
|
||||
];
|
||||
|
||||
const articleIntegrationList = [
|
||||
|
|
@ -38,12 +48,16 @@ const articleIntegrationList = [
|
|||
|
||||
@Injectable()
|
||||
export class IntegrationManager {
|
||||
getAllIntegrations() {
|
||||
async getAllIntegrations() {
|
||||
return {
|
||||
social: socialIntegrationList.map((p) => ({
|
||||
name: p.name,
|
||||
identifier: p.identifier,
|
||||
})),
|
||||
social: await Promise.all(
|
||||
socialIntegrationList.map(async (p) => ({
|
||||
name: p.name,
|
||||
identifier: p.identifier,
|
||||
isExternal: !!p.externalUrl,
|
||||
...(p.customFields ? { customFields: await p.customFields() } : {}),
|
||||
}))
|
||||
),
|
||||
article: articleIntegrationList.map((p) => ({
|
||||
name: p.name,
|
||||
identifier: p.identifier,
|
||||
|
|
|
|||
|
|
@ -1,32 +1,40 @@
|
|||
export class RefreshToken {}
|
||||
export class RefreshToken {
|
||||
constructor(
|
||||
public identifier: string,
|
||||
public json: string,
|
||||
public body: BodyInit
|
||||
) {}
|
||||
}
|
||||
export class BadBody {
|
||||
constructor(public body: BodyInit) {
|
||||
}
|
||||
constructor(
|
||||
public identifier: string,
|
||||
public json: string,
|
||||
public body: BodyInit
|
||||
) {}
|
||||
}
|
||||
|
||||
export class NotEnoughScopes {}
|
||||
|
||||
export abstract class SocialAbstract {
|
||||
async fetch(url: string, options: RequestInit = {}) {
|
||||
async fetch(url: string, options: RequestInit = {}, identifier = '') {
|
||||
const request = await fetch(url, options);
|
||||
|
||||
if (request.status !== 200 && request.status !== 201) {
|
||||
try {
|
||||
console.log(await request.json());
|
||||
}
|
||||
catch (err) {
|
||||
console.log('skip');
|
||||
}
|
||||
if (request.status === 200 || request.status === 201) {
|
||||
return request;
|
||||
}
|
||||
|
||||
let json = '{}';
|
||||
try {
|
||||
json = await request.text();
|
||||
} catch (err) {
|
||||
json = '{}';
|
||||
}
|
||||
|
||||
if (request.status === 401) {
|
||||
throw new RefreshToken();
|
||||
throw new RefreshToken(identifier, json, options.body!);
|
||||
}
|
||||
|
||||
if (request.status === 400) {
|
||||
throw new BadBody(options.body!);
|
||||
}
|
||||
|
||||
return request;
|
||||
throw new BadBody(identifier, json, options.body!);
|
||||
}
|
||||
|
||||
checkScopes(required: string[], got: string | string[]) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import {
|
||||
AuthTokenDetails,
|
||||
PostDetails,
|
||||
PostResponse,
|
||||
SocialProvider,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import { BskyAgent } from '@atproto/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { Integration } from '@prisma/client';
|
||||
import { AuthService } from '@gitroom/helpers/auth/auth.service';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export class BlueskyProvider extends SocialAbstract implements SocialProvider {
|
||||
identifier = 'bluesky';
|
||||
name = 'Bluesky';
|
||||
isBetweenSteps = false;
|
||||
scopes = ['write:statuses', 'profile', 'write:media'];
|
||||
|
||||
async customFields() {
|
||||
return [
|
||||
{
|
||||
key: 'service',
|
||||
label: 'Service',
|
||||
defaultValue: 'https://bsky.social',
|
||||
validation: `/^(https?:\\/\\/)?((([a-zA-Z0-9\\-_]{1,256}\\.[a-zA-Z]{2,6})|(([0-9]{1,3}\\.){3}[0-9]{1,3}))(:[0-9]{1,5})?)(\\/[^\\s]*)?$/`,
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
key: 'identifier',
|
||||
label: 'Identifier',
|
||||
validation: `/^.{3,}$/`,
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'Password',
|
||||
validation: `/^.{3,}$/`,
|
||||
type: 'password' as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
|
||||
return {
|
||||
refreshToken: '',
|
||||
expiresIn: 0,
|
||||
accessToken: '',
|
||||
id: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
username: '',
|
||||
};
|
||||
}
|
||||
|
||||
async generateAuthUrl(refresh?: string) {
|
||||
const state = makeId(6);
|
||||
return {
|
||||
url: '',
|
||||
codeVerifier: makeId(10),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
refresh?: string;
|
||||
}) {
|
||||
const body = JSON.parse(Buffer.from(params.code, 'base64').toString());
|
||||
|
||||
const agent = new BskyAgent({
|
||||
service: body.service,
|
||||
});
|
||||
|
||||
const {
|
||||
data: { accessJwt, refreshJwt, handle, did },
|
||||
} = await agent.login({
|
||||
identifier: body.identifier,
|
||||
password: body.password,
|
||||
});
|
||||
|
||||
const profile = await agent.getProfile({
|
||||
actor: did,
|
||||
});
|
||||
|
||||
return {
|
||||
refreshToken: refreshJwt,
|
||||
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
|
||||
accessToken: accessJwt,
|
||||
id: did,
|
||||
name: profile.data.displayName!,
|
||||
picture: profile.data.avatar!,
|
||||
username: profile.data.handle!,
|
||||
};
|
||||
}
|
||||
|
||||
async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[],
|
||||
integration: Integration
|
||||
): Promise<PostResponse[]> {
|
||||
const body = JSON.parse(
|
||||
AuthService.fixedDecryption(integration.customInstanceDetails!)
|
||||
);
|
||||
const agent = new BskyAgent({
|
||||
service: body.service,
|
||||
});
|
||||
|
||||
await agent.login({
|
||||
identifier: body.identifier,
|
||||
password: body.password,
|
||||
});
|
||||
|
||||
let loadCid = '';
|
||||
let loadUri = '';
|
||||
const cidUrl = [] as { cid: string; url: string, rev: string }[];
|
||||
for (const post of postDetails) {
|
||||
const images = await Promise.all(
|
||||
post.media?.map(async (p) => {
|
||||
return await agent.uploadBlob(
|
||||
new Blob([
|
||||
await sharp(await (await fetch(p.url)).arrayBuffer())
|
||||
.resize({ width: 400 })
|
||||
.toBuffer(),
|
||||
])
|
||||
);
|
||||
}) || []
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const { cid, uri, commit } = await agent.post({
|
||||
text: post.message,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(images.length
|
||||
? {
|
||||
embed: {
|
||||
$type: 'app.bsky.embed.images',
|
||||
images: images.map((p) => ({
|
||||
// can be an array up to 4 values
|
||||
alt: 'image', // the alt text
|
||||
image: p.data.blob,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(loadCid
|
||||
? {
|
||||
reply: {
|
||||
root: {
|
||||
uri: loadUri,
|
||||
cid: loadCid,
|
||||
},
|
||||
parent: {
|
||||
uri: loadUri,
|
||||
cid: loadCid,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
loadCid = loadCid || cid;
|
||||
loadUri = loadUri || uri;
|
||||
|
||||
cidUrl.push({ cid, url: uri, rev: commit.rev });
|
||||
}
|
||||
|
||||
return postDetails.map((p, index) => ({
|
||||
id: p.id,
|
||||
postId: cidUrl[index].cid,
|
||||
status: 'completed',
|
||||
releaseURL: `https://bsky.app/profile/${id}/post/${cidUrl[index].url.split('/').pop()}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import {
|
||||
AuthTokenDetails,
|
||||
PostDetails,
|
||||
PostResponse,
|
||||
SocialProvider,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
|
||||
export class DiscordProvider extends SocialAbstract implements SocialProvider {
|
||||
identifier = 'discord';
|
||||
name = 'Discord';
|
||||
isBetweenSteps = false;
|
||||
scopes = ['identify', 'guilds'];
|
||||
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
|
||||
const { access_token, expires_in, refresh_token } = await (
|
||||
await this.fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
process.env.DISCORD_CLIENT_ID +
|
||||
':' +
|
||||
process.env.DISCORD_CLIENT_SECRET
|
||||
).toString('base64')}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
const { application } = await (
|
||||
await fetch('https://discord.com/api/oauth2/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
refreshToken: refresh_token,
|
||||
expiresIn: expires_in,
|
||||
accessToken: access_token,
|
||||
id: '',
|
||||
name: application.name,
|
||||
picture: '',
|
||||
username: '',
|
||||
};
|
||||
}
|
||||
async generateAuthUrl(refresh?: string) {
|
||||
const state = makeId(6);
|
||||
return {
|
||||
url: `https://discord.com/oauth2/authorize?client_id=${
|
||||
process.env.DISCORD_CLIENT_ID
|
||||
}&permissions=377957124096&response_type=code&redirect_uri=${encodeURIComponent(
|
||||
`${process.env.FRONTEND_URL}/integrations/social/discord${
|
||||
refresh ? `?refresh=${refresh}` : ''
|
||||
}`
|
||||
)}&integration_type=0&scope=bot+identify+guilds&state=${state}`,
|
||||
codeVerifier: makeId(10),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
refresh?: string;
|
||||
}) {
|
||||
const { access_token, expires_in, refresh_token, scope, guild } = await (
|
||||
await this.fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
code: params.code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/discord`,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
process.env.DISCORD_CLIENT_ID +
|
||||
':' +
|
||||
process.env.DISCORD_CLIENT_SECRET
|
||||
).toString('base64')}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
this.checkScopes(this.scopes, scope.split(' '));
|
||||
|
||||
const { application } = await (
|
||||
await fetch('https://discord.com/api/oauth2/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
id: guild.id,
|
||||
name: application.name,
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
expiresIn: expires_in,
|
||||
picture: `https://cdn.discordapp.com/avatars/${application.bot.id}/${application.bot.avatar}.png`,
|
||||
username: application.bot.username,
|
||||
};
|
||||
}
|
||||
|
||||
async channels(accessToken: string, params: any, id: string) {
|
||||
const list = await (
|
||||
await fetch(`https://discord.com/api/guilds/${id}/channels`, {
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
console.log(list);
|
||||
|
||||
return list
|
||||
.filter((p: any) => p.type === 0 || p.type === 15)
|
||||
.map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[]
|
||||
): Promise<PostResponse[]> {
|
||||
let channel = postDetails[0].settings.channel;
|
||||
if (postDetails.length > 1) {
|
||||
const { id: threadId } = await (
|
||||
await fetch(
|
||||
`https://discord.com/api/channels/${postDetails[0].settings.channel}/threads`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: postDetails[0].message,
|
||||
auto_archive_duration: 1440,
|
||||
type: 11, // Public thread type
|
||||
}),
|
||||
}
|
||||
)
|
||||
).json();
|
||||
channel = threadId;
|
||||
}
|
||||
|
||||
const finalData = [];
|
||||
for (const post of postDetails) {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'payload_json',
|
||||
JSON.stringify({
|
||||
content: post.message,
|
||||
attachments: post.media?.map((p, index) => ({
|
||||
id: index,
|
||||
description: `Picture ${index}`,
|
||||
filename: p.url.split('/').pop(),
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
let index = 0;
|
||||
for (const media of post.media || []) {
|
||||
const loadMedia = await fetch(media.url);
|
||||
|
||||
form.append(
|
||||
`files[${index}]`,
|
||||
await loadMedia.blob(),
|
||||
media.url.split('/').pop()
|
||||
);
|
||||
index++;
|
||||
}
|
||||
|
||||
const data = await (
|
||||
await fetch(`https://discord.com/api/channels/${channel}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
|
||||
},
|
||||
body: form,
|
||||
})
|
||||
).json();
|
||||
|
||||
finalData.push({
|
||||
id: post.id,
|
||||
releaseURL: `https://discord.com/channels/${id}/${channel}/${data.id}`,
|
||||
postId: data.id,
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
return finalData;
|
||||
}
|
||||
|
||||
async changeNickname(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
name: string,
|
||||
) {
|
||||
await (await fetch(`https://discord.com/api/guilds/${id}/members/@me`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nick: name,
|
||||
})
|
||||
})).json();
|
||||
|
||||
return {
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -174,7 +174,8 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
|
|||
let finalId = '';
|
||||
let finalUrl = '';
|
||||
if ((firstPost?.media?.[0]?.path?.indexOf('mp4') || -2) > -1) {
|
||||
const { id: videoId, permalink_url } = await (
|
||||
console.log('mp4');
|
||||
const { id: videoId, permalink_url, ...all } = await (
|
||||
await this.fetch(
|
||||
`https://graph.facebook.com/v20.0/${id}/videos?access_token=${accessToken}&fields=id,permalink_url`,
|
||||
{
|
||||
|
|
@ -183,15 +184,16 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
|
|||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_url: firstPost?.media?.[0]?.path!,
|
||||
file_url: firstPost?.media?.[0]?.url!,
|
||||
description: firstPost.message,
|
||||
published: true,
|
||||
}),
|
||||
}
|
||||
},
|
||||
'upload mp4'
|
||||
)
|
||||
).json();
|
||||
|
||||
finalUrl = permalink_url;
|
||||
finalUrl = 'https://www.facebook.com/reel/' + videoId;
|
||||
finalId = videoId;
|
||||
} else {
|
||||
const uploadPhotos = !firstPost?.media?.length
|
||||
|
|
@ -210,7 +212,8 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
|
|||
url: media.url,
|
||||
published: false,
|
||||
}),
|
||||
}
|
||||
},
|
||||
'upload images slides'
|
||||
)
|
||||
).json();
|
||||
|
||||
|
|
@ -235,7 +238,8 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
|
|||
message: firstPost.message,
|
||||
published: true,
|
||||
}),
|
||||
}
|
||||
},
|
||||
'finalize upload'
|
||||
)
|
||||
).json();
|
||||
|
||||
|
|
@ -259,7 +263,8 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
|
|||
: {}),
|
||||
message: comment.message,
|
||||
}),
|
||||
}
|
||||
},
|
||||
'add comment'
|
||||
)
|
||||
).json();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
|||
import { timer } from '@gitroom/helpers/utils/timer';
|
||||
import dayjs from 'dayjs';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import { chunk } from 'lodash';
|
||||
|
||||
export class InstagramProvider
|
||||
extends SocialAbstract
|
||||
|
|
@ -207,7 +206,7 @@ export class InstagramProvider
|
|||
const medias = await Promise.all(
|
||||
firstPost?.media?.map(async (m) => {
|
||||
const caption =
|
||||
firstPost.media?.length === 1 ? `&caption=${firstPost.message}` : ``;
|
||||
firstPost.media?.length === 1 ? `&caption=${encodeURIComponent(firstPost.message)}` : ``;
|
||||
const isCarousel =
|
||||
(firstPost?.media?.length || 0) > 1 ? `&is_carousel_item=true` : ``;
|
||||
const mediaType =
|
||||
|
|
@ -216,9 +215,10 @@ export class InstagramProvider
|
|||
? `video_url=${m.url}&media_type=REELS`
|
||||
: `video_url=${m.url}&media_type=VIDEO`
|
||||
: `image_url=${m.url}`;
|
||||
|
||||
const { id: photoId } = await (
|
||||
await this.fetch(
|
||||
`https://graph.facebook.com/v20.0/${id}/media?${mediaType}${caption}${isCarousel}&access_token=${accessToken}`,
|
||||
`https://graph.facebook.com/v20.0/${id}/media?${mediaType}${isCarousel}&access_token=${accessToken}${caption}`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
|
||||
import dayjs from 'dayjs';
|
||||
import { Integration } from '@prisma/client';
|
||||
|
||||
export class LinkedinPageProvider
|
||||
extends LinkedinProvider
|
||||
|
|
@ -206,9 +207,10 @@ export class LinkedinPageProvider
|
|||
override async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[]
|
||||
postDetails: PostDetails[],
|
||||
integration: Integration
|
||||
): Promise<PostResponse[]> {
|
||||
return super.post(id, accessToken, postDetails, 'company');
|
||||
return super.post(id, accessToken, postDetails, integration, 'company');
|
||||
}
|
||||
|
||||
async analytics(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import sharp from 'sharp';
|
|||
import { lookup } from 'mime-types';
|
||||
import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch';
|
||||
import { removeMarkdown } from '@gitroom/helpers/utils/remove.markdown';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import {
|
||||
BadBody,
|
||||
SocialAbstract,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import { Integration } from '@prisma/client';
|
||||
|
||||
export class LinkedinProvider extends SocialAbstract implements SocialProvider {
|
||||
identifier = 'linkedin';
|
||||
|
|
@ -19,7 +23,11 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
|
|||
refreshWait = true;
|
||||
|
||||
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
|
||||
const { access_token: accessToken, refresh_token: refreshToken, expires_in } = await (
|
||||
const {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
expires_in,
|
||||
} = await (
|
||||
await this.fetch('https://www.linkedin.com/oauth/v2/accessToken', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -224,21 +232,25 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
|
|||
const sendUrlRequest = uploadInstructions?.[0]?.uploadUrl || uploadUrl;
|
||||
const finalOutput = video || image;
|
||||
|
||||
const upload = await this.fetch(sendUrlRequest, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Restli-Protocol-Version': '2.0.0',
|
||||
'LinkedIn-Version': '202402',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(fileName.indexOf('mp4') > -1
|
||||
? { 'Content-Type': 'application/octet-stream' }
|
||||
: {}),
|
||||
},
|
||||
body: picture,
|
||||
});
|
||||
const etags = [];
|
||||
for (let i = 0; i < picture.length; i += 1024 * 1024 * 2) {
|
||||
const upload = await this.fetch(sendUrlRequest, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Restli-Protocol-Version': '2.0.0',
|
||||
'LinkedIn-Version': '202402',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(fileName.indexOf('mp4') > -1
|
||||
? { 'Content-Type': 'application/octet-stream' }
|
||||
: {}),
|
||||
},
|
||||
body: picture.slice(i, i + 1024 * 1024 * 2),
|
||||
});
|
||||
|
||||
etags.push(upload.headers.get('etag'));
|
||||
}
|
||||
|
||||
if (fileName.indexOf('mp4') > -1) {
|
||||
const etag = upload.headers.get('etag');
|
||||
const a = await this.fetch(
|
||||
'https://api.linkedin.com/rest/videos?action=finalizeUpload',
|
||||
{
|
||||
|
|
@ -247,7 +259,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
|
|||
finalizeUploadRequest: {
|
||||
video,
|
||||
uploadToken: '',
|
||||
uploadedPartIds: [etag],
|
||||
uploadedPartIds: etags,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
|
|
@ -262,7 +274,13 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
|
|||
|
||||
return finalOutput;
|
||||
} catch (err: any) {
|
||||
throw 'eerr';
|
||||
throw new BadBody('error-posting-to-linkedin', JSON.stringify(err), {
|
||||
// @ts-ignore
|
||||
fileName,
|
||||
personId,
|
||||
picture,
|
||||
type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +288,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
|
|||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[],
|
||||
integration: Integration,
|
||||
type = 'personal' as 'company' | 'personal'
|
||||
): Promise<PostResponse[]> {
|
||||
const [firstPost, ...restPosts] = postDetails;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
ClientInformation,
|
||||
PostDetails,
|
||||
PostResponse,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { MastodonProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.provider';
|
||||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
|
||||
export class MastodonCustomProvider extends MastodonProvider {
|
||||
override identifier = 'mastodon-custom';
|
||||
override name = 'M. Instance';
|
||||
async externalUrl(url: string) {
|
||||
const form = new FormData();
|
||||
form.append('client_name', 'Postiz');
|
||||
form.append(
|
||||
'redirect_uris',
|
||||
`${process.env.FRONTEND_URL}/integrations/social/mastodon`
|
||||
);
|
||||
form.append('scopes', this.scopes.join(' '));
|
||||
form.append('website', process.env.FRONTEND_URL!);
|
||||
const { client_id, client_secret, ...all } = await (
|
||||
await fetch(url + '/api/v1/apps', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
client_id,
|
||||
client_secret,
|
||||
};
|
||||
}
|
||||
override async generateAuthUrl(
|
||||
refresh?: string,
|
||||
external?: ClientInformation
|
||||
) {
|
||||
const state = makeId(6);
|
||||
const url = this.generateUrlDynamic(
|
||||
external?.instanceUrl!,
|
||||
state,
|
||||
external?.client_id!,
|
||||
process.env.FRONTEND_URL!,
|
||||
refresh
|
||||
);
|
||||
|
||||
return {
|
||||
url,
|
||||
codeVerifier: makeId(10),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
override async authenticate(
|
||||
params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
refresh?: string;
|
||||
},
|
||||
clientInformation?: ClientInformation
|
||||
) {
|
||||
return this.dynamicAuthenticate(
|
||||
clientInformation?.client_id!,
|
||||
clientInformation?.client_secret!,
|
||||
clientInformation?.instanceUrl!,
|
||||
params.code
|
||||
);
|
||||
}
|
||||
|
||||
override async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[]
|
||||
): Promise<PostResponse[]> {
|
||||
return this.dynamicPost(
|
||||
id,
|
||||
accessToken,
|
||||
'https://mastodon.social',
|
||||
postDetails
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import {
|
||||
AuthTokenDetails,
|
||||
PostDetails,
|
||||
PostResponse,
|
||||
SocialProvider,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export class MastodonProvider extends SocialAbstract implements SocialProvider {
|
||||
identifier = 'mastodon';
|
||||
name = 'Mastodon';
|
||||
isBetweenSteps = false;
|
||||
scopes = ['write:statuses', 'profile', 'write:media'];
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
|
||||
return {
|
||||
refreshToken: '',
|
||||
expiresIn: 0,
|
||||
accessToken: '',
|
||||
id: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
username: '',
|
||||
};
|
||||
}
|
||||
protected generateUrlDynamic(
|
||||
customUrl: string,
|
||||
state: string,
|
||||
clientId: string,
|
||||
url: string,
|
||||
refresh?: string
|
||||
) {
|
||||
return `${customUrl}/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(
|
||||
`${url}/integrations/social/mastodon${
|
||||
refresh ? `?refresh=${refresh}` : ''
|
||||
}`
|
||||
)}&scope=${this.scopes.join('+')}&state=${state}`;
|
||||
}
|
||||
|
||||
async generateAuthUrl(refresh?: string) {
|
||||
const state = makeId(6);
|
||||
const url = this.generateUrlDynamic(
|
||||
'https://mastodon.social',
|
||||
state,
|
||||
process.env.MASTODON_CLIENT_ID!,
|
||||
process.env.FRONTEND_URL!,
|
||||
refresh
|
||||
);
|
||||
return {
|
||||
url,
|
||||
codeVerifier: makeId(10),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
protected async dynamicAuthenticate(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
url: string,
|
||||
code: string
|
||||
) {
|
||||
const form = new FormData();
|
||||
form.append('client_id', clientId);
|
||||
form.append('client_secret', clientSecret);
|
||||
form.append('code', code);
|
||||
form.append('grant_type', 'authorization_code');
|
||||
form.append(
|
||||
'redirect_uri',
|
||||
`${process.env.FRONTEND_URL}/integrations/social/mastodon`
|
||||
);
|
||||
form.append('scope', this.scopes.join(' '));
|
||||
|
||||
const tokenInformation = await (
|
||||
await this.fetch(`${url}/oauth/token`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
).json();
|
||||
|
||||
const personalInformation = await (
|
||||
await this.fetch(`${url}/api/v1/accounts/verify_credentials`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenInformation.access_token}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
id: personalInformation.id,
|
||||
name: personalInformation.display_name || personalInformation.acct,
|
||||
accessToken: tokenInformation.access_token,
|
||||
refreshToken: 'null',
|
||||
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
|
||||
picture: personalInformation.avatar,
|
||||
username: personalInformation.username,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(
|
||||
params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
refresh?: string;
|
||||
}
|
||||
) {
|
||||
return this.dynamicAuthenticate(
|
||||
process.env.MASTODON_CLIENT_ID!,
|
||||
process.env.MASTODON_CLIENT_SECRET!,
|
||||
'https://mastodon.social',
|
||||
params.code
|
||||
);
|
||||
}
|
||||
|
||||
async uploadFile(instanceUrl: string, fileUrl: string, accessToken: string) {
|
||||
const form = new FormData();
|
||||
form.append('file', await fetch(fileUrl).then((r) => r.blob()));
|
||||
const media = await (
|
||||
await this.fetch(`${instanceUrl}/api/v1/media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: form,
|
||||
})
|
||||
).json();
|
||||
return media.id;
|
||||
}
|
||||
|
||||
async dynamicPost(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
url: string,
|
||||
postDetails: PostDetails[]
|
||||
): Promise<PostResponse[]> {
|
||||
let loadId = '';
|
||||
const ids = [] as string[];
|
||||
for (const getPost of postDetails) {
|
||||
const uploadFiles = await Promise.all(
|
||||
getPost?.media?.map((media) =>
|
||||
this.uploadFile(url, media.url, accessToken)
|
||||
) || []
|
||||
);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('status', getPost.message);
|
||||
form.append('visibility', 'public');
|
||||
if (loadId) {
|
||||
form.append('in_reply_to_id', loadId);
|
||||
}
|
||||
if (uploadFiles.length) {
|
||||
for (const file of uploadFiles) {
|
||||
form.append('media_ids[]', file);
|
||||
}
|
||||
}
|
||||
|
||||
const post = await (
|
||||
await this.fetch(`${url}/api/v1/statuses`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: form,
|
||||
})
|
||||
).json();
|
||||
|
||||
ids.push(post.id);
|
||||
loadId = loadId || post.id;
|
||||
}
|
||||
|
||||
return postDetails.map((p, i) => ({
|
||||
id: p.id,
|
||||
postId: ids[i],
|
||||
releaseURL: `${url}/statuses/${ids[i]}`,
|
||||
status: 'completed',
|
||||
}));
|
||||
}
|
||||
|
||||
async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[]
|
||||
): Promise<PostResponse[]> {
|
||||
return this.dynamicPost(
|
||||
id,
|
||||
accessToken,
|
||||
'https://mastodon.social',
|
||||
postDetails
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import {
|
||||
AuthTokenDetails,
|
||||
PostDetails,
|
||||
PostResponse,
|
||||
SocialProvider,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import dayjs from 'dayjs';
|
||||
import { Integration } from '@prisma/client';
|
||||
|
||||
export class SlackProvider extends SocialAbstract implements SocialProvider {
|
||||
identifier = 'slack';
|
||||
name = 'Slack';
|
||||
isBetweenSteps = false;
|
||||
scopes = [
|
||||
'channels:read',
|
||||
'chat:write',
|
||||
'users:read',
|
||||
'groups:read',
|
||||
'channels:join',
|
||||
'chat:write.customize',
|
||||
];
|
||||
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
|
||||
return {
|
||||
refreshToken: '',
|
||||
expiresIn: 1000000,
|
||||
accessToken: '',
|
||||
id: '',
|
||||
name: '',
|
||||
picture: '',
|
||||
username: '',
|
||||
};
|
||||
}
|
||||
async generateAuthUrl(refresh?: string) {
|
||||
const state = makeId(6);
|
||||
|
||||
return {
|
||||
url: `https://slack.com/oauth/v2/authorize?client_id=${
|
||||
process.env.SLACK_ID
|
||||
}&redirect_uri=${encodeURIComponent(
|
||||
`${
|
||||
process?.env?.FRONTEND_URL?.indexOf('https') === -1
|
||||
? 'https://redirectmeto.com/'
|
||||
: ''
|
||||
}${process?.env?.FRONTEND_URL}/integrations/social/slack${
|
||||
refresh ? `?refresh=${refresh}` : ''
|
||||
}`
|
||||
)}&scope=channels:read,chat:write,users:read,groups:read,channels:join,chat:write.customize&state=${state}`,
|
||||
codeVerifier: makeId(10),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
refresh?: string;
|
||||
}) {
|
||||
const { access_token, team, bot_user_id, scope } = await (
|
||||
await this.fetch(`https://slack.com/api/oauth.v2.access`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.SLACK_ID!,
|
||||
client_secret: process.env.SLACK_SECRET!,
|
||||
code: params.code,
|
||||
redirect_uri: `${
|
||||
process?.env?.FRONTEND_URL?.indexOf('https') === -1
|
||||
? 'https://redirectmeto.com/'
|
||||
: ''
|
||||
}${process?.env?.FRONTEND_URL}/integrations/social/slack${
|
||||
params.refresh ? `?refresh=${params.refresh}` : ''
|
||||
}`,
|
||||
}),
|
||||
})
|
||||
).json();
|
||||
|
||||
this.checkScopes(this.scopes, scope.split(','));
|
||||
|
||||
const { user } = await (
|
||||
await fetch(`https://slack.com/api/users.info?user=${bot_user_id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
return {
|
||||
id: team.id,
|
||||
name: user.real_name,
|
||||
accessToken: access_token,
|
||||
refreshToken: 'null',
|
||||
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
|
||||
picture: user.profile.image_original,
|
||||
username: user.name,
|
||||
};
|
||||
}
|
||||
|
||||
async channels(accessToken: string, params: any, id: string) {
|
||||
const list = await (
|
||||
await fetch(
|
||||
`https://slack.com/api/conversations.list?types=public_channel,private_channel`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
).json();
|
||||
|
||||
return list.channels.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[],
|
||||
integration: Integration
|
||||
): Promise<PostResponse[]> {
|
||||
await fetch(`https://slack.com/api/conversations.join`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: postDetails[0].settings.channel,
|
||||
}),
|
||||
});
|
||||
|
||||
let lastId = '';
|
||||
for (const post of postDetails) {
|
||||
const { ts } = await (
|
||||
await fetch(`https://slack.com/api/chat.postMessage`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: postDetails[0].settings.channel,
|
||||
username: integration.name,
|
||||
icon_url: integration.picture,
|
||||
...(lastId ? { thread_ts: lastId } : {}),
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: post.message,
|
||||
},
|
||||
},
|
||||
...(post.media?.length
|
||||
? post.media.map((m) => ({
|
||||
type: 'image',
|
||||
image_url: m.url,
|
||||
alt_text: '',
|
||||
}))
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
})
|
||||
).json();
|
||||
|
||||
lastId = ts;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async changeProfilePicture(id: string, accessToken: string, url: string) {
|
||||
return {
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async changeNickname(id: string, accessToken: string, name: string) {
|
||||
return {
|
||||
name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,39 @@
|
|||
import { Integration } from '@prisma/client';
|
||||
|
||||
export interface ClientInformation {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
instanceUrl: string;
|
||||
}
|
||||
export interface IAuthenticator {
|
||||
authenticate(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
refresh?: string;
|
||||
}): Promise<AuthTokenDetails>;
|
||||
authenticate(
|
||||
params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
refresh?: string;
|
||||
},
|
||||
clientInformation?: ClientInformation
|
||||
): Promise<AuthTokenDetails>;
|
||||
refreshToken(refreshToken: string): Promise<AuthTokenDetails>;
|
||||
generateAuthUrl(refresh?: string): Promise<GenerateAuthUrlResponse>;
|
||||
analytics?(id: string, accessToken: string, date: number): Promise<AnalyticsData[]>;
|
||||
generateAuthUrl(
|
||||
refresh?: string,
|
||||
clientInformation?: ClientInformation
|
||||
): Promise<GenerateAuthUrlResponse>;
|
||||
analytics?(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]>;
|
||||
changeNickname?(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
name: string
|
||||
): Promise<{ name: string }>;
|
||||
changeProfilePicture?(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
url: string
|
||||
): Promise<{ url: string }>;
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
|
|
@ -35,7 +62,8 @@ export interface ISocialMediaIntegration {
|
|||
post(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
postDetails: PostDetails[]
|
||||
postDetails: PostDetails[],
|
||||
integration: Integration
|
||||
): Promise<PostResponse[]>; // Schedules a new post
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +98,19 @@ export interface SocialProvider
|
|||
ISocialMediaIntegration {
|
||||
identifier: string;
|
||||
refreshWait?: boolean;
|
||||
customFields?: () => Promise<
|
||||
{
|
||||
key: string;
|
||||
label: string;
|
||||
defaultValue?: string;
|
||||
validation: string;
|
||||
type: 'text' | 'password';
|
||||
}[]
|
||||
>;
|
||||
name: string;
|
||||
isBetweenSteps: boolean;
|
||||
scopes: string[];
|
||||
externalUrl?: (
|
||||
url: string
|
||||
) => Promise<{ client_id: string; client_secret: string }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
|
|||
'https://threads.net/oauth/authorize' +
|
||||
`?client_id=${process.env.THREADS_APP_ID}` +
|
||||
`&redirect_uri=${encodeURIComponent(
|
||||
process.env.NODE_ENV === 'development' || !process.env.NODE_ENV
|
||||
? `https://integration.git.sn/integrations/social/threads`
|
||||
: `${process.env.FRONTEND_URL}/integrations/social/threads${
|
||||
refresh ? `?refresh=${refresh}` : ''
|
||||
}`
|
||||
`${
|
||||
process?.env.FRONTEND_URL?.indexOf('https') == -1
|
||||
? `https://redirectmeto.com/${process?.env.FRONTEND_URL}`
|
||||
: `${process?.env.FRONTEND_URL}`
|
||||
}/integrations/social/threads`
|
||||
)}` +
|
||||
`&state=${state}` +
|
||||
`&scope=${encodeURIComponent(this.scopes.join(','))}`,
|
||||
|
|
@ -64,11 +64,11 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
|
|||
'https://graph.threads.net/oauth/access_token' +
|
||||
`?client_id=${process.env.THREADS_APP_ID}` +
|
||||
`&redirect_uri=${encodeURIComponent(
|
||||
process.env.NODE_ENV === 'development' || !process.env.NODE_ENV
|
||||
? `https://integration.git.sn/integrations/social/threads`
|
||||
: `${process.env.FRONTEND_URL}/integrations/social/threads${
|
||||
params.refresh ? `?refresh=${params.refresh}` : ''
|
||||
}`
|
||||
`${
|
||||
process?.env.FRONTEND_URL?.indexOf('https') == -1
|
||||
? `https://redirectmeto.com/${process?.env.FRONTEND_URL}`
|
||||
: `${process?.env.FRONTEND_URL}`
|
||||
}/integrations/social/threads`
|
||||
)}` +
|
||||
`&grant_type=authorization_code` +
|
||||
`&client_secret=${process.env.THREADS_APP_SECRET}` +
|
||||
|
|
@ -104,8 +104,15 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
|
|||
};
|
||||
}
|
||||
|
||||
private async checkLoaded(mediaContainerId: string, accessToken: string): Promise<boolean> {
|
||||
const {status, id, error_message} = await (await this.fetch(`https://graph.threads.net/v1.0/${mediaContainerId}?fields=status,error_message&access_token=${accessToken}`)).json();
|
||||
private async checkLoaded(
|
||||
mediaContainerId: string,
|
||||
accessToken: string
|
||||
): Promise<boolean> {
|
||||
const { status, id, error_message } = await (
|
||||
await this.fetch(
|
||||
`https://graph.threads.net/v1.0/${mediaContainerId}?fields=status,error_message&access_token=${accessToken}`
|
||||
)
|
||||
).json();
|
||||
console.log(status, error_message);
|
||||
if (status === 'ERROR') {
|
||||
throw new Error(id);
|
||||
|
|
@ -205,12 +212,8 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
|
|||
mediaLoad.path.indexOf('.mp4') > -1 ? 'video_url' : 'image_url';
|
||||
|
||||
const media = new URLSearchParams({
|
||||
...(type === 'video_url'
|
||||
? { video_url: mediaLoad.path }
|
||||
: {}),
|
||||
...(type === 'image_url'
|
||||
? { image_url: mediaLoad.path }
|
||||
: {}),
|
||||
...(type === 'video_url' ? { video_url: mediaLoad.path } : {}),
|
||||
...(type === 'image_url' ? { image_url: mediaLoad.path } : {}),
|
||||
is_carousel_item: 'true',
|
||||
media_type:
|
||||
type === 'video_url'
|
||||
|
|
@ -234,7 +237,9 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
|
|||
medias.push(mediaId);
|
||||
}
|
||||
|
||||
await Promise.all(medias.map((p: string) => this.checkLoaded(p, accessToken)));
|
||||
await Promise.all(
|
||||
medias.map((p: string) => this.checkLoaded(p, accessToken))
|
||||
);
|
||||
|
||||
const { id: containerId } = await (
|
||||
await this.fetch(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import {
|
|||
SocialProvider,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import dayjs from 'dayjs';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import {
|
||||
BadBody,
|
||||
SocialAbstract,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import { TikTokDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tiktok.dto';
|
||||
|
||||
export class TiktokProvider extends SocialAbstract implements SocialProvider {
|
||||
|
|
@ -183,7 +186,10 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
|
|||
},
|
||||
];
|
||||
} catch (err) {
|
||||
return [];
|
||||
throw new BadBody('titok-error', JSON.stringify(err), {
|
||||
// @ts-ignore
|
||||
postDetails
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,52 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY || 're_132');
|
||||
import { EmailInterface } from '@gitroom/nestjs-libraries/emails/email.interface';
|
||||
import { ResendProvider } from '@gitroom/nestjs-libraries/emails/resend.provider';
|
||||
import { EmptyProvider } from '@gitroom/nestjs-libraries/emails/empty.provider';
|
||||
import { NodeMailerProvider } from '@gitroom/nestjs-libraries/emails/node.mailer.provider';
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
emailService: EmailInterface;
|
||||
constructor() {
|
||||
this.emailService = this.selectProvider(process.env.EMAIL_PROVIDER!);
|
||||
console.log('Email service provider:', this.emailService.name);
|
||||
for (const key of this.emailService.validateEnvKeys) {
|
||||
if (!process.env[key]) {
|
||||
console.error(`Missing environment variable: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasProvider() {
|
||||
return !(this.emailService instanceof EmptyProvider);
|
||||
}
|
||||
|
||||
selectProvider(provider: string) {
|
||||
switch (provider) {
|
||||
case 'resend':
|
||||
return new ResendProvider();
|
||||
case 'nodemailer':
|
||||
return new NodeMailerProvider();
|
||||
default:
|
||||
return new EmptyProvider();
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(to: string, subject: string, html: string) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.log('No Resend API Key found, skipping email sending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.EMAIL_FROM_ADDRESS || !process.env.EMAIL_FROM_NAME) {
|
||||
console.log('Email sender information not found in environment variables');
|
||||
console.log(
|
||||
'Email sender information not found in environment variables'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Sending email to', to);
|
||||
const sends = await resend.emails.send({
|
||||
from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM_ADDRESS}>`,
|
||||
const sends = await this.emailService.sendEmail(
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
|
||||
process.env.EMAIL_FROM_NAME,
|
||||
process.env.EMAIL_FROM_ADDRESS
|
||||
);
|
||||
console.log(sends);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,22 @@ export const Checkbox = forwardRef<null, {
|
|||
)}
|
||||
>
|
||||
{currentStatus && (
|
||||
<Image src="/form/checked.svg" alt="Checked" width={20} height={20} />
|
||||
)}
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface ImageSrc {
|
||||
|
|
@ -12,6 +12,11 @@ interface ImageSrc {
|
|||
const ImageWithFallback: FC<ImageSrc> = (props) => {
|
||||
const { src, fallbackSrc, ...rest } = props;
|
||||
const [imgSrc, setImgSrc] = useState(src);
|
||||
useEffect(() => {
|
||||
if (src !== imgSrc) {
|
||||
setImgSrc(src);
|
||||
}
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<Image
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export const Toaster = () => {
|
|||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">{toasterText}</div>
|
||||
<div className="flex-1 text-textColor">{toasterText}</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="60"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -30,6 +30,7 @@
|
|||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.13.11",
|
||||
"@aws-sdk/client-s3": "^3.410.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||
"@casl/ability": "^6.5.0",
|
||||
|
|
@ -70,6 +71,7 @@
|
|||
"@types/md5": "^2.3.5",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/remove-markdown": "^0.3.4",
|
||||
"@types/sha256": "^0.2.2",
|
||||
"@types/stripe": "^8.0.417",
|
||||
|
|
@ -112,6 +114,7 @@
|
|||
"nestjs-command": "^3.1.4",
|
||||
"next": "14.2.3",
|
||||
"next-plausible": "^3.12.0",
|
||||
"nodemailer": "^6.9.15",
|
||||
"nx": "19.7.2",
|
||||
"openai": "^4.47.1",
|
||||
"polotno": "^2.10.5",
|
||||
|
|
@ -138,6 +141,7 @@
|
|||
"swr": "^2.2.5",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss": "3.4.3",
|
||||
"tldts": "^6.1.47",
|
||||
"tslib": "^2.3.0",
|
||||
"twitter-api-v2": "^1.16.0",
|
||||
"use-debounce": "^10.0.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue