Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,12 @@ TIMEZONE=UTC
# MAIN_TRACK=101
# SUB_TRACK=102
# APP_FRONTEND_URL=http://localhost:8081
# NTFY=
# - NTFY with authentication and HTTPS
# "ntfy://username:password@domain/hikvision-backups?title=Hikvision Backup&scheme=https"
#
# - NTFY without authentication (public server)
# "ntfy://ntfy.sh/my-backup-topic?title=My Backups&scheme=https"
#
# - NTFY with custom port
# "ntfy://username:password@domain:8080/backups?scheme=https"
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ services:
CAMERA_RTSP_PORT: ${CAMERA_RTSP_PORT:-554}
TIMEZONE: ${TIMEZONE:-UTC}

# Notification
NTFY: ${NTFY:-}

volumes:
- stream_temp:/tmp/stream
- recordings:/tmp/recordings
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.kcn.hikvisionmanager.config;

import com.kcn.hikvisionmanager.domain.ParsedNotificationUrl;
import com.kcn.hikvisionmanager.service.notification.NotificationService;
import com.kcn.hikvisionmanager.service.notification.NtfyNotificationService;
import com.kcn.hikvisionmanager.util.NotificationUrlParser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

/**
* Configuration for notification services.
* Creates RestTemplate for notifications and initializes notification providers
* based on configured URLs.
*/
@Configuration
@RequiredArgsConstructor
@Slf4j
public class NotificationConfiguration {

private final NotificationProperties notificationProperties;

/**
* Creates dedicated RestTemplate for notification delivery.
* Configured with timeouts from notification properties.
*
* @param builder injected by Spring, pre-configured with sensible defaults
* @return Configured RestTemplate instance
*/
@Bean
public RestTemplate notificationRestTemplate(RestTemplateBuilder builder) {
return builder
.connectTimeout(Duration.ofSeconds(notificationProperties.getTimeoutSeconds())) // was setConnectTimeout
.readTimeout(Duration.ofSeconds(notificationProperties.getTimeoutSeconds())) // was setReadTimeout
.build();
}

/**
* Creates list of notification service implementations based on configured URLs.
* Supports multiple notification providers simultaneously.
* Invalid URLs are logged and skipped without stopping application startup.
*
* @param notificationRestTemplate RestTemplate for HTTP requests
* @param urlParser URL parser for notification URLs
* @return List of active notification services
*/
@Bean
public List<NotificationService> notificationServices(
RestTemplate notificationRestTemplate,
NotificationUrlParser urlParser) {

List<NotificationService> services = new ArrayList<>();

// Filter out empty/blank URLs
List<String> validUrls = notificationProperties.getUrls().stream()
.filter(url -> url != null && !url.isBlank())
.toList();

if (validUrls.isEmpty()) {
log.info("ℹ️ No notification URLs configured - notifications disabled");
return services;
}

for (String url : validUrls) {
try {
ParsedNotificationUrl parsed = urlParser.parse(url);

NotificationService service = switch (parsed.getScheme()) {
case "ntfy", "ntfys" -> new NtfyNotificationService(
notificationRestTemplate, parsed);
// Future providers:
// case "webhook" -> new WebhookNotificationService(...)
// case "discord" -> new DiscordNotificationService(...)
default -> {
log.warn("⚠️ Unknown notification scheme '{}' in URL: {}",
parsed.getScheme(), maskUrl(url));
yield null;
}
};

if (service != null) {
services.add(service);
log.info("✅ Registered {} notification service: {}",
service.getType(), maskUrl(url));
}

} catch (Exception e) {
log.error("❌ Failed to parse notification URL: {} - {}",
maskUrl(url), e.getMessage());
// Continue with other URLs - don't fail application startup
}
}

if (services.isEmpty()) {
log.warn("⚠️ No valid notification services initialized");
} else {
log.info("🔔 Initialized {} notification service(s)", services.size());
}

return services;
}

/**
* Masks sensitive information (username/password) in URLs for logging.
*
* @param url Original URL
* @return Masked URL with credentials hidden
*/
private String maskUrl(String url) {
if (url.contains("@")) {
return url.replaceAll("://[^@]+@", "://***:***@");
}
return url;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.kcn.hikvisionmanager.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

/**
* Configuration properties for notification services.
*
* Example URLs:
* - ntfy://username:password@domain/topic?title=MyApp&priority=default&scheme=https
* - ntfy://domain/topic?scheme=https (without auth)
* - webhook://domain/endpoint?scheme=https
*/
@Data
@Component
@ConfigurationProperties(prefix = "notification")
public class NotificationProperties {

/**
* List of notification URLs.
* Format: scheme://[user:pass@]host[:port]/path[?params]
*
* Examples:
* - "ntfy://user:pass@domain/hikvision-backups?title=Hikvision Backup&scheme=https"
* - "ntfy://domain/my-topic?scheme=https"
* - "webhook://domain/notify?scheme=https"
*/
private List<String> urls = new ArrayList<>();

/**
* HTTP request timeout in seconds for notification delivery
*/
private int timeoutSeconds = 10;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.kcn.hikvisionmanager.domain;

import lombok.Builder;
import lombok.Data;

import java.util.List;
import java.util.Map;

/**
* Universal notification request DTO.
* Can be used for NTFY, webhooks, and other notification providers.
*
* This model is designed to be provider-agnostic - each provider
* can map these fields to its specific API format.
*/
@Data
@Builder
public class NotificationRequest {

/**
* Notification topic/channel (used by NTFY)
*/
private String topic;

/**
* Notification title/subject
*/
private String title;

/**
* Notification message body (supports markdown for NTFY)
*/
private String message;

// /**
// * Priority level: default, high, urgent
// */
// private String priority;

/**
* Tags for categorization and emoji icons
* Example: ["backup", "white_check_mark", "camera"]
*/
private List<String> tags;

/**
* Optional URL to open when notification is clicked
*/
private String click;

/**
* Additional provider-specific fields
* Can be used by custom webhook implementations
*/
private Map<String, Object> extras;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.kcn.hikvisionmanager.domain;

import lombok.Builder;
import lombok.Data;

import java.util.Map;

/**
* Parsed notification URL structure.
* Contains all components extracted from notification URL string.
*
* Example URL: ntfy://user:pass@domain:8080/topic?title=App&scheme=https
*/
@Data
@Builder
public class ParsedNotificationUrl {

/**
* URL scheme (ntfy, ntfys, webhook, discord, etc.)
*/
private String scheme;

/**
* Optional username for authentication
*/
private String username;

/**
* Optional password for authentication
*/
private String password;

/**
* Host/domain name
*/
private String host;

/**
* Port number (default: 80 for HTTP, 443 for HTTPS)
*/
private int port;

/**
* URL path (e.g., /topic, /webhook/endpoint)
*/
private String path;

/**
* Topic extracted from path (for NTFY)
* Path "/my-topic" becomes topic "my-topic"
*/
private String topic;

/**
* Query parameters as key-value map
* Common params: title, priority, tags, scheme
*/
private Map<String, String> queryParams;

/**
* Whether to use HTTPS (from ?scheme=https param)
* Defaults to HTTP if not specified
*/
private boolean useHttps;

/**
* Checks if authentication is configured
*/
public boolean hasAuthentication() {
return username != null && password != null;
}

/**
* Builds full server URL (protocol + host + port)
* Example: https://domain:8080
*/
public String getServerUrl() {
String protocol = useHttps ? "https" : "http";

// Default ports don't need to be included
boolean isDefaultPort = (useHttps && port == 443) || (!useHttps && port == 80);

if (isDefaultPort) {
return String.format("%s://%s", protocol, host);
} else {
return String.format("%s://%s:%d", protocol, host, port);
}
}

/**
* Gets query parameter value or returns default
*/
public String getQueryParam(String key, String defaultValue) {
return queryParams.getOrDefault(key, defaultValue);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public class BackupConfigurationEntity {
@Column(name = "backup_path", nullable = false, length = 500)
private String backupPath;

@Column(name = "notify_on_complete", nullable = true)
private Boolean notifyOnComplete;

@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public BackupConfigurationEntity toEntity(BackupConfigDTO dto) {
.backupPath(backupConfig.getBaseDir().toString())
.cronExpression(generateCron(dto))
.retentionDays(dto.getRetentionDays())
.notifyOnComplete(dto.isNotifyOnComplete())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.timeRangeStrategy(dto.getTimeRangeStrategy() != null
Expand All @@ -62,7 +63,7 @@ public BackupConfigDTO toDTO(BackupConfigurationEntity entity) {
.time(parseCron(entity.getCronExpression()).time)
.dayOfWeek(parseCron(entity.getCronExpression()).dayOfWeek)
.retentionDays(entity.getRetentionDays())
.notifyOnComplete(false)
.notifyOnComplete(entity.getNotifyOnComplete() !=null ? entity.getNotifyOnComplete() : false)
.timeRangeStrategy(entity.getTimeRangeStrategy() != null
? entity.getTimeRangeStrategy()
: BackupTimeRangeStrategy.LAST_24_HOURS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ public BackupConfigDTO updateBackupConfig(String id, BackupConfigDTO dto) {
existing.setCameraId(dto.getCameraId());
existing.setRetentionDays(dto.getRetentionDays());
existing.setEnabled(dto.isEnabled());
existing.setNotifyOnComplete(dto.isNotifyOnComplete());
String newCron = configMapper.generateCron(dto);
// Only reset next run if cron changed or it was disabled/re-enabled
// Only reset next run if cron changed or it was disabled/re-enabled
if (!newCron.equals(existing.getCronExpression()) || (!existing.isEnabled() && dto.isEnabled())) {
existing.setCronExpression(newCron);
existing.setNextRunAt(null); // Force scheduler to recalculate immediately
Expand Down
Loading