feat(notifications): Add test notification routes for NTFY, Discord, Slack, and Gotify

This commit is contained in:
Félix MARQUET
2025-08-21 18:27:07 +02:00
parent 1ffa17d82e
commit 66400f2946
9 changed files with 653 additions and 66 deletions

View File

@@ -12,7 +12,8 @@ use crate::database::{
get_user_by_username, verify_password, create_user, create_session,
get_session, delete_session, get_app_settings, update_app_settings
};
use crate::models::{UserLogin, UserRegistration, AuthResponse, ApiResponse, AppSettings};
use crate::models::{UserLogin, UserRegistration, AuthResponse, ApiResponse, AppSettings, GithubReleaseInfo};
use crate::notifications::{ntfy, discord, slack, gotify};
#[derive(Debug, Serialize, Deserialize)]
struct RepoRequest {
@@ -128,6 +129,35 @@ pub async fn start_api() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
.and(with_db(versions_db.clone()))
.and_then(is_configured);
// Test notification routes
let test_ntfy_route = warp::path("test")
.and(warp::path("ntfy"))
.and(warp::post())
.and(with_db(versions_db.clone()))
.and(with_auth())
.and_then(test_ntfy_notification);
let test_discord_route = warp::path("test")
.and(warp::path("discord"))
.and(warp::post())
.and(with_db(versions_db.clone()))
.and(with_auth())
.and_then(test_discord_notification);
let test_slack_route = warp::path("test")
.and(warp::path("slack"))
.and(warp::post())
.and(with_db(versions_db.clone()))
.and(with_auth())
.and_then(test_slack_notification);
let test_gotify_route = warp::path("test")
.and(warp::path("gotify"))
.and(warp::post())
.and(with_db(versions_db.clone()))
.and(with_auth())
.and_then(test_gotify_notification);
// Configure CORS
let cors = warp::cors()
.allow_any_origin()
@@ -148,6 +178,10 @@ pub async fn start_api() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
.or(get_settings_route)
.or(update_settings_route)
.or(is_configured_route)
.or(test_ntfy_route)
.or(test_discord_route)
.or(test_slack_route)
.or(test_gotify_route)
.with(cors);
// Start the server
@@ -872,3 +906,357 @@ async fn is_configured(db: Arc<Mutex<Connection>>) -> Result<impl Reply, Rejecti
StatusCode::OK,
))
}
async fn test_ntfy_notification(db: Arc<Mutex<Connection>>, token: String) -> Result<impl Reply, Rejection> {
let conn = db.lock().await;
// Verify authentication
if let Ok(Some(session)) = get_session(&conn, &token) {
if session.expires_at < Utc::now() {
return Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Session expired".to_string(),
data: None,
}),
StatusCode::UNAUTHORIZED,
));
}
// Retrieve settings
match get_app_settings(&conn) {
Ok(Some(settings)) => {
if let Some(ntfy_url) = &settings.ntfy_url {
// Send a test notification
let result = ntfy::send_notification(
ntfy_url,
"Test Notification",
"Ceci est une notification de test depuis l'API GitHub-NTFY.",
).await;
match result {
Ok(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: true,
message: "Test notification sent successfully".to_string(),
data: None,
}),
StatusCode::OK,
))
},
Err(e) => {
error!("Error sending notification: {}", e);
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: format!("Error sending notification: {}", e),
data: None,
}),
StatusCode::INTERNAL_SERVER_ERROR,
))
}
}
} else {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "NTFY URL not configured".to_string(),
data: None,
}),
StatusCode::BAD_REQUEST,
))
}
},
Ok(None) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "No settings found".to_string(),
data: None,
}),
StatusCode::NOT_FOUND,
))
},
Err(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Error retrieving settings".to_string(),
data: None,
}),
StatusCode::INTERNAL_SERVER_ERROR,
))
}
}
} else {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Unauthorized".to_string(),
data: None,
}),
StatusCode::UNAUTHORIZED,
))
}
}
async fn test_discord_notification(db: Arc<Mutex<Connection>>, token: String) -> Result<impl Reply, Rejection> {
let conn = db.lock().await;
// Verify authentication
if let Ok(Some(session)) = get_session(&conn, &token) {
if session.expires_at < Utc::now() {
return Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Session expired".to_string(),
data: None,
}),
StatusCode::UNAUTHORIZED,
));
}
// Retrieve settings
match get_app_settings(&conn) {
Ok(Some(settings)) => {
if let Some(webhook_url) = &settings.discord_webhook_url {
// Send a test notification
let result = discord::send_notification(
webhook_url,
"Test Notification",
"Ceci est une notification de test depuis l'API GitHub-NTFY.",
).await;
match result {
Ok(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: true,
message: "Test notification sent successfully".to_string(),
data: None,
}),
StatusCode::OK,
))
},
Err(e) => {
error!("Error sending notification: {}", e);
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: format!("Error sending notification: {}", e),
data: None,
}),
StatusCode::INTERNAL_SERVER_ERROR,
))
}
}
} else {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Discord webhook URL not configured".to_string(),
data: None,
}),
StatusCode::BAD_REQUEST,
))
}
},
Ok(None) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "No settings found".to_string(),
data: None,
}),
StatusCode::NOT_FOUND,
))
},
Err(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Error retrieving settings".to_string(),
data: None,
}),
StatusCode::INTERNAL_SERVER_ERROR,
))
}
}
} else {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Unauthorized".to_string(),
data: None,
}),
StatusCode::UNAUTHORIZED,
))
}
}
async fn test_slack_notification(db: Arc<Mutex<Connection>>, token: String) -> Result<impl Reply, Rejection> {
let conn = db.lock().await;
// Verify authentication
if let Ok(Some(session)) = get_session(&conn, &token) {
if session.expires_at < Utc::now() {
return Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Session expired".to_string(),
data: None,
}),
StatusCode::UNAUTHORIZED,
));
}
// Retrieve settings
match get_app_settings(&conn) {
Ok(Some(settings)) => {
// Send a test notification
let result = slack::send_notification(
&settings.slack_webhook_url,
"Test Notification",
"Ceci est une notification de test depuis l'API GitHub-NTFY.",
).await;
match result {
Ok(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: true,
message: "Test notification sent successfully".to_string(),
data: None,
}),
StatusCode::OK,
))
},
Err(e) => {
error!("Error sending notification: {}", e);
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: format!("Error sending notification: {}", e),
data: None,
}),
StatusCode::INTERNAL_SERVER_ERROR,
))
}
}
},
Ok(None) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "No settings found".to_string(),
data: None,
}),
StatusCode::NOT_FOUND,
))
},
Err(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Error retrieving settings".to_string(),
data: None,
}),
StatusCode::INTERNAL_SERVER_ERROR,
))
}
}
} else {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Unauthorized".to_string(),
data: None,
}),
StatusCode::UNAUTHORIZED,
))
}
}
async fn test_gotify_notification(db: Arc<Mutex<Connection>>, token: String) -> Result<impl Reply, Rejection> {
let conn = db.lock().await;
// Verify authentication
if let Ok(Some(session)) = get_session(&conn, &token) {
if session.expires_at < Utc::now() {
return Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Session expired".to_string(),
data: None,
}),
StatusCode::UNAUTHORIZED,
));
}
// Retrieve settings
match get_app_settings(&conn) {
Ok(Some(settings)) => {
// Send a test notification
let result = gotify::send_notification(
&settings.gotify_url,
"Test Notification",
"Ceci est une notification de test depuis l'API GitHub-NTFY.",
).await;
match result {
Ok(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: true,
message: "Test notification sent successfully".to_string(),
data: None,
}),
StatusCode::OK,
))
},
Err(e) => {
error!("Error sending notification: {}", e);
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: format!("Error sending notification: {}", e),
data: None,
}),
StatusCode::INTERNAL_SERVER_ERROR,
))
}
}
},
Ok(None) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "No settings found".to_string(),
data: None,
}),
StatusCode::NOT_FOUND,
))
},
Err(_) => {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Error retrieving settings".to_string(),
data: None,
}),
StatusCode::INTERNAL_SERVER_ERROR,
))
}
}
} else {
Ok(warp::reply::with_status(
warp::reply::json(&ApiResponse::<()> {
success: false,
message: "Unauthorized".to_string(),
data: None,
}),
StatusCode::UNAUTHORIZED,
))
}
}

