Compare commits

...

93 Commits
v1.2 ... main

Author SHA1 Message Date
Félix MARQUET
8550fb045c Merge pull request #22 from BreizhHardware/dev
Update dependencies
2025-06-16 10:39:33 +02:00
Félix MARQUET
bf35608f71 Update .github/workflows/dependabot-build.yml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-16 10:32:22 +02:00
Félix MARQUET
b842d104c7 Merge pull request #20 from BreizhHardware/dependabot/cargo/dev/reqwest-0.12.20
build(deps): bump reqwest from 0.11.27 to 0.12.20
2025-06-16 10:08:33 +02:00
dependabot[bot]
b22351e77e build(deps): bump reqwest from 0.11.27 to 0.12.20
Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.11.27 to 0.12.20.
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.11.27...v0.12.20)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.12.20
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-16 08:04:33 +00:00
Félix MARQUET
10d8a23897 Merge pull request #21 from BreizhHardware/dependabot/cargo/dev/rusqlite-0.36.0
build(deps): bump rusqlite from 0.29.0 to 0.36.0
2025-06-16 10:03:22 +02:00
Félix MARQUET
4e54b557b0 Merge pull request #19 from BreizhHardware/dependabot/cargo/dev/env_logger-0.11.8
build(deps): bump env_logger from 0.10.2 to 0.11.8
2025-06-16 10:01:33 +02:00
dependabot[bot]
a92caf5e37 build(deps): bump rusqlite from 0.29.0 to 0.36.0
Bumps [rusqlite](https://github.com/rusqlite/rusqlite) from 0.29.0 to 0.36.0.
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.29.0...v0.36.0)

---
updated-dependencies:
- dependency-name: rusqlite
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-16 07:59:11 +00:00
dependabot[bot]
f2a6d4f0de build(deps): bump env_logger from 0.10.2 to 0.11.8
Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.10.2 to 0.11.8.
- [Release notes](https://github.com/rust-cli/env_logger/releases)
- [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/env_logger/compare/v0.10.2...v0.11.8)

---
updated-dependencies:
- dependency-name: env_logger
  dependency-version: 0.11.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-16 07:56:05 +00:00
Félix MARQUET
08bf34104a update(create_dev): add condition to skip jobs for Dependabot pull requests 2025-06-16 09:54:24 +02:00
Félix MARQUET
ff3e00eb4e add(dependabot): create build check workflow for Dependabot pull requests 2025-06-16 09:51:13 +02:00
Félix MARQUET
802081937f Merge pull request #18 from BreizhHardware/dev
Rust rewrite
2025-06-16 09:33:18 +02:00
Félix MARQUET
f52f505e38 update(README): simplify Docker image description and remove unused DB_PATH entry 2025-06-16 09:28:45 +02:00
Félix MARQUET
82f5f59413 update(create_release): modify workflow name and add dev tag for Docker image 2025-06-16 09:18:06 +02:00
Félix MARQUET
0a5945e7b3 Update src/database.rs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-15 17:53:47 +02:00
Félix MARQUET
e4d2bc303f Update src/config.rs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-15 17:51:39 +02:00
34729a7edd fix(api): update default database path to '/github-ntfy' 2025-06-15 17:43:36 +02:00
Félix MARQUET
21b51766bb Update src/api.rs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-15 17:43:00 +02:00
Félix MARQUET
6a0031ac5d Update src/database.rs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-15 17:42:47 +02:00
Félix MARQUET
79e48391cb refactor(database): add version tracking functions and update notification logic 2025-06-14 13:58:36 +02:00
Félix MARQUET
43275d1fd9 refactor(github): enhance error handling and add User-Agent header in get_latest_releases function 2025-06-14 11:45:13 +02:00
Félix MARQUET
fe33377fa0 refactor(ci): simplify CI configuration by consolidating binary build steps and updating Dockerfile 2025-06-13 13:55:10 +02:00
Félix MARQUET
60db3550c0 refactor(docker): update Dockerfile for architecture-specific binary handling and add OpenSSL dependency 2025-06-13 08:50:42 +02:00
2b9eb94337 refactor(ci): remove dynamic version tag from Docker image in create_dev.yml 2025-06-12 21:54:33 +02:00
acbd6ccc00 refactor(ci): update Docker image tags to use breizhhardware namespace 2025-06-12 21:44:39 +02:00
8c97043b2f refactor(rust): remove support for armv7 architecture in CI configuration 2025-06-12 21:39:19 +02:00
38918f0bb8 refactor(rust): simplify CI dependencies by removing unnecessary job requirement 2025-06-12 21:33:46 +02:00
622e3d4334 refactor(rust): update CI configuration for multi-architecture Docker builds 2025-06-12 21:32:20 +02:00
Félix MARQUET
b28f70b659 refactor(rust): add support for vendored OpenSSL in CI configuration 2025-06-12 20:23:06 +02:00
Félix MARQUET
5caa2b56ce refactor(rust): update CI configuration to support static OpenSSL with cross 2025-06-12 20:18:30 +02:00
Félix MARQUET
4ffa83efb4 Merge pull request #17 from BreizhHardware/refactor/rust-implementation
refactor(rust): upgrade GitHub Actions to use version 4 of upload and…
2025-06-12 20:10:13 +02:00
Félix MARQUET
39f0d6aa8b refactor(rust): upgrade GitHub Actions to use version 4 of upload and download artifacts 2025-06-12 20:09:10 +02:00
Félix MARQUET
856811a446 Merge pull request #16 from BreizhHardware/refactor/rust-implementation
Refactor/rust implementation
2025-06-12 20:06:36 +02:00
Félix MARQUET
57ea0ef54b refactor(rust): update notification functions and improve database handling 2025-06-12 20:05:44 +02:00
Félix MARQUET
cc39b743e6 refactor(rust): restructure project and update configuration management 2025-06-12 19:55:09 +02:00
Félix MARQUET
426403ad92 refactor(rust): Rewrite everything in rust 2025-06-12 19:41:10 +02:00
Félix MARQUET
d2ba0e510a refactor(rust): Rewrite everything in rust 2025-06-12 19:40:54 +02:00
Félix MARQUET
de60020b01 Merge pull request #13 from BreizhHardware/dev
fix(dependabot): ensure updates target the dev branch
2025-06-10 11:47:55 +02:00
Félix MARQUET
1430d39b5c fix(dependabot): ensure updates target the dev branch 2025-06-10 11:46:40 +02:00
Félix MARQUET
47fa8f820e Merge pull request #10 from BreizhHardware/dev
Create dependabot.yaml
2025-06-10 11:31:06 +02:00
Félix MARQUET
56439d8c62 Update dependabot.yaml 2025-06-10 11:30:56 +02:00
Félix MARQUET
013c5bd70d Create dependabot.yaml 2025-06-10 11:29:08 +02:00
Félix MARQUET
c81fc26881 fix(dockerfile): add send_slack.py to the dockerfile 2025-05-05 09:08:18 +00:00
Félix MARQUET
e7b89930f1 Merge pull request #7 from Mahmoud0x00/slack-notification-add
feat: add Slack notification
2025-05-05 08:36:45 +02:00
Mahmoud Osama
246b727d0a Update notification messages format
Update notification messages format to use Emojis with:
* Discord
* Gotify
* Ntfy
2025-05-04 19:59:39 +03:00
Mahmoud Osama
83cfd9a2f1 Update README.md with SLACK_WEBHOOK_URL 2025-05-04 14:13:43 +03:00
Mahmoud Osama
8795add7f0 Add SLACK_WEBHOOK_URL to Dockerfile 2025-05-03 20:52:08 +03:00
Mahmoud Osama
3d33cb8282 Add Slack notifcation
 Support Slack incoming webhook notifications with good looking block message design
2025-05-03 20:17:20 +03:00
Félix MARQUET
aa2f654d4b Merge pull request #6 from BreizhHardware/dev
feat: Refactor web UI
2024-12-23 20:17:52 +01:00
e1b16ac645 feat: Refactor web UI
- Refactor of the webUI
2024-12-23 20:12:11 +01:00
Félix MARQUET
7a221a9ab9 Merge pull request #5 from BreizhHardware/dev
feat: merge dev into main

    Created the connection with Discord.
    Added CONTRIBUTION.md.
    Reworked GitHub CI.
    Fixed threading issues.
2024-12-23 20:00:10 +01:00
a3e892c8f0 fix: use separate database connections for each function
- Refactored `send_discord.py` to better work with webhook (but remove the embed)
- Updated Dockerfile to adjust package installation order.
2024-12-23 19:56:57 +01:00
e8eb8d18d2 fix: use separate database connections for each function
- Refactored `send_gotify.py`, `send_ntfy.py`, and `send_discord.py` to use separate database connections for each function.
- Added `get_db_connection` function to create a new database connection.
- Updated `github_send_to_gotify`, `docker_send_to_gotify`, `github_send_to_ntfy`, `docker_send_to_ntfy`, `github_send_to_discord`, and `docker_send_to_discord` functions to use the new `get_db_connection` function.
- Modified `ntfy.py` to use threading for calling notification functions simultaneously.
2024-12-23 19:33:49 +01:00
Félix MARQUET
66759932f0 chore(ci): remove GitHub release build to use Docker only
Edit CI configuration to remove GitHub release build. Now, builds are only for Docker, and I will use the Conventional Release Bot app to manage releases.
2024-12-23 15:47:49 +00:00
Félix MARQUET
dc831c958f docs: update README.md with dependancies installation
- Add instruction for installing the dependencies
2024-12-23 12:41:57 +00:00
Félix MARQUET
edff2e3806 docs: add CONTRIBUTION.md and update README.md with contribution guidelines
- Added CONTRIBUTION.md to provide guidelines for contributing to the project.
- Updated README.md to include a link to CONTRIBUTION.md for easy access to contribution guidelines.
2024-12-23 12:29:18 +00:00
Félix MARQUET
91cc7bc9bf First itération for discord 2024-12-23 12:20:13 +00:00
fc577ea17f Fix error 2024-11-21 17:20:57 +01:00
ae95654ec3 Fix error 2024-11-21 17:19:52 +01:00
Félix MARQUET
7c0e34c08c Merge pull request #4 from BreizhHardware/dev
Fix DOCKERFILE error
2024-10-25 08:39:33 +02:00
Félix MARQUET
921f40e98e Fix DOCKERFILE error 2024-10-25 06:39:03 +00:00
Félix MARQUET
dcf9edba97 Merge pull request #3 from BreizhHardware/dev
New Readme.md
2024-10-24 15:54:55 +02:00
Félix MARQUET
5f2e86d86a New Readme.md 2024-10-24 13:54:27 +00:00
Félix MARQUET
66e22f6788 Merge pull request #2 from BreizhHardware/dev
Add Gotify support and arm64 and armv7 support
2024-10-24 15:32:08 +02:00
Félix MARQUET
71cf7baa32 Add arm64 and armv7 compatibility 2024-10-24 13:26:18 +00:00
Félix MARQUET
8d26c2821c Fix typo and add error message if neither ntfy_url or gotify_url is set 2024-10-24 13:10:59 +00:00
Félix MARQUET
3e59106fa6 Start of the gotify implementation 2024-10-24 13:10:10 +00:00
Félix MARQUET
d6c0e4e08e Update README.md 2024-10-23 13:03:14 +02:00
Félix MARQUET
4bfc6e254a Update README.md 2024-10-23 12:59:41 +02:00
e863be9dc0 Fix CI 2024-10-22 10:15:57 +02:00
e4f2ca9e49 Fix CI 2024-10-22 10:14:49 +02:00
996aad9c5e Fix CI 2024-10-22 10:13:38 +02:00
b958689318 Fix CI 2024-10-22 10:11:30 +02:00
8800902bf1 Fix CI 2024-10-22 10:10:55 +02:00
8f50debb0a Fix CI 2024-10-22 10:10:12 +02:00
c55b3f871e Fix CI 2024-10-22 10:08:29 +02:00
63594b910f Fix CI 2024-10-22 10:03:46 +02:00
6297ce14fd Update changelog 2024-10-22 10:01:51 +02:00
Félix MARQUET
7a48c3da50 Merge pull request #1 from BreizhHardware/dev
V1.4 release
2024-10-22 09:50:26 +02:00
7c2b4e545c Update changelog 2024-10-22 09:49:37 +02:00
d218c7a0bc Add import for json 2024-10-22 09:46:01 +02:00
694bfcaf6b Add docker login possibility 2024-10-22 09:38:53 +02:00
Félix MARQUET
b11bc64e52 Fix 2024-10-21 15:00:13 +00:00
Félix MARQUET
d796d5b24f Remove migration from entrypoint 2024-10-21 16:44:11 +02:00
Félix MARQUET
0be8d008c5 Fix entrypoint persmisson 2024-10-21 16:41:25 +02:00
Félix MARQUET
a14cc1848f Edit CI 2024-10-21 16:26:46 +02:00
Félix MARQUET
350ad9bf6a Update NGINX config with the new route 2024-10-21 16:17:13 +02:00
Félix MARQUET
76de8af42b Change the temp changelog 2024-10-21 16:15:52 +02:00
Félix MARQUET
3cfa54248f Add docker-hub compatibility 2024-10-21 16:15:01 +02:00
a270978728 Add remove repos button and change background color 2024-06-27 14:22:15 +02:00
2a7305a4cf Emergency PUSH 2024-03-05 13:00:23 +01:00
f5fc6e38da Add date and time of the release (use full when adding a new repo) 2024-03-05 12:51:15 +01:00
4a57e9e2e1 Comment everything from french to english 2024-03-04 13:18:13 +01:00
1ef3dfa49d Miss name variable that break all program 2024-03-04 13:08:00 +01:00
34 changed files with 4541 additions and 358 deletions

12
.github/dependabot.yaml vendored Normal file
View File

@@ -0,0 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "cargo" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
target-branch: "dev"

89
.github/workflows/create_dev.yml vendored Normal file
View File

@@ -0,0 +1,89 @@
name: Build et Push Docker Dev Image
on:
push:
branches:
- dev
jobs:
build-binary:
if: ${{ github.actor != 'dependabot[bot]' && !startsWith(github.ref, 'refs/heads/dependabot/') }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Installer Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: x86_64-unknown-linux-musl
override: true
- name: Installer cross
run: cargo install cross
- name: Créer Cross.toml pour spécifier OpenSSL vendored
run: |
cat > Cross.toml << 'EOF'
[build.env]
passthrough = [
"RUSTFLAGS",
"OPENSSL_STATIC",
"OPENSSL_NO_VENDOR"
]
EOF
- name: Construire avec cross et OpenSSL vendored
env:
OPENSSL_STATIC: 1
RUSTFLAGS: "-C target-feature=+crt-static"
OPENSSL_NO_VENDOR: 0
run: |
cross build --release --target x86_64-unknown-linux-musl --features vendored-openssl
- name: Préparer le binaire
run: |
mkdir -p release
cp target/x86_64-unknown-linux-musl/release/github-ntfy release/github-ntfy
- name: Upload binaire comme artifact
uses: actions/upload-artifact@v4
with:
name: github-ntfy
path: release/github-ntfy
docker-build-push:
if: ${{ github.actor != 'dependabot[bot]' && !startsWith(github.ref, 'refs/heads/dependabot/') }}
needs: [build-binary]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configurer Docker
uses: docker/setup-buildx-action@v3
- name: Login Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Télécharger le binaire
uses: actions/download-artifact@v4
with:
name: github-ntfy
path: binaries
- name: Préparer le binaire pour Docker
run: |
chmod +x binaries/github-ntfy
- name: Construire et pousser l'image Docker
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: breizhhardware/github-ntfy:dev
file: Dockerfile

150
.github/workflows/create_release.yml vendored Normal file
View File

@@ -0,0 +1,150 @@
name: Build et Release
on:
push:
branches:
- main
jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Calculer la prochaine version
id: version
run: |
# Récupérer la dernière version ou utiliser v0.1.0 si aucune n'existe
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.1.0")
echo "Dernière version: $LATEST_TAG"
# Extraire les composants de version
VERSION=${LATEST_TAG#v}
MAJOR=$(echo $VERSION | cut -d. -f1)
MINOR=$(echo $VERSION | cut -d. -f2)
PATCH=$(echo $VERSION | cut -d. -f3)
# Incrémenter le patch
PATCH=$((PATCH + 1))
# Nouvelle version
NEW_VERSION="v$MAJOR.$MINOR.$PATCH"
echo "Nouvelle version: $NEW_VERSION"
echo "tag=$NEW_VERSION" >> $GITHUB_OUTPUT
build-binaries:
needs: version
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Installer Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: x86_64-unknown-linux-musl
override: true
- name: Installer cross
run: cargo install cross
- name: Créer Cross.toml pour spécifier OpenSSL vendored
run: |
cat > Cross.toml << 'EOF'
[build.env]
passthrough = [
"RUSTFLAGS",
"OPENSSL_STATIC",
"OPENSSL_NO_VENDOR"
]
EOF
- name: Construire avec cross et OpenSSL vendored
env:
OPENSSL_STATIC: 1
RUSTFLAGS: "-C target-feature=+crt-static"
OPENSSL_NO_VENDOR: 0
run: |
cross build --release --target x86_64-unknown-linux-musl --features vendored-openssl
- name: Préparer le binaire
run: |
mkdir -p release
cp target/x86_64-unknown-linux-musl/release/github-ntfy release/github-ntfy
- name: Upload binaire comme artifact
uses: actions/upload-artifact@v4
with:
name: github-ntfy
path: release/github-ntfy
docker-build-push:
needs: [version, build-binaries]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configurer Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Télécharger tous les binaires
uses: actions/download-artifact@v4
with:
name: github-ntfy
path: binaries
- name: Préparer le binaire pour Docker
run: |
chmod +x binaries/github-ntfy
# Construire et pousser l'image multi-architecture
- name: Construire et pousser l'image Docker
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
breizhhardware/github-ntfy:latest
breizhhardware/github-ntfy:dev
breizhhardware/github-ntfy:${{ needs.version.outputs.version }}
file: Dockerfile
create-release:
needs: [version, build-binaries]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Télécharger tous les binaires
uses: actions/download-artifact@v4
with:
name: github-ntfy
path: binaries
- name: Créer une release GitHub
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.version.outputs.version }}
name: Release ${{ needs.version.outputs.version }}
files: |
binaries/github-ntfy
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}