View File

@@ -82,4 +82,28 @@ pub async fn send_docker_notification(release: &DockerReleaseInfo, webhook_url:
error!("Error sending to Discord: {}", e);
}
}
}
}
pub async fn send_notification(webhook_url: &str, title: &str, message: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let data = json!({
"content": format!("**{}**\n{}", title, message),
"username": "GitHub Ntfy Test"
});
let response = client.post(webhook_url)
.header("Content-Type", "application/json")
.json(&data)
.send()
.await?;
if response.status().is_success() {
info!("Test notification sent to Discord successfully");
Ok(())
} else {
let error_msg = format!("Failed to send test notification to Discord. Status code: {}", response.status());
error!("{}", error_msg);
Err(error_msg.into())
}
}

View File

@@ -75,4 +75,32 @@ pub async fn send_docker_notification(release: &DockerReleaseInfo, token: &str,
error!("Error sending to Gotify: {}", e);
}
}
}
}
pub async fn send_notification(gotify_url: &str, title: &str, message: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
// Note: For test notifications, we'll need to get the token from settings
// This function will be called from the API where we have access to settings
let url = format!("{}/message", gotify_url);
let content = json!({
"title": title,
"message": message,
"priority": 5
});
let response = client.post(&url)
.json(&content)
.send()
.await?;
if response.status().is_success() {
info!("Test notification sent to Gotify successfully");
Ok(())
} else {
let error_msg = format!("Failed to send test notification to Gotify. Status code: {}", response.status());
error!("{}", error_msg);
Err(error_msg.into())
}
}