52
.github/workflows/dependabot-build.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Dependabot Build Check
on:
pull_request:
branches: [dev]
permissions:
contents: read
pull-requests: read
jobs:
build:
if: ${{ startsWith(github.ref, 'refs/heads/dependabot/') || github.actor == 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Installer Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: x86_64-unknown-linux-musl
override: true
- name: Installer cross
run: cargo install cross
- name: Créer Cross.toml pour spécifier OpenSSL vendored
run: |
cat > Cross.toml << 'EOF'
[build.env]
passthrough = [
"RUSTFLAGS",
"OPENSSL_STATIC",
"OPENSSL_NO_VENDOR"
]
EOF
- name: Construire avec cross et OpenSSL vendored
env:
OPENSSL_STATIC: 1
RUSTFLAGS: "-C target-feature=+crt-static"
OPENSSL_NO_VENDOR: 0
run: |
cross build --release --target x86_64-unknown-linux-musl --features vendored-openssl
- name: Afficher des informations de débogage
run: |
echo "Acteur: ${{ github.actor }}"
echo "Référence de la branche: ${{ github.head_ref }}"
echo "Event name: ${{ github.event_name }}"

13
.gitignore vendored
View File

@@ -400,3 +400,16 @@ _deps
.nfs*
# End of https://www.toptal.com/developers/gitignore/api/c++,linux,clion,cmake,clion+all
docker-compose.yml
github-ntfy/
github-ntfy/*
*.db
# Rust
target
target/*
binaries
binaries/*

6
CHANGELOG.md Normal file
View File

@@ -0,0 +1,6 @@
**New features**:
- Add gotify compatibility please Read the README.md
- Add arm64 support
- Add armv7 support
**Full Changelog**: https://github.com/BreizhHardware/ntfy_alerts/compare/v1.4.3...v1.5

54
CONTRIBUTION.md Normal file
View File

@@ -0,0 +1,54 @@
# Contribution Guidelines
Thank you for considering contributing to this project! Your help is greatly appreciated. Please follow these guidelines to ensure a smooth contribution process.
## How to Contribute
1. **Fork the repository**: Click the "Fork" button at the top right of this repository to create a copy of the repository in your GitHub account.
2. **Clone your fork**: Clone your forked repository to your local machine.
```sh
git clone https://github.com/BreizhHardware/ntfy_alerts.git
cd ntfy_alerts
```
3. **Create a new branch**: Create a new branch for your feature or bugfix.
```sh
git checkout -b feat/my-feature-branch
```
4. **Make your changes**: Make your changes to the codebase. Ensure your code follows the project's coding standards and includes appropriate tests.
5. **Commit your changes**: Commit your changes with a clear and concise commit message using conventional commit.
```sh
git add .
git commit -m "feat: add feature X"
```
6. **Push to your fork**: Push your changes to your forked repository.
```sh
git push origin feat/my-feature-branch
```
7. **Create a Pull Request**: Go to the original repository and create a pull request from your forked repository. Provide a clear description of your changes and the problem they solve.
## Code Style
- Follow the existing code style and conventions.
- Write clear and concise comments where necessary.
- Ensure your code is well-documented.
## Testing
- Write tests for any new features or bug fixes.
- Ensure all tests pass before submitting your pull request.
## Reporting Issues
If you find a bug or have a feature request, please create an issue on the GitHub repository. Provide as much detail as possible to help us understand and address the issue.
## Code of Conduct
Please note that this project is released with a Contributor Code of Conduct. By participating in this project, you agree to abide by its terms.
Thank you for contributing!

2346
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "github-ntfy"
version = "2.0.0"
edition = "2021"
[[bin]]
name = "github-ntfy"
path = "src/main.rs"
[features]
vendored-openssl = ["openssl/vendored"]
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
rusqlite = { version = "0.36", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
env_logger = "0.11"
dotenv = "0.15"
chrono = "0.4"
warp = "0.3"
openssl = { version = "0.10", features = ["vendored"] }

View File

@@ -1,26 +1,25 @@
FROM python:3.11.8-alpine3.19
FROM alpine:3.22
LABEL maintainer="BreizhHardware"
# Copier le binaire
COPY binaries/github-ntfy /usr/local/bin/github-ntfy
ADD ntfy.py /
ADD ntfy_api.py /
ADD requirements.txt /
ADD entrypoint.sh /
ADD index.html /var/www/html/index.html
ADD script.js /var/www/html/script.js
RUN apk add --no-cache sqlite-dev sqlite-libs gcc musl-dev nginx
RUN pip install -r requirements.txt
# Installer les dépendances
RUN apk add --no-cache sqlite-libs openssl nginx && \
chmod +x /usr/local/bin/github-ntfy
# Définir les variables d'environnement pour username et password
ENV USERNAME="" \
PASSWORD="" \
NTFY_URL="" \
GHNTFY_TIMEOUT="3600" \
GHNTFY_TOKEN=""
# Exposer le port 5000 pour l'API et le port 80 pour le serveur web
EXPOSE 5000 80
WORKDIR /app
# Copier les fichiers web dans le répertoire attendu par nginx
COPY web/* /var/www/html/
COPY nginx.conf /etc/nginx/nginx.conf
ENTRYPOINT ["/entrypoint.sh"]
# Copier le script d'entrée
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Créer le répertoire de données
RUN mkdir -p /github-ntfy && chmod 755 /github-ntfy
EXPOSE 5000 80
ENTRYPOINT ["/app/entrypoint.sh"]

115
README.md
View File

@@ -1,25 +1,23 @@
# ntfy_alerts
Personal ntfy alerts system
<h1 align="center">Welcome to ntfy_alerts 👋</h1>
<p>
<img alt="Version" src="https://img.shields.io/badge/version-2.0-blue.svg?cacheSeconds=2592000" />
<a href="#" target="_blank">
<img alt="License: GPL--3" src="https://img.shields.io/badge/License-GPL--3-yellow.svg" />
</a>
<a href="https://twitter.com/BreizhHardware" target="_blank">
<img alt="Twitter: BreizhHardware" src="https://img.shields.io/twitter/follow/BreizhHardware.svg?style=social" />
</a>
</p>
feel free to contribute and to fork
> This project allows you to receive notifications about new GitHub or Docker Hub releases on ntfy, gotify, Discord and Slack. Implemented in Rust for better performance.
# Python ntfy.py
## Description:
This script is used to watch the github repos and send a notification to the ntfy server when a new release is published.
## Utilisation:
auth and ntfy_url are required to be set as environment variables.
## Installation
auth: can be generataed by the folowing command: echo -n 'username:password' | base64
### Docker (recommended)
ntfy_url: the url of the ntfy server including the topic
Use our Docker image, which automatically supports amd64, arm64 and armv7:
````python
python ntfy.py
````
## Docker:
If you want to use the docker image you can use the following docker-compose file:
````yaml
version: '3'
```yaml
services:
github-ntfy:
image: breizhhardware/github-ntfy:latest
@@ -27,51 +25,68 @@ services:
environment:
- USERNAME=username # Required
- PASSWORD=password # Required
- NTFY_URL=ntfy_url # Required
- NTFY_URL=ntfy_url # Required if ntfy is used
- GHNTFY_TIMEOUT=timeout # Default is 3600 (1 hour)
- GHNTFY_TOKEN= # Default is empty (Github token)
- DOCKER_USERNAME= # Default is empty (Docker Hub username)
- DOCKER_PASSWORD= # Default is empty (Docker Hub password)
- GOTIFY_URL=gotify_url # Required if gotify is used
- GOTIFY_TOKEN= # Required if gotify is used
- DISCORD_WEBHOOK_URL= # Required if discord is used
- SLACK_WEBHOOK_URL= # Required if Slack is used
volumes:
- /path/to/github-ntfy:/github-ntfy/
- /path/to/data:/data
ports:
- 80:80
restart: unless-stopped
````
GHNTFY_TOKEN, need to have repo, read:org and read:user
```
Docker Hub repo: https://hub.docker.com/r/breizhhardware/github-ntfy
## TODO:
- [x] Dockerize the ntfy.py
- [x] Add the watched repos list as a parameter
- [x] Add the application version as a database
- [x] Add the watched repos list as a web interface
# Bash setup-notify.sh
## Description:
This script is used to setup the ntfy notification system on ssh login for a new server.
## Utilisation:
````bash
bash setup-notify.sh <ntfy_url> <username> <password> <topic>
````
ntfy_url: the url of the ntfy server
### Manual Installation
Install Rust if needed
```BASH
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
username: the username of the user
Clone the repository
```BASH
git clone https://github.com/BreizhHardware/ntfy_alerts.git
cd ntfy_alerts
```
password: the password of the user
Compile
```BASH
cargo build --release
```
topic: the topic of the notification
Run
```BASH
./target/release/github-ntfy
```
This script will create a send-notify.sh in the root of your disk and add the login-notify.sh to the /etc/profile.d/ folder.
# Bash send-notify.sh
## Description:
This script is used to send a notification to the ntfy server.
## Utilisation:
````bash
bash send-notify.sh <ntfy_url> <basic_auth> <topic> <message>
````
ntfy_url: the url of the ntfy server
## Version Notes
- v2.0: Complete rewrite in Rust for better performance and reduced resource consumption
- [v1.7.1](https://github.com/BreizhHardware/ntfy_alerts/tree/v1.7.2): Stable Python version
basic_auth: the basic auth of the user
## Configuration
The GitHub token (GHNTFY_TOKEN) needs to have the following permissions: repo, read:org and read:user.
topic: the topic of the notification
## TODO
- [ ] Add support for multi achitecture Docker images
- [ ] Rework web interface
- [ ] Add support for more notification services (Telegram, Matrix, etc.)
- [ ] Add web oneboarding instead of using environment variables
message: the message of the notification
## Author
👤 BreizhHardware
- Website: [https://mrqt.fr](https://mrqt.fr?ref=github)
- Twitter: [@BreizhHardware](https://twitter.com/BreizhHardware)
- Github: [@BreizhHardware](https://github.com/BreizhHardware)
- LinkedIn: [@félix-marquet-5071bb167](https://linkedin.com/in/félix-marquet-5071bb167)
## Contributing
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. But first, please read the [CONTRIBUTION.md](CONTRIBUTION.md) file.
## Show your support
Give a ⭐️ if this project helped you!

View File

@@ -6,5 +6,5 @@ echo -n "$USERNAME:$PASSWORD" | base64 > /auth.txt
# Démarrer nginx en arrière-plan
nginx -g 'daemon off;' &
# Exécute le script Python
exec python ./ntfy.py
# Exécute l'application Rust
exec /usr/local/bin/github-ntfy

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Github-Ntfy Add a repo</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="./script.js" defer></script>
</head>
<body class="bg-gradient-to-b from-cyan-500 to-fuchsia-500">
<div class="flex flex-col gap-2 justify-center items-center my-2 h-screen">
<h1 class="text-4xl font-semibold leading-10 text-gray-900">Github-Ntfy</h1>
<h1>Add a repo</h1>
<form id="addRepoForm">
<div class="space-y-12">
<div class="border-b border-gray-900/10 pb-12">
<h2 class="text-base font-semibold leading-7 text-gray-900">Name of the github repo</h2>
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-4">
<div class="mt-2">
<div class="flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600 sm:max-w-md">
<span class="flex select-none items-center pl-3 sm:text-sm">github.com/</span>
<input type="text" name="repo" id="repo" autocomplete="repo" class="block flex-1 border-0 bg-transparent py-1.5 pl-1 placeholder:text-gray-600 focus:ring-0 sm:text-sm sm:leading-6" placeholder="BreizhHardware/ntfy_alerts">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-x-6">
<button type="button" class="text-sm font-semibold leading-6 text-gray-900">Cancel</button>
<button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Save</button>
</div>
</form>
<div class="mt-8">
<h2 class="text-base font-semibold leading-7 text-gray-900">Watched Repositories</h2>
<ul id="watchedReposList" class="mt-4">
<!-- Dynamically populated with JavaScript -->
</ul>
</div>
</div>
</body>
</html>

View File

@@ -28,5 +28,33 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /delete_repo {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /app_docker_repo {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /watched_docker_repos {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /delete_docker_repo {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

134
ntfy.py
View File

@@ -1,134 +0,0 @@
import requests
import time
import os
import logging
import sqlite3
import subprocess
# Configurer le logger
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
github_token = os.environ.get('GHNTFY_TOKEN')
github_headers = {}
if github_token:
github_headers['Authorization'] = f"token {github_token}"
# Connexion à la base de données pour stocker les versions précédentes
conn = sqlite3.connect('/github-ntfy/ghntfy_versions.db', check_same_thread=False)
cursor = conn.cursor()
# Création de la table si elle n'existe pas
cursor.execute('''CREATE TABLE IF NOT EXISTS versions
(repo TEXT PRIMARY KEY, version TEXT, changelog TEXT)''')
conn.commit()
logger.info("Démarrage de la surveillance des versions...")
conn2 = sqlite3.connect('/github-ntfy/watched_repos.db', check_same_thread=False)
cursor2 = conn2.cursor()
cursor2.execute('''CREATE TABLE IF NOT EXISTS watched_repos
(id INTEGER PRIMARY KEY, repo TEXT)''')
conn2.commit()
def get_watched_repos():
cursor2.execute("SELECT * FROM watched_repos")
watched_repos = cursor2.fetchall()
watched_repos = []
for repo in watched_repos:
watched_repos.append(repo[1])
return watched_repos
def start_api():
subprocess.Popen(["python", "ntfy_api.py"])
def get_latest_releases(watched_repos):
releases = []
for repo in watched_repos:
url = f"https://api.github.com/repos/{repo}/releases/latest"
response = requests.get(url, headers=github_headers)
if response.status_code == 200:
release_info = response.json()
changelog = get_changelog(repo)
releases.append({
"repo": repo,
"name": release_info["name"],
"tag_name": release_info["tag_name"],
"html_url": release_info["html_url"],
"changelog": changelog
})
else:
logger.error(f"Failed to fetch release info for {repo}")
return releases
def get_changelog(repo):
url = f"https://api.github.com/repos/{repo}/releases"
response = requests.get(url, headers=github_headers)
if response.status_code == 200:
releases = response.json()
if releases:
latest_release = releases[0]
if 'body' in latest_release:
return latest_release['body']
return "Changelog non disponible"
def send_to_ntfy(releases, auth, url):
for release in releases:
app_name = release['repo'].split('/')[-1] # Obtenir le nom de l'application à partir du repo
version_number = release['tag_name'] # Obtenir le numéro de version
app_url = release['html_url'] # Obtenir l'URL de l'application
changelog = release['changelog'] # Obtenir le changelog
# Vérifier si la version a changé depuis la dernière fois
cursor.execute("SELECT version FROM versions WHERE repo=?", (app_name,))
previous_version = cursor.fetchone()
if previous_version and previous_version[0] == version_number:
logger.info(f"La version de {app_name} n'a pas changé. Pas de notification envoyée.")
continue # Passer à l'application suivante
message = f"Nouvelle version: {version_number}\nPour: {app_name}\nChangelog:\n{changelog}\n{app_url}"
# Mettre à jour la version précédente pour cette application
cursor.execute("INSERT OR REPLACE INTO versions (repo, version, changelog) VALUES (?, ?, ?)",
(app_name, version_number, changelog))
conn.commit()
headers = {
"Authorization": f"Basic {auth}",
"Title": f"New version for {app_name}",
"Priority": "urgent",
"Markdown": "yes",
"Actions": f"view, Update {app_name}, {app_url}, clear=true"}
response = requests.post(f"{url}", headers=headers, data=message)
if response.status_code == 200:
logger.info(f"Message envoyé à Ntfy pour {app_name}")
continue
else:
logger.error(f"Échec de l'envoi du message à Ntfy. Code d'état : {response.status_code}")
if __name__ == "__main__":
start_api()
with open('/auth.txt', 'r') as f:
auth = f.read().strip()
ntfy_url = os.environ.get('NTFY_URL')
timeout = float(os.environ.get('GHNTFY_TIMEOUT'))
if auth and ntfy_url:
while True:
watched_repos_list = get_watched_repos()
latest_release = get_latest_releases(watched_repos_list)
if latest_release:
send_to_ntfy(latest_release, auth, ntfy_url)
time.sleep(timeout) # Attendre une heure avant de vérifier à nouveau
else:
logger.error("Usage: python ntfy.py")
logger.error(
"auth: can be generataed by the folowing command: echo -n 'username:password' | base64 and need to be stored in a file named auth.txt")
logger.error("NTFY_URL: the url of the ntfy server need to be stored in an environment variable named NTFY_URL")

View File

@@ -1,60 +0,0 @@
from flask import Flask, request, jsonify
from flask_cors import CORS
import sqlite3
app = Flask(__name__)
CORS(app)
def get_db_connection():
conn = sqlite3.connect('/github-ntfy/watched_repos.db')
conn.row_factory = sqlite3.Row
return conn
def close_db_connection(conn):
conn.close()
@app.route('/app_repo', methods=['POST'])
def app_repo():
data = request.json
repo = data.get('repo')
# Vérifier si le champ 'repo' est présent dans les données JSON
if not repo:
return jsonify({"error": "Le champ 'repo' est requis."}), 400
# Établir une connexion à la base de données
conn = get_db_connection()
cursor = conn.cursor()
try:
# Vérifier si le dépôt existe déjà dans la base de données
cursor.execute("SELECT * FROM watched_repos WHERE repo=?", (repo,))
existing_repo = cursor.fetchone()
if existing_repo:
return jsonify({"error": f"Le dépôt {repo} existe déjà."}), 409
# Ajouter le dépôt à la base de données
cursor.execute("INSERT INTO watched_repos (repo) VALUES (?)", (repo,))
conn.commit()
return jsonify({"message": f"Le dépôt {repo} a été ajouté à la liste des dépôts surveillés."})
finally:
# Fermer la connexion à la base de données
close_db_connection(conn)
@app.route('/watched_repos', methods=['GET'])
def get_watched_repos():
db = get_db_connection()
cursor = db.cursor()
cursor.execute("SELECT repo FROM watched_repos")
watched_repos = [repo[0] for repo in cursor.fetchall()]
cursor.close()
db.close()
return jsonify(watched_repos)
if __name__ == "__main__":
app.run(debug=True)

View File

@@ -1,4 +0,0 @@
requests==2.31.0
pysqlite3==0.5.2
flask==3.0.2
flask-cors==4.0.0

View File

@@ -1,45 +0,0 @@
document.getElementById('addRepoForm').addEventListener('submit', function(event) {
event.preventDefault();
let repoName = document.getElementById('repo').value;
fetch('/app_repo', {
method: 'POST',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
},
body: JSON.stringify({repo: repoName})
})
.then(response => {
if (response.ok) {
// Si la requête s'est bien déroulée, actualiser la liste des dépôts surveillés
refreshWatchedRepos();
} else {
throw new Error('Erreur lors de l\'ajout du dépôt');
}
})
.catch(error => {
console.error('Error:', error);
});
});
function refreshWatchedRepos() {
fetch('/watched_repos')
.then(response => response.json())
.then(data => {
const watchedReposList = document.getElementById('watchedReposList');
// Vider la liste actuelle
watchedReposList.innerHTML = '';
// Ajouter chaque dépôt surveillé à la liste
data.forEach(repo => {
const listItem = document.createElement('li');
listItem.textContent = repo;
watchedReposList.appendChild(listItem);
});
})
.catch(error => {
console.error('Error:', error);
});
}
// Appeler la fonction pour charger les dépôts surveillés au chargement de la page
refreshWatchedRepos();

386
src/api.rs Normal file
View File

@@ -0,0 +1,386 @@
use log::{error, info};
use rusqlite::{Connection, Result as SqliteResult, params};
use serde_json::json;
use std::env;
use std::sync::Arc;
use tokio::sync::Mutex;
use warp::{Filter, Reply, Rejection};
use warp::http::StatusCode;
use serde::{Serialize, Deserialize};
use warp::cors::Cors;
#[derive(Debug, Serialize, Deserialize)]
struct RepoRequest {
repo: String,
}
pub async fn start_api() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Open the database
let db_path = env::var("DB_PATH").unwrap_or_else(|_| "/github-ntfy".to_string());
std::fs::create_dir_all(&db_path).ok();
let repos_path = format!("{}/watched_repos.db", db_path);
match Connection::open(&repos_path) {
Ok(conn) => {
info!("Database connection established successfully");
let db = Arc::new(Mutex::new(conn));
// Route definitions
let add_github = warp::path("app_repo")
.and(warp::post())
.and(warp::body::json())
.and(with_db(db.clone()))
.and_then(add_github_repo);
let add_docker = warp::path("app_docker_repo")
.and(warp::post())
.and(warp::body::json())
.and(with_db(db.clone()))
.and_then(add_docker_repo);
let get_github = warp::path("watched_repos")
.and(warp::get())
.and(with_db(db.clone()))
.and_then(get_github_repos);
let get_docker = warp::path("watched_docker_repos")
.and(warp::get())
.and(with_db(db.clone()))
.and_then(get_docker_repos);
let delete_github = warp::path("delete_repo")
.and(warp::post())
.and(warp::body::json())
.and(with_db(db.clone()))
.and_then(delete_github_repo);
let delete_docker = warp::path("delete_docker_repo")
.and(warp::post())
.and(warp::body::json())
.and(with_db(db.clone()))
.and_then(delete_docker_repo);
// Configure CORS
let cors = warp::cors()
.allow_any_origin()
.allow_headers(vec!["Content-Type"])
.allow_methods(vec!["GET", "POST"]);
// Combine all routes with CORS
let routes = add_github
.or(add_docker)
.or(get_github)
.or(get_docker)
.or(delete_github)
.or(delete_docker)
.with(cors);
// Start the server
info!("Starting API on 0.0.0.0:5000");
warp::serve(routes).run(([0, 0, 0, 0], 5000)).await;
Ok(())
},
Err(e) => {
error!("Unable to open database: {}", e);
Err(Box::new(e))
}
}
}
fn with_db(db: Arc<Mutex<Connection>>) -> impl Filter<Extract = (Arc<Mutex<Connection>>,), Error = std::convert::Infallible> + Clone {
warp::any().map(move || db.clone())
}
async fn add_github_repo(body: RepoRequest, db: Arc<Mutex<Connection>>) -> Result<impl Reply, Rejection> {
let repo = body.repo;
if repo.is_empty() {
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": "The 'repo' field is required."})),
StatusCode::BAD_REQUEST
));
}
let mut db_guard = db.lock().await;
// Check if repository already exists
match db_guard.query_row(
"SELECT COUNT(*) FROM watched_repos WHERE repo = ?",
params![repo],
|row| row.get::<_, i64>(0)
) {
Ok(count) if count > 0 => {
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("GitHub repository {} is already in the database.", repo)})),
StatusCode::CONFLICT
));
},
Err(e) => {
error!("Error while checking repository: {}", e);
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": "An internal server error occurred."})),
StatusCode::INTERNAL_SERVER_ERROR
));
},
_ => {}
}
// Add the repository
match db_guard.execute("INSERT INTO watched_repos (repo) VALUES (?)", params![repo]) {
Ok(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&json!({"message": format!("GitHub repository {} has been added to watched repositories.", repo)})),
StatusCode::OK
))
},
Err(e) => {
error!("Error while adding repository: {}", e);
Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
))
}
}
}
async fn add_docker_repo(body: RepoRequest, db: Arc<Mutex<Connection>>) -> Result<impl Reply, Rejection> {
let repo = body.repo;
if repo.is_empty() {
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": "The 'repo' field is required."})),
StatusCode::BAD_REQUEST
));
}
let mut db_guard = db.lock().await;
// Check if repository already exists
match db_guard.query_row(
"SELECT COUNT(*) FROM docker_watched_repos WHERE repo = ?",
params![repo],
|row| row.get::<_, i64>(0)
) {
Ok(count) if count > 0 => {
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Docker repository {} is already in the database.", repo)})),
StatusCode::CONFLICT
));
},
Err(e) => {
error!("Error while checking repository: {}", e);
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
));
},
_ => {}
}
// Add the repository
match db_guard.execute("INSERT INTO docker_watched_repos (repo) VALUES (?)", params![repo]) {
Ok(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&json!({"message": format!("Docker repository {} has been added to watched repositories.", repo)})),
StatusCode::OK
))
},
Err(e) => {
error!("Error while adding repository: {}", e);
Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
))
}
}
}
async fn get_github_repos(db: Arc<Mutex<Connection>>) -> Result<impl Reply, Rejection> {
// Solution: collect all results inside the locked block
let repos = {
let db_guard = db.lock().await;
let mut stmt = match db_guard.prepare("SELECT repo FROM watched_repos") {
Ok(stmt) => stmt,
Err(e) => {
error!("Error while preparing query: {}", e);
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
));
}
};
let rows = match stmt.query_map([], |row| row.get::<_, String>(0)) {
Ok(rows) => rows,
Err(e) => {
error!("Error while executing query: {}", e);
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
));
}
};
let mut repos = Vec::new();
for row in rows {
if let Ok(repo) = row {
repos.push(repo);
}
}
repos
}; // Lock is released here
Ok(warp::reply::with_status(
warp::reply::json(&repos),
StatusCode::OK
))
}
async fn get_docker_repos(db: Arc<Mutex<Connection>>) -> Result<impl Reply, Rejection> {
// Solution: collect all results inside the locked block
let repos = {
let db_guard = db.lock().await;
let mut stmt = match db_guard.prepare("SELECT repo FROM docker_watched_repos") {
Ok(stmt) => stmt,
Err(e) => {
error!("Error while preparing query: {}", e);
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
));
}
};
let rows = match stmt.query_map([], |row| row.get::<_, String>(0)) {
Ok(rows) => rows,
Err(e) => {
error!("Error while executing query: {}", e);
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
));
}
};
let mut repos = Vec::new();
for row in rows {
if let Ok(repo) = row {
repos.push(repo);
}
}
repos
}; // Lock is released here
Ok(warp::reply::with_status(
warp::reply::json(&repos),
StatusCode::OK
))
}
async fn delete_github_repo(body: RepoRequest, db: Arc<Mutex<Connection>>) -> Result<impl Reply, Rejection> {
let repo = body.repo;
if repo.is_empty() {
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": "The 'repo' field is required."})),
StatusCode::BAD_REQUEST
));
}
let mut db_guard = db.lock().await;
// Check if repository exists
match db_guard.query_row(
"SELECT COUNT(*) FROM watched_repos WHERE repo = ?",
params![repo],
|row| row.get::<_, i64>(0)
) {
Ok(count) if count == 0 => {
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("GitHub repository {} is not in the database.", repo)})),
StatusCode::NOT_FOUND
));
},
Err(e) => {
error!("Error while checking repository: {}", e);
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
));
},
_ => {}
}
// Delete the repository
match db_guard.execute("DELETE FROM watched_repos WHERE repo = ?", params![repo]) {
Ok(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&json!({"message": format!("GitHub repository {} has been removed from watched repositories.", repo)})),
StatusCode::OK
))
},
Err(e) => {
error!("Error while deleting repository: {}", e);
Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
))
}
}
}
async fn delete_docker_repo(body: RepoRequest, db: Arc<Mutex<Connection>>) -> Result<impl Reply, Rejection> {
let repo = body.repo;
if repo.is_empty() {
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": "The 'repo' field is required."})),
StatusCode::BAD_REQUEST
));
}
let mut db_guard = db.lock().await;
// Check if repository exists
match db_guard.query_row(
"SELECT COUNT(*) FROM docker_watched_repos WHERE repo = ?",
params![repo],
|row| row.get::<_, i64>(0)
) {
Ok(count) if count == 0 => {
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Docker repository {} is not in the database.", repo)})),
StatusCode::NOT_FOUND
));
},
Err(e) => {
error!("Error while checking repository: {}", e);
return Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
));
},
_ => {}
}
// Delete the repository
match db_guard.execute("DELETE FROM docker_watched_repos WHERE repo = ?", params![repo]) {
Ok(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&json!({"message": format!("Docker repository {} has been removed from watched repositories.", repo)})),
StatusCode::OK
))
},
Err(e) => {
error!("Error while deleting repository: {}", e);
Ok(warp::reply::with_status(
warp::reply::json(&json!({"error": format!("Database error: {}", e)})),
StatusCode::INTERNAL_SERVER_ERROR
))
}
}
}

81
src/config.rs Normal file
View File

@@ -0,0 +1,81 @@
use dotenv::dotenv;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use std::env;
use std::fs::File;
use std::io::Read;
use crate::docker::create_dockerhub_token;
// Configuration
pub struct Config {
pub github_token: Option<String>,
pub docker_username: Option<String>,
pub docker_password: Option<String>,
pub docker_token: Option<String>,
pub ntfy_url: Option<String>,
pub gotify_url: Option<String>,
pub gotify_token: Option<String>,
pub discord_webhook_url: Option<String>,
pub slack_webhook_url: Option<String>,
pub auth: String,
pub timeout: f64,
}
impl Config {
pub fn from_env() -> Self {
dotenv().ok();
let docker_username = env::var("DOCKER_USERNAME").ok();
let docker_password = env::var("DOCKER_PASSWORD").ok();
let docker_token = if let (Some(username), Some(password)) = (&docker_username, &docker_password) {
create_dockerhub_token(username, password)
} else {
None
};
// Read authentication file
let mut auth = String::new();
if let Ok(mut file) = File::open("/auth.txt") {
file.read_to_string(&mut auth).ok();
auth = auth.trim().to_string();
}
Config {
github_token: env::var("GHNTFY_TOKEN").ok(),
docker_username,
docker_password,
docker_token,
ntfy_url: env::var("NTFY_URL").ok(),
gotify_url: env::var("GOTIFY_URL").ok(),
gotify_token: env::var("GOTIFY_TOKEN").ok(),
discord_webhook_url: env::var("DISCORD_WEBHOOK_URL").ok(),
slack_webhook_url: env::var("SLACK_WEBHOOK_URL").ok(),
auth,
timeout: env::var("GHNTFY_TIMEOUT")
.unwrap_or_else(|_| "3600".to_string())
.parse()
.unwrap_or(3600.0),
}
}
pub fn github_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(token) = &self.github_token {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("token {}", token)).unwrap(),
);
}
headers
}
pub fn docker_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(token) = &self.docker_token {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
);
}
headers
}
}

103
src/database.rs Normal file
View File

@@ -0,0 +1,103 @@
use log::info;
pub(crate) use rusqlite::{Connection, Result as SqliteResult, OpenFlags};
use std::env;
use std::path::Path;
pub fn init_databases() -> SqliteResult<(Connection, Connection)> {
let db_path = env::var("DB_PATH").unwrap_or_else(|_| "/github-ntfy".to_string());
if let Err(e) = std::fs::create_dir_all(&db_path) {
info!("Error while creating directory {}: {}", db_path, e);
}
let versions_path = format!("{}/ghntfy_versions.db", db_path);
let repos_path = format!("{}/watched_repos.db", db_path);
let conn = Connection::open_with_flags(&versions_path, OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_URI)?;
info!("Database open at {}", versions_path);
conn.execute(
"CREATE TABLE IF NOT EXISTS versions (
repo TEXT PRIMARY KEY,
version TEXT,
changelog TEXT
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS docker_versions (
repo TEXT PRIMARY KEY,
digest TEXT
)",
[],
)?;
let conn2 = Connection::open_with_flags(&repos_path, OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_URI)?;
info!("Database open at {}", repos_path);
conn2.execute(
"CREATE TABLE IF NOT EXISTS watched_repos (
id INTEGER PRIMARY KEY,
repo TEXT
)",
[],
)?;
conn2.execute(
"CREATE TABLE IF NOT EXISTS docker_watched_repos (
id INTEGER PRIMARY KEY,
repo TEXT
)",
[],
)?;
Ok((conn, conn2))
}
// Functions to retrieve watched repositories
pub fn get_watched_repos(conn: &Connection) -> SqliteResult<Vec<String>> {
let mut stmt = conn.prepare("SELECT repo FROM watched_repos")?;
let repos_iter = stmt.query_map([], |row| Ok(row.get::<_, String>(0)?))?;
let mut repos = Vec::new();
for repo in repos_iter {
repos.push(repo?);
}
Ok(repos)
}
pub fn get_docker_watched_repos(conn: &Connection) -> SqliteResult<Vec<String>> {
let mut stmt = conn.prepare("SELECT repo FROM docker_watched_repos")?;
let repos_iter = stmt.query_map([], |row| Ok(row.get::<_, String>(0)?))?;
let mut repos = Vec::new();
for repo in repos_iter {
repos.push(repo?);
}
Ok(repos)
}
pub fn is_new_version(conn: &Connection, repo: &str, version: &str) -> SqliteResult<bool> {
let mut stmt = conn.prepare("SELECT version FROM versions WHERE repo = ?")?;
let result = stmt.query_map([repo], |row| row.get::<_, String>(0))?;
for stored_version in result {
if let Ok(v) = stored_version {
return Ok(v != version);
}
}
Ok(true)
}
pub fn update_version(conn: &Connection, repo: &str, version: &str, changelog: Option<&str>) -> SqliteResult<()> {
conn.execute(
"REPLACE INTO versions (repo, version, changelog) VALUES (?, ?, ?)",
[repo, version, changelog.unwrap_or("")],
)?;
Ok(())
}

73
src/docker.rs Normal file
View File

@@ -0,0 +1,73 @@
use log::error;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use serde_json::json;
use crate::models::{DockerTag, DockerReleaseInfo};
pub fn create_dockerhub_token(username: &str, password: &str) -> Option<String> {
let client = reqwest::blocking::Client::new();
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
let data = json!({
"username": username,
"password": password
});
match client
.post("https://hub.docker.com/v2/users/login")
.headers(headers)
.json(&data)
.send()
{
Ok(response) => {
let status = response.status();
if status.is_success() {
if let Ok(json) = response.json::<serde_json::Value>() {
return json["token"].as_str().map(|s| s.to_string());
}
}
error!("DockerHub authentication failed: {}", status);
None
}
Err(e) => {
error!("Error connecting to DockerHub: {}", e);
None
}
}
}
pub async fn get_latest_docker_releases(
repos: &[String],
client: &reqwest::Client,
headers: HeaderMap,
) -> Vec<DockerReleaseInfo> {
let mut releases = Vec::new();
for repo in repos {
let url = format!("https://hub.docker.com/v2/repositories/{}/tags/latest", repo);
match client.get(&url).headers(headers.clone()).send().await {
Ok(response) => {
if response.status().is_success() {
if let Ok(tag) = response.json::<DockerTag>().await {
releases.push(DockerReleaseInfo {
repo: repo.clone(),
digest: tag.digest.clone(),
html_url: format!("https://hub.docker.com/r/{}", repo),
published_at: tag.last_updated,
});
}
} else {
error!("Error fetching Docker tag for {}: {}", repo, response.status());
}
}
Err(e) => {
error!("Error fetching Docker tag for {}: {}", repo, e);
}
}
}
releases
}

80
src/github.rs Normal file
View File

@@ -0,0 +1,80 @@
use log::{error, info};
use reqwest::header::HeaderMap;
use crate::models::{GithubRelease, GithubReleaseInfo};
pub async fn get_latest_releases(
repos: &[String],
client: &reqwest::Client,
mut headers: HeaderMap
) -> Vec<GithubReleaseInfo> {
let mut releases = Vec::new();
if !headers.contains_key("User-Agent") {
headers.insert("User-Agent", "github-ntfy/1.0".parse().unwrap());
}
let has_auth = headers.contains_key("Authorization");
if !has_auth {
info!("Aucun token GitHub configuré, les requêtes seront limitées");
}
for repo in repos {
let url = format!("https://api.github.com/repos/{}/releases/latest", repo);
match client.get(&url).headers(headers.clone()).send().await {
Ok(response) => {
if response.status().is_success() {
if let Ok(release) = response.json::<GithubRelease>().await {
let changelog = get_changelog(repo, client, headers.clone()).await;
releases.push(GithubReleaseInfo {
repo: repo.clone(),
name: release.name,
tag_name: release.tag_name,
html_url: release.html_url,
changelog,
published_at: release.published_at.unwrap_or_else(|| "Unknown date".to_string()),
});
}
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!("Erreur lors de la récupération de la release GitHub pour {}: {} - {}",
repo, status, body);
}
},
Err(e) => {
error!("Erreur de connexion pour {}: {}", repo, e);
}
}
}
releases
}
pub async fn get_changelog(
repo: &str,
client: &reqwest::Client,
headers: HeaderMap,
) -> String {
let url = format!("https://api.github.com/repos/{}/releases", repo);
match client.get(&url).headers(headers).send().await {
Ok(response) => {
if response.status().is_success() {
if let Ok(releases) = response.json::<Vec<GithubRelease>>().await {
if !releases.is_empty() {
if let Some(body) = &releases[0].body {
return body.clone();
}
}
}
}
}
Err(e) => {
error!("Error retrieving changelog for {}: {}", repo, e);
}
}
"Changelog not available".to_string()
}

64
src/main.rs Normal file
View File

@@ -0,0 +1,64 @@
mod config;
mod models;
mod database;
mod github;
mod docker;
mod notifications;
mod api;
use log::{error, info};
use std::thread;
use std::time::Duration;
use tokio::task;
// Function to start the API in a separate thread
fn start_api() {
std::thread::spawn(|| {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
match api::start_api().await {
Ok(_) => info!("API closed correctly"),
Err(e) => error!("API error: {}", e),
}
});
});
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let config = config::Config::from_env();
let (conn_versions, conn_repos) = database::init_databases()?;
start_api();
let client = reqwest::Client::new();
if config.auth.is_empty() || (config.ntfy_url.is_none() && config.gotify_url.is_none()
&& config.discord_webhook_url.is_none() && config.slack_webhook_url.is_none()) {
error!("Incorrect configuration!");
error!("auth: can be generated with the command: echo -n 'username:password' | base64");
error!("NTFY_URL: URL of the ntfy server");
error!("GOTIFY_URL: URL of the gotify server");
error!("GOTIFY_TOKEN: Gotify token");
error!("DISCORD_WEBHOOK_URL: Discord webhook URL");
error!("SLACK_WEBHOOK_URL: Slack webhook URL");
error!("GHNTFY_TIMEOUT: interval between checks");
return Ok(());
}
info!("Starting version monitoring...");
loop {
let github_repos = database::get_watched_repos(&conn_repos)?;
let docker_repos = database::get_docker_watched_repos(&conn_repos)?;
let github_releases = github::get_latest_releases(&github_repos, &client, config.github_headers()).await;
let docker_releases = docker::get_latest_docker_releases(&docker_repos, &client, config.docker_headers()).await;
notifications::send_notifications(github_releases, docker_releases, &config, &conn_versions).await;
tokio::time::sleep(Duration::from_secs_f64(config.timeout)).await;
}
}

42
src/models.rs Normal file
View File

@@ -0,0 +1,42 @@
use serde::Deserialize;
// Structures for GitHub data
#[derive(Debug, Deserialize, Clone)]
pub struct GithubRelease {
pub name: String,
pub tag_name: String,
pub html_url: String,
pub published_at: Option<String>,
pub body: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GithubReleaseInfo {
pub repo: String,
pub name: String,
pub tag_name: String,
pub html_url: String,
pub changelog: String,
pub published_at: String,
}
// Structures for Docker data
#[derive(Debug, Deserialize)]
pub struct DockerTag {
pub digest: String,
pub last_updated: String,
}
#[derive(Debug, Clone)]
pub struct DockerReleaseInfo {
pub repo: String,
pub digest: String,
pub html_url: String,
pub published_at: String,
}
pub struct NotifiedRelease {
pub repo: String,
pub tag_name: String,
pub notified_at: chrono::DateTime<chrono::Utc>,
}

View File

@@ -0,0 +1,85 @@
use log::{error, info};
use serde_json::json;
use reqwest::header::HeaderMap;
use crate::models::{GithubReleaseInfo, DockerReleaseInfo};
pub async fn send_github_notification(release: &GithubReleaseInfo, webhook_url: &str) {
let client = reqwest::Client::new();
let app_name = release.repo.split('/').last().unwrap_or(&release.repo);
let mut message = format!(
"📌 *New version*: {}\n\n📦*For*: {}\n\n📅 *Published on*: {}\n\n📝 *Changelog*:\n\n```{}```",
release.tag_name,
app_name,
release.published_at.replace("T", " ").replace("Z", ""),
release.changelog
);
if message.len() > 2000 {
message = format!(
"📌 *New version*: {}\n\n📦*For*: {}\n\n📅 *Published on*: {}\n\n🔗 *Release Link*: {}",
release.tag_name,
app_name,
release.published_at.replace("T", " ").replace("Z", ""),
release.html_url
);
}
let data = json!({
"content": message,
"username": "GitHub Ntfy"
});
let headers = HeaderMap::new();
match client.post(webhook_url)
.headers(headers)
.json(&data)
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Message sent to Discord for {}", app_name);
},
Ok(response) => {
error!("Failed to send message to Discord. Status code: {}", response.status());
},
Err(e) => {
error!("Error sending to Discord: {}", e);
}
}
}
pub async fn send_docker_notification(release: &DockerReleaseInfo, webhook_url: &str) {
let client = reqwest::Client::new();
let app_name = release.repo.split('/').last().unwrap_or(&release.repo);
let message = format!(
"🐳 *Docker Image Updated!*\n\n🔐 *New Digest*: `{}`\n\n📦 *App*: {}\n\n📢 *Published*: {}\n\n🔗 *Link*: {}",
release.digest,
app_name,
release.published_at.replace("T", " ").replace("Z", ""),
release.html_url
);
let data = json!({
"content": message,
"username": "GitHub Ntfy"
});
match client.post(webhook_url)
.json(&data)
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Message sent to Discord for {}", app_name);
},
Ok(response) => {
error!("Failed to send message to Discord. Status code: {}", response.status());
},
Err(e) => {
error!("Error sending to Discord: {}", e);
}
}
}

View File

@@ -0,0 +1,69 @@
use tokio::task;
use crate::models::DockerReleaseInfo;
use crate::config::Config;
use crate::notifications::{ntfy, gotify, discord, slack};
pub async fn send_to_ntfy(release: DockerReleaseInfo, auth: &str, ntfy_url: &str) {
ntfy::send_docker_notification(&release, auth, ntfy_url).await;
}
pub async fn send_to_gotify(release: DockerReleaseInfo, token: &str, gotify_url: &str) {
gotify::send_docker_notification(&release, token, gotify_url).await;
}
pub async fn send_to_discord(release: DockerReleaseInfo, webhook_url: &str) {
discord::send_docker_notification(&release, webhook_url).await;
}
pub async fn send_to_slack(release: DockerReleaseInfo, webhook_url: &str) {
slack::send_docker_notification(&release, webhook_url).await;
}
pub async fn send_notifications(releases: &[DockerReleaseInfo], config: &Config) {
let mut tasks = Vec::new();
for release in releases {
// Send to Ntfy
if let Some(url) = &config.ntfy_url {
let release_clone = release.clone();
let auth = config.auth.clone();
let url_clone = url.clone();
tasks.push(task::spawn(async move {
send_to_ntfy(release_clone, &auth, &url_clone).await;
}));
}
// Send to Gotify
if let (Some(gotify_url), Some(gotify_token)) = (&config.gotify_url, &config.gotify_token) {
let release_clone = release.clone();
let token = gotify_token.clone();
let url = gotify_url.clone();
tasks.push(task::spawn(async move {
send_to_gotify(release_clone, &token, &url).await;
}));
}
// Send to Discord
if let Some(discord_url) = &config.discord_webhook_url {
let release_clone = release.clone();
let url = discord_url.clone();
tasks.push(task::spawn(async move {
send_to_discord(release_clone, &url).await;
}));
}
// Send to Slack
if let Some(slack_url) = &config.slack_webhook_url {
let release_clone = release.clone();
let url = slack_url.clone();
tasks.push(task::spawn(async move {
send_to_slack(release_clone, &url).await;
}));
}
}
// Wait for all tasks to complete
for task in tasks {
let _ = task.await;
}
}

View File

@@ -0,0 +1,69 @@
use tokio::task;
use crate::models::GithubReleaseInfo;
use crate::config::Config;
use crate::notifications::{ntfy, gotify, discord, slack};
pub async fn send_to_ntfy(release: GithubReleaseInfo, auth: &str, ntfy_url: &str) {
ntfy::send_github_notification(&release, auth, ntfy_url).await;
}
pub async fn send_to_gotify(release: GithubReleaseInfo, token: &str, gotify_url: &str) {
gotify::send_github_notification(&release, token, gotify_url).await;
}
pub async fn send_to_discord(release: GithubReleaseInfo, webhook_url: &str) {
discord::send_github_notification(&release, webhook_url).await;
}
pub async fn send_to_slack(release: GithubReleaseInfo, webhook_url: &str) {
slack::send_github_notification(&release, webhook_url).await;
}
pub async fn send_notifications(releases: &[GithubReleaseInfo], config: &Config) {
let mut tasks = Vec::new();
for release in releases {
// Send to Ntfy
if let Some(url) = &config.ntfy_url {
let release_clone = release.clone();
let auth = config.auth.clone();
let url_clone = url.clone();
tasks.push(task::spawn(async move {
send_to_ntfy(release_clone, &auth, &url_clone).await;
}));
}
// Send to Gotify
if let (Some(gotify_url), Some(gotify_token)) = (&config.gotify_url, &config.gotify_token) {
let release_clone = release.clone();
let token = gotify_token.clone();
let url = gotify_url.clone();
tasks.push(task::spawn(async move {
send_to_gotify(release_clone, &token, &url).await;
}));
}
// Send to Discord
if let Some(discord_url) = &config.discord_webhook_url {
let release_clone = release.clone();
let url = discord_url.clone();
tasks.push(task::spawn(async move {
send_to_discord(release_clone, &url).await;
}));
}
// Send to Slack
if let Some(slack_url) = &config.slack_webhook_url {
let release_clone = release.clone();
let url = slack_url.clone();
tasks.push(task::spawn(async move {
send_to_slack(release_clone, &url).await;
}));
}
}
// Wait for all tasks to complete
for task in tasks {
let _ = task.await;
}
}

View File

@@ -0,0 +1,78 @@
use log::{error, info};
use serde_json::json;
use crate::models::{GithubReleaseInfo, DockerReleaseInfo};
pub async fn send_github_notification(release: &GithubReleaseInfo, token: &str, gotify_url: &str) {
let client = reqwest::Client::new();
let app_name = release.repo.split('/').last().unwrap_or(&release.repo);
let url = format!("{}/message?token={}", gotify_url, token);
let message = format!(
"📌 *New version*: {}\n\n📦*For*: {}\n\n📅 *Published on*: {}\n\n📝 *Changelog*:\n\n```{}```\n\n🔗 *Release Url*:{}",
release.tag_name,
app_name,
release.published_at.replace("T", " ").replace("Z", ""),
release.changelog,
release.html_url
);
let content = json!({
"title": format!("New version for {}", app_name),
"message": message,
"priority": "2"
});
match client.post(&url)
.json(&content)
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Message sent to Gotify for {}", app_name);
},
Ok(response) => {
error!("Failed to send message to Gotify. Status code: {}", response.status());
},
Err(e) => {
error!("Error sending to Gotify: {}", e);
}
}
}
pub async fn send_docker_notification(release: &DockerReleaseInfo, token: &str, gotify_url: &str) {
let client = reqwest::Client::new();
let app_name = release.repo.split('/').last().unwrap_or(&release.repo);
let url = format!("{}/message?token={}", gotify_url, token);
let message = format!(
"🐳 *Docker Image Updated!*\n\n🔐 *New Digest*: `{}`\n\n📦 *App*: {}\n\n📢 *Published*: {}\n\n🔗 *Release Url*:{}",
release.digest,
app_name,
release.published_at.replace("T", " ").replace("Z", ""),
release.html_url
);
let content = json!({
"title": format!("New version for {}", app_name),
"message": message,
"priority": "2"
});
match client.post(&url)
.json(&content)
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Message sent to Gotify for {}", app_name);
},
Ok(response) => {
error!("Failed to send message to Gotify. Status code: {}", response.status());
},
Err(e) => {
error!("Error sending to Gotify: {}", e);
}
}
}

109
src/notifications/mod.rs Normal file
View File

@@ -0,0 +1,109 @@
pub mod ntfy;
pub mod gotify;
pub mod discord;
pub mod slack;
pub mod github;
pub mod docker;
use tokio::task;
use crate::models::{GithubReleaseInfo, DockerReleaseInfo};
use crate::config::Config;
use crate::database::{Connection, is_new_version, update_version};
use rusqlite::Result as SqliteResult;
pub async fn send_notifications(
github_releases: Vec<GithubReleaseInfo>,
docker_releases: Vec<DockerReleaseInfo>,
config: &Config,
db_conn: &Connection,
) -> SqliteResult<()> {
let mut tasks = Vec::new();
// Create tasks for GitHub notifications
for release in &github_releases {
if is_new_version(db_conn, &release.repo, &release.tag_name)? {
if let Some(url) = &config.ntfy_url {
let release = release.clone();
let auth = config.auth.clone();
let url = url.clone();
tasks.push(task::spawn(async move {
github::send_to_ntfy(release, &auth, &url).await;
}));
}
if let (Some(gotify_url), Some(gotify_token)) = (&config.gotify_url, &config.gotify_token) {
let release = release.clone();
let url = gotify_url.clone();
let token = gotify_token.clone();
tasks.push(task::spawn(async move {
github::send_to_gotify(release, &token, &url).await;
}));
}
if let Some(discord_url) = &config.discord_webhook_url {
let release = release.clone();
let url = discord_url.clone();
tasks.push(task::spawn(async move {
github::send_to_discord(release, &url).await;
}));
}
if let Some(slack_url) = &config.slack_webhook_url {
let release = release.clone();
let url = slack_url.clone();
tasks.push(task::spawn(async move {
github::send_to_slack(release, &url).await;
}));
}
update_version(db_conn, &release.repo, &release.tag_name, Some(release.changelog.as_str()))?;
}
}
for release in &docker_releases {
if is_new_version(db_conn, &release.repo, &release.digest)? {
if let Some(url) = &config.ntfy_url {
let release = release.clone();
let auth = config.auth.clone();
let url = url.clone();
tasks.push(task::spawn(async move {
docker::send_to_ntfy(release, &auth, &url).await;
}));
}
if let (Some(gotify_url), Some(gotify_token)) = (&config.gotify_url, &config.gotify_token) {
let release = release.clone();
let url = gotify_url.clone();
let token = gotify_token.clone();
tasks.push(task::spawn(async move {
docker::send_to_gotify(release, &token, &url).await;
}));
}
if let Some(discord_url) = &config.discord_webhook_url {
let release = release.clone();
let url = discord_url.clone();
tasks.push(task::spawn(async move {
docker::send_to_discord(release, &url).await;
}));
}
if let Some(slack_url) = &config.slack_webhook_url {
let release = release.clone();
let url = slack_url.clone();
tasks.push(task::spawn(async move {
docker::send_to_slack(release, &url).await;
}));
}
update_version(db_conn, &release.repo, &release.digest, None)?;
}
}
// Wait for all tasks to complete
for task in tasks {
let _ = task.await;
}
Ok(())
}

84
src/notifications/ntfy.rs Normal file
View File

@@ -0,0 +1,84 @@
use log::{error, info};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use crate::models::{GithubReleaseInfo, DockerReleaseInfo};
pub async fn send_github_notification(release: &GithubReleaseInfo, auth: &str, ntfy_url: &str) {
let client = reqwest::Client::new();
let app_name = release.repo.split('/').last().unwrap_or(&release.repo);
let mut headers = HeaderMap::new();
headers.insert("Authorization", HeaderValue::from_str(&format!("Basic {}", auth))
.unwrap_or_else(|_| HeaderValue::from_static("")));
headers.insert("Title", HeaderValue::from_str(&format!("New version for {}", app_name))
.unwrap_or_else(|_| HeaderValue::from_static("")));
headers.insert("Priority", HeaderValue::from_static("urgent"));
headers.insert("Markdown", HeaderValue::from_static("yes"));
headers.insert("Actions", HeaderValue::from_str(&format!("view, Update {}, {}, clear=true", app_name, release.html_url))
.unwrap_or_else(|_| HeaderValue::from_static("")));
let message = format!(
"📌 *New version*: {}\n\n📦*For*: {}\n\n📅 *Published on*: {}\n\n📝 *Changelog*:\n\n```{}```\n\n 🔗 *Release Url*: {}",
release.tag_name,
app_name,
release.published_at.replace("T", " ").replace("Z", ""),
release.changelog,
release.html_url
);
match client.post(ntfy_url)
.headers(headers)
.body(message)
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Message sent to Ntfy for {}", app_name);
},
Ok(response) => {
error!("Failed to send message to Ntfy. Status code: {}", response.status());
},
Err(e) => {
error!("Error sending to Ntfy: {}", e);
}
}
}
pub async fn send_docker_notification(release: &DockerReleaseInfo, auth: &str, ntfy_url: &str) {
let client = reqwest::Client::new();
let app_name = release.repo.split('/').last().unwrap_or(&release.repo);
let mut headers = HeaderMap::new();
headers.insert("Authorization", HeaderValue::from_str(&format!("Basic {}", auth))
.unwrap_or_else(|_| HeaderValue::from_static("")));
headers.insert("Title", HeaderValue::from_str(&format!("🆕 New version for {}", app_name))
.unwrap_or_else(|_| HeaderValue::from_static("")));
headers.insert("Priority", HeaderValue::from_static("urgent"));
headers.insert("Markdown", HeaderValue::from_static("yes"));
headers.insert("Actions", HeaderValue::from_str(&format!("View, Update {}, {}, clear=true", app_name, release.html_url))
.unwrap_or_else(|_| HeaderValue::from_static("")));
let message = format!(
"🐳 *Docker Image Updated!*\n\n🔐 *New Digest*: `{}`\n\n📦 *App*: {}\n\n📢 *Published*: {}\n\n 🔗 *Release Url*: {}",
release.digest,
app_name,
release.published_at.replace("T", " ").replace("Z", ""),
release.html_url
);
match client.post(ntfy_url)
.headers(headers)
.body(message)
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Message sent to Ntfy for {}", app_name);
},
Ok(response) => {
error!("Failed to send message to Ntfy. Status code: {}", response.status());
},
Err(e) => {
error!("Error sending to Ntfy: {}", e);
}
}
}

131
src/notifications/slack.rs Normal file
View File

@@ -0,0 +1,131 @@
use log::{error, info};
use serde_json::json;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use std::iter::FromIterator;
use crate::models::{GithubReleaseInfo, DockerReleaseInfo};
pub async fn send_github_notification(release: &GithubReleaseInfo, webhook_url: &str) {
let client = reqwest::Client::new();
let app_name = release.repo.split('/').last().unwrap_or(&release.repo);
let mut message = format!(
"📌 *New version*: {}\n\n📦*For*: {}\n\n📅 *Published on*: {}\n\n📝 *Changelog*:\n\n```{}```",
release.tag_name,
app_name,
release.published_at.replace("T", " ").replace("Z", ""),
release.changelog
);
if message.len() > 2000 {
message = format!(
"📌 *New version*: {}\n\n📦*For*: {}\n\n📅 *Published on*: {}\n\n📝 *Changelog*:\n\n `truncated..` use 🔗 instead",
release.tag_name,
app_name,
release.published_at.replace("T", " ").replace("Z", "")
);
}
let data = json!({
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": message
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": "View Release"
},
"url": release.html_url,
"action_id": "button-action"
}
},
{
"type": "divider"
}
]
});
let headers = HeaderMap::from_iter([(
CONTENT_TYPE,
HeaderValue::from_static("application/json")
)]);
match client.post(webhook_url)
.headers(headers)
.json(&data)
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Message sent to Slack for {}", app_name);
},
Ok(response) => {
error!("Failed to send message to Slack. Status code: {}", response.status());
},
Err(e) => {
error!("Error sending to Slack: {}", e);
}
}
}
pub async fn send_docker_notification(release: &DockerReleaseInfo, webhook_url: &str) {
let client = reqwest::Client::new();
let app_name = release.repo.split('/').last().unwrap_or(&release.repo);
let message = format!(
"🐳 *Docker Image Updated!*\n\n🔐 *New Digest*: `{}`\n\n📦 *App*: {}\n\n📢*Published*: {}",
release.digest,
app_name,
release.published_at.replace("T", " ").replace("Z", "")
);
let data = json!({
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": message
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": "View Image"
},
"url": release.html_url,
"action_id": "button-action"
}
},
{
"type": "divider"
}
]
});
let headers = HeaderMap::from_iter([(
CONTENT_TYPE,
HeaderValue::from_static("application/json")
)]);
match client.post(webhook_url)
.headers(headers)
.json(&data)
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Message sent to Slack for {}", app_name);
},
Ok(response) => {
error!("Failed to send message to Slack. Status code: {}", response.status());
},
Err(e) => {
error!("Error sending to Slack: {}", e);
}
}
}

69
web/index.html Normal file
View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Github-Ntfy Add a Repo</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="script.js" defer></script>
</head>
<body class="bg-[#1b2124] text-gray-200">
<header class="text-center py-8 bg-[#23453d] shadow-lg">
<h1 class="text-5xl font-bold tracking-wide text-white">Github-Ntfy</h1>
</header>
<main class="flex flex-wrap justify-center gap-8 py-12">
<!-- Github Repo Section -->
<section class="bg-[#23453d] rounded-lg shadow-lg p-6 w-full max-w-lg">
<h2 class="text-2xl font-semibold mb-4">Add a Github Repo</h2>
<form id="addRepoForm" class="space-y-6">
<div>
<label for="repo" class="block text-sm font-medium">Name of the Github Repo</label>
<div class="mt-2 flex items-center border rounded-md bg-gray-700">
<span class="px-3 text-gray-400">github.com/</span>
<input type="text" name="repo" id="repo" autocomplete="repo" class="flex-1 py-2 px-3 bg-transparent focus:outline-none" placeholder="BreizhHardware/ntfy_alerts">
</div>
</div>
<div class="flex justify-end gap-4">
<button type="button" class="px-4 py-2 text-gray-400 hover:text-white">Cancel</button>
<button type="submit" class="px-4 py-2 bg-green-700 hover:bg-green-600 text-white font-semibold rounded-md">Save</button>
</div>
</form>
<div class="mt-8">
<h3 class="text-lg font-semibold mb-2">Watched Github Repositories</h3>
<ul id="watchedReposList" class="space-y-2">
<!-- Dynamically populated with JavaScript -->
</ul>
</div>
</section>
<!-- Docker Repo Section -->
<section class="bg-[#23453d] rounded-lg shadow-lg p-6 w-full max-w-lg">
<h2 class="text-2xl font-semibold mb-4">Add a Docker Repo</h2>
<form id="addDockerRepoForm" class="space-y-6">
<div>
<label for="dockerRepo" class="block text-sm font-medium">Name of the Docker Repo</label>
<div class="mt-2 flex items-center border rounded-md bg-gray-700">
<span class="px-3 text-gray-400">hub.docker.com/r/</span>
<input type="text" name="dockerRepo" id="dockerRepo" autocomplete="dockerRepo" class="flex-1 py-2 px-3 bg-transparent focus:outline-none" placeholder="breizhhardware/github-ntfy">
</div>
</div>
<div class="flex justify-end gap-4">
<button type="button" class="px-4 py-2 text-gray-400 hover:text-white">Cancel</button>
<button type="submit" class="px-4 py-2 bg-green-700 hover:bg-green-600 text-white font-semibold rounded-md">Save</button>
</div>
</form>
<div class="mt-8">
<h3 class="text-lg font-semibold mb-2">Watched Docker Repositories</h3>
<ul id="watchedDockerReposList" class="space-y-2">
<!-- Dynamically populated with JavaScript -->
</ul>
</div>
</section>
</main>
<footer class="text-center py-6 bg-[#23453d]">
<p class="text-sm">I know this web interface is simple, but I'm improving!</p>
</footer>
</body>
</html>

158
web/script.js Normal file
View File

@@ -0,0 +1,158 @@
document.getElementById('addRepoForm').addEventListener('submit', function(event) {
event.preventDefault();
let repoName = document.getElementById('repo').value;
fetch('/app_repo', {
method: 'POST',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
},
body: JSON.stringify({repo: repoName})
})
.then(response => {
if (response.ok) {
// Si la requête s'est bien déroulée, actualiser la liste des dépôts surveillés
refreshWatchedRepos();
} else {
throw new Error('Erreur lors de l\'ajout du dépôt');
}
})
.catch(error => {
console.error('Error:', error);
});
});
document.getElementById('addDockerRepoForm').addEventListener('submit', function(event) {
event.preventDefault();
let repoName = document.getElementById('dockerRepo').value;
fetch('/app_docker_repo', {
method: 'POST',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
},
body: JSON.stringify({repo: repoName})
})
.then(response => {
if (response.ok) {
// Si la requête s'est bien déroulée, actualiser la liste des dépôts surveillés
refreshWatchedRepos();
} else {
throw new Error('Erreur lors de l\'ajout du dépôt');
}
})
.catch(error => {
console.error('Error:', error);
});
});
function refreshWatchedRepos() {
fetch('/watched_repos')
.then(response => response.json())
.then(data => {
const watchedReposList = document.getElementById('watchedReposList');
// Vider la liste actuelle
watchedReposList.innerHTML = '';
// Ajouter chaque dépôt surveillé à la liste
data.forEach(repo => {
const listItem = document.createElement('li');
const repoName = document.createElement('span');
repoName.textContent = repo;
repoName.className = 'repo-name';
listItem.appendChild(repoName);
const deleteButton = document.createElement('button');
deleteButton.textContent = ' X';
deleteButton.className = 'delete-btn text-red-500 ml-2';
deleteButton.addEventListener('click', () => {
// Remove the repo from the watched repos
// This is a placeholder. Replace it with your actual code to remove the repo from the watched repos.
removeRepoFromWatchedRepos(repo);
// Remove the repo from the DOM
listItem.remove();
});
listItem.appendChild(deleteButton);
watchedReposList.appendChild(listItem);
});
})
.catch(error => {
console.error('Error:', error);
});
fetch('/watched_docker_repos')
.then(response => response.json())
.then(data => {
const watchedDockerReposList = document.getElementById('watchedDockerReposList');
// Vider la liste actuelle
watchedDockerReposList.innerHTML = '';
// Ajouter chaque dépôt surveillé à la liste
data.forEach(repo => {
const listItem = document.createElement('li');
const repoName = document.createElement('span');
repoName.textContent = repo;
repoName.className = 'repo-name';
listItem.appendChild(repoName);
const deleteButton = document.createElement('button');
deleteButton.textContent = ' X';
deleteButton.className = 'delete-btn text-red-500 ml-2';
deleteButton.addEventListener('click', () => {
// Remove the repo from the watched repos
// This is a placeholder. Replace it with your actual code to remove the repo from the watched repos.
removeDockerRepoFromWatchedRepos(repo);
// Remove the repo from the DOM
listItem.remove();
});
listItem.appendChild(deleteButton);
watchedDockerReposList.appendChild(listItem);
});
})
.catch(error => {
console.error('Error:', error);
});
}
function removeRepoFromWatchedRepos(repo) {
fetch('/delete_repo', {
method: 'POST',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
},
body: JSON.stringify({repo: repo})
})
.then(response => {
if (!response.ok) {
throw new Error('Erreur lors de la suppression du dépôt');
}
})
.catch(error => {
console.error('Error:', error);
});
}
function removeDockerRepoFromWatchedRepos(repo) {
fetch('/delete_docker_repo', {
method: 'POST',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
},
body: JSON.stringify({repo: repo})
})
.then(response => {
if (!response.ok) {
throw new Error('Erreur lors de la suppression du dépôt');
}
})
.catch(error => {
console.error('Error:', error);
});
}
// Appeler la fonction pour charger les dépôts surveillés au chargement de la page
refreshWatchedRepos();