View File

@@ -81,4 +81,27 @@ pub async fn send_docker_notification(release: &DockerReleaseInfo, auth: &str, n
error!("Error sending to Ntfy: {}", e);
}
}
}
}
pub async fn send_notification(ntfy_url: &str, title: &str, message: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let mut headers = HeaderMap::new();
headers.insert("Title", HeaderValue::from_str(title)?);
headers.insert("Priority", HeaderValue::from_static("default"));
let response = client.post(ntfy_url)
.headers(headers)
.body(message.to_string())
.send()
.await?;
if response.status().is_success() {
info!("Test notification sent to NTFY successfully");
Ok(())
} else {
let error_msg = format!("Failed to send test notification to NTFY. Status code: {}", response.status());
error!("{}", error_msg);
Err(error_msg.into())
}
}

View File

@@ -128,4 +128,27 @@ pub async fn send_docker_notification(release: &DockerReleaseInfo, webhook_url:
error!("Error sending to Slack: {}", e);
}
}
}
}
pub async fn send_notification(webhook_url: &str, title: &str, message: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let data = json!({
"text": format!("*{}*\n{}", title, message)
});
let response = client.post(webhook_url)
.header("Content-Type", "application/json")
.json(&data)
.send()
.await?;
if response.status().is_success() {
info!("Test notification sent to Slack successfully");
Ok(())
} else {
let error_msg = format!("Failed to send test notification to Slack. Status code: {}", response.status());
error!("{}", error_msg);
Err(error_msg.into())
}
}

5
web/layouts/auth.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<div class="min-h-screen bg-gray-900 text-gray-200 flex items-center justify-center">
<slot />
</div>
</template>

View File

@@ -1,63 +1,64 @@
<template>
<div class="flex items-center justify-center min-h-screen bg-gray-900">
<div class="w-full max-w-md p-8 space-y-8 bg-gray-800 rounded-lg shadow-lg">
<div class="text-center">
<h1 class="text-2xl font-bold text-white">Login</h1>
<p class="mt-2 text-sm text-gray-400">Sign in to manage your notifications</p>
</div>
<form @submit.prevent="handleLogin" class="mt-8 space-y-6">
<div>
<label for="username" class="block text-sm font-medium text-gray-400">Username</label>
<input
id="username"
v-model="form.username"
type="text"
required
class="block w-full px-3 py-2 mt-1 text-white placeholder-gray-500 bg-gray-700 border border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="block w-full px-3 py-2 mt-1 text-white placeholder-gray-500 bg-gray-700 border border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div v-if="error" class="p-3 text-sm text-red-500 bg-red-100 rounded-md">
{{ error }}
</div>
<div>
<UButton
type="submit"
color="primary"
block
:loading="loading"
>
Login
</UButton>
</div>
</form>
<div class="text-center mt-4">
<p class="text-sm text-gray-400">
First time?
<NuxtLink to="/onboarding" class="font-medium text-indigo-400 hover:text-indigo-300">
Setup your application
</NuxtLink>
</p>
</div>
<div class="w-full max-w-md p-8 space-y-8 bg-gray-800 rounded-lg shadow-lg">
<div class="text-center">
<h1 class="text-2xl font-bold text-white">Login</h1>
<p class="mt-2 text-sm text-gray-400">Sign in to manage your notifications</p>
</div>
</div>
<form @submit.prevent="handleLogin" class="mt-8 space-y-6">
<div>
<input
id="username"
v-model="form.username"
type="text"
required
class="block w-full px-3 py-2 mt-1 text-white placeholder-gray-500 bg-gray-700 border border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<div>
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>
<div v-if="error" class="p-3 text-sm text-red-500 bg-red-100 rounded-md">
id="password"
v-model="form.password"
type="password"
required
class="block w-full px-3 py-2 mt-1 text-white placeholder-gray-500 bg-gray-700 border border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
{{ error }}
<div v-if="error" class="p-3 text-sm text-red-500 bg-red-100 rounded-md">
{{ error }}
</div>
<UButton
<div>
<UButton
type="submit"
color="primary"
block
:loading="loading"
>
Login
</UButton>
</div>
</form>
<p class="text-sm text-gray-400">
<div class="text-center mt-4">
<p class="text-sm text-gray-400">
First time?
<NuxtLink to="/onboarding" class="font-medium text-indigo-400 hover:text-indigo-300">
Setup your application
</NuxtLink>
</p>
</template>
<script setup>
// Utiliser le layout d'authentification
definePageMeta({
layout: 'auth'
})
const auth = useAuth();
const router = useRouter();

View File

@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-gray-900 p-6">
<div class="min-h-screen p-6">
<div class="max-w-3xl mx-auto bg-gray-800 rounded-lg shadow-lg overflow-hidden">
<div class="p-6 border-b border-gray-700">
<h1 class="text-2xl font-bold text-white">Application Setup</h1>
@@ -247,6 +247,11 @@
</template>
<script setup>
// Utiliser le layout d'authentification
definePageMeta({
layout: 'auth'
})
const auth = useAuth();
const router = useRouter();
const route = useRoute();

View File

@@ -1,6 +1,5 @@
<template>
<div>
<AppHeader />
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold text-white mb-8">Settings</h1>
@@ -15,7 +14,19 @@
<div class="space-y-6">
<!-- NTFY -->
<div>
<h3 class="text-lg font-medium mb-2">NTFY</h3>
<div class="flex justify-between items-center mb-2">
<h3 class="text-lg font-medium">NTFY</h3>
<UButton
@click="testNotification('ntfy')"
size="sm"
color="gray"
variant="outline"
:loading="testingNotifications.ntfy"
:disabled="!settings.ntfy_url"
>
Tester
</UButton>
</div>
<div class="space-y-2">
<UInput
v-model="settings.ntfy_url"
@@ -44,7 +55,19 @@
<!-- Discord -->
<div>
<h3 class="text-lg font-medium mb-2">Discord</h3>
<div class="flex justify-between items-center mb-2">
<h3 class="text-lg font-medium">Discord</h3>
<UButton
@click="testNotification('discord')"
size="sm"
color="gray"
variant="outline"
:loading="testingNotifications.discord"
:disabled="!settings.discord_webhook_url"
>
Tester
</UButton>
</div>
<UInput
v-model="settings.discord_webhook_url"
label="Discord Webhook URL"
@@ -55,7 +78,19 @@
<!-- Slack -->
<div>
<h3 class="text-lg font-medium mb-2">Slack</h3>
<div class="flex justify-between items-center mb-2">
<h3 class="text-lg font-medium">Slack</h3>
<UButton
@click="testNotification('slack')"
size="sm"
color="gray"
variant="outline"
:loading="testingNotifications.slack"
:disabled="!settings.slack_webhook_url"
>
Tester
</UButton>
</div>
<UInput
v-model="settings.slack_webhook_url"
label="Slack Webhook URL"
@@ -66,7 +101,19 @@
<!-- Gotify -->
<div>
<h3 class="text-lg font-medium mb-2">Gotify</h3>
<div class="flex justify-between items-center mb-2">
<h3 class="text-lg font-medium">Gotify</h3>
<UButton
@click="testNotification('gotify')"
size="sm"
color="gray"
variant="outline"
:loading="testingNotifications.gotify"
:disabled="!settings.gotify_url || !settings.gotify_token"
>
Tester
</UButton>
</div>
<div class="space-y-2">
<UInput
v-model="settings.gotify_url"
@@ -208,6 +255,12 @@ const settings = reactive({
const error = ref('');
const success = ref('');
const loading = ref(false);
const testingNotifications = reactive({
ntfy: false,
discord: false,
slack: false,
gotify: false
});
// Load current settings
async function loadSettings() {
@@ -291,4 +344,41 @@ async function saveSettings() {
loading.value = false;
}
}
// Function to test notifications
async function testNotification(type) {
try {
// Set loading state for the specific notification type
testingNotifications[type] = true;
error.value = '';
success.value = '';
// Send test notification via our API endpoint
const response = await fetch(`/test/${type}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': auth.token.value
}
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Error sending test notification');
}
const data = await response.json();
if (data.success) {
success.value = `Notification de test envoyée avec succès via ${type.toUpperCase()}`;
} else {
throw new Error(data.message || 'Error sending test notification');
}
} catch (err) {
error.value = err.message || 'Une erreur est survenue lors de l\'envoi de la notification de test';
} finally {
// Reset loading state for the specific notification type
testingNotifications[type] = false;
}
}
</script>