Skip to content

Commit f466bfc

Browse files
authored
Merge pull request #1 from kCn3333/feature/notifications
Add notification system with NTFY support
2 parents 1420f46 + 85236e4 commit f466bfc

17 files changed

+840
-5
lines changed

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,12 @@ TIMEZONE=UTC
3333
# MAIN_TRACK=101
3434
# SUB_TRACK=102
3535
# APP_FRONTEND_URL=http://localhost:8081
36+
# NTFY=
37+
# - NTFY with authentication and HTTPS
38+
# "ntfy://username:password@domain/hikvision-backups?title=Hikvision Backup&scheme=https"
39+
#
40+
# - NTFY without authentication (public server)
41+
# "ntfy://ntfy.sh/my-backup-topic?title=My Backups&scheme=https"
42+
#
43+
# - NTFY with custom port
44+
# "ntfy://username:password@domain:8080/backups?scheme=https"

docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ services:
4848
CAMERA_RTSP_PORT: ${CAMERA_RTSP_PORT:-554}
4949
TIMEZONE: ${TIMEZONE:-UTC}
5050

51+
# Notification
52+
NTFY: ${NTFY:-}
53+
5154
volumes:
5255
- stream_temp:/tmp/stream
5356
- recordings:/tmp/recordings
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.kcn.hikvisionmanager.config;
2+
3+
import com.kcn.hikvisionmanager.domain.ParsedNotificationUrl;
4+
import com.kcn.hikvisionmanager.service.notification.NotificationService;
5+
import com.kcn.hikvisionmanager.service.notification.NtfyNotificationService;
6+
import com.kcn.hikvisionmanager.util.NotificationUrlParser;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.boot.web.client.RestTemplateBuilder;
10+
import org.springframework.context.annotation.Bean;
11+
import org.springframework.context.annotation.Configuration;
12+
import org.springframework.web.client.RestTemplate;
13+
14+
import java.time.Duration;
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
18+
/**
19+
* Configuration for notification services.
20+
* Creates RestTemplate for notifications and initializes notification providers
21+
* based on configured URLs.
22+
*/
23+
@Configuration
24+
@RequiredArgsConstructor
25+
@Slf4j
26+
public class NotificationConfiguration {
27+
28+
private final NotificationProperties notificationProperties;
29+
30+
/**
31+
* Creates dedicated RestTemplate for notification delivery.
32+
* Configured with timeouts from notification properties.
33+
*
34+
* @param builder injected by Spring, pre-configured with sensible defaults
35+
* @return Configured RestTemplate instance
36+
*/
37+
@Bean
38+
public RestTemplate notificationRestTemplate(RestTemplateBuilder builder) {
39+
return builder
40+
.connectTimeout(Duration.ofSeconds(notificationProperties.getTimeoutSeconds())) // was setConnectTimeout
41+
.readTimeout(Duration.ofSeconds(notificationProperties.getTimeoutSeconds())) // was setReadTimeout
42+
.build();
43+
}
44+
45+
/**
46+
* Creates list of notification service implementations based on configured URLs.
47+
* Supports multiple notification providers simultaneously.
48+
* Invalid URLs are logged and skipped without stopping application startup.
49+
*
50+
* @param notificationRestTemplate RestTemplate for HTTP requests
51+
* @param urlParser URL parser for notification URLs
52+
* @return List of active notification services
53+
*/
54+
@Bean
55+
public List<NotificationService> notificationServices(
56+
RestTemplate notificationRestTemplate,
57+
NotificationUrlParser urlParser) {
58+
59+
List<NotificationService> services = new ArrayList<>();
60+
61+
// Filter out empty/blank URLs
62+
List<String> validUrls = notificationProperties.getUrls().stream()
63+
.filter(url -> url != null && !url.isBlank())
64+
.toList();
65+
66+
if (validUrls.isEmpty()) {
67+
log.info("ℹ️ No notification URLs configured - notifications disabled");
68+
return services;
69+
}
70+
71+
for (String url : validUrls) {
72+
try {
73+
ParsedNotificationUrl parsed = urlParser.parse(url);
74+
75+
NotificationService service = switch (parsed.getScheme()) {
76+
case "ntfy", "ntfys" -> new NtfyNotificationService(
77+
notificationRestTemplate, parsed);
78+
// Future providers:
79+
// case "webhook" -> new WebhookNotificationService(...)
80+
// case "discord" -> new DiscordNotificationService(...)
81+
default -> {
82+
log.warn("⚠️ Unknown notification scheme '{}' in URL: {}",
83+
parsed.getScheme(), maskUrl(url));
84+
yield null;
85+
}
86+
};
87+
88+
if (service != null) {
89+
services.add(service);
90+
log.info("✅ Registered {} notification service: {}",
91+
service.getType(), maskUrl(url));
92+
}
93+
94+
} catch (Exception e) {
95+
log.error("❌ Failed to parse notification URL: {} - {}",
96+
maskUrl(url), e.getMessage());
97+
// Continue with other URLs - don't fail application startup
98+
}
99+
}
100+
101+
if (services.isEmpty()) {
102+
log.warn("⚠️ No valid notification services initialized");
103+
} else {
104+
log.info("🔔 Initialized {} notification service(s)", services.size());
105+
}
106+
107+
return services;
108+
}
109+
110+
/**
111+
* Masks sensitive information (username/password) in URLs for logging.
112+
*
113+
* @param url Original URL
114+
* @return Masked URL with credentials hidden
115+
*/
116+
private String maskUrl(String url) {
117+
if (url.contains("@")) {
118+
return url.replaceAll("://[^@]+@", "://***:***@");
119+
}
120+
return url;
121+
}
122+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.kcn.hikvisionmanager.config;
2+
3+
import lombok.Data;
4+
import org.springframework.boot.context.properties.ConfigurationProperties;
5+
import org.springframework.stereotype.Component;
6+
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
10+
/**
11+
* Configuration properties for notification services.
12+
*
13+
* Example URLs:
14+
* - ntfy://username:password@domain/topic?title=MyApp&priority=default&scheme=https
15+
* - ntfy://domain/topic?scheme=https (without auth)
16+
* - webhook://domain/endpoint?scheme=https
17+
*/
18+
@Data
19+
@Component
20+
@ConfigurationProperties(prefix = "notification")
21+
public class NotificationProperties {
22+
23+
/**
24+
* List of notification URLs.
25+
* Format: scheme://[user:pass@]host[:port]/path[?params]
26+
*
27+
* Examples:
28+
* - "ntfy://user:pass@domain/hikvision-backups?title=Hikvision Backup&scheme=https"
29+
* - "ntfy://domain/my-topic?scheme=https"
30+
* - "webhook://domain/notify?scheme=https"
31+
*/
32+
private List<String> urls = new ArrayList<>();
33+
34+
/**
35+
* HTTP request timeout in seconds for notification delivery
36+
*/
37+
private int timeoutSeconds = 10;
38+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.kcn.hikvisionmanager.domain;
2+
3+
import lombok.Builder;
4+
import lombok.Data;
5+
6+
import java.util.List;
7+
import java.util.Map;
8+
9+
/**
10+
* Universal notification request DTO.
11+
* Can be used for NTFY, webhooks, and other notification providers.
12+
*
13+
* This model is designed to be provider-agnostic - each provider
14+
* can map these fields to its specific API format.
15+
*/
16+
@Data
17+
@Builder
18+
public class NotificationRequest {
19+
20+
/**
21+
* Notification topic/channel (used by NTFY)
22+
*/
23+
private String topic;
24+
25+
/**
26+
* Notification title/subject
27+
*/
28+
private String title;
29+
30+
/**
31+
* Notification message body (supports markdown for NTFY)
32+
*/
33+
private String message;
34+
35+
// /**
36+
// * Priority level: default, high, urgent
37+
// */
38+
// private String priority;
39+
40+
/**
41+
* Tags for categorization and emoji icons
42+
* Example: ["backup", "white_check_mark", "camera"]
43+
*/
44+
private List<String> tags;
45+
46+
/**
47+
* Optional URL to open when notification is clicked
48+
*/
49+
private String click;
50+
51+
/**
52+
* Additional provider-specific fields
53+
* Can be used by custom webhook implementations
54+
*/
55+
private Map<String, Object> extras;
56+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.kcn.hikvisionmanager.domain;
2+
3+
import lombok.Builder;
4+
import lombok.Data;
5+
6+
import java.util.Map;
7+
8+
/**
9+
* Parsed notification URL structure.
10+
* Contains all components extracted from notification URL string.
11+
*
12+
* Example URL: ntfy://user:pass@domain:8080/topic?title=App&scheme=https
13+
*/
14+
@Data
15+
@Builder
16+
public class ParsedNotificationUrl {
17+
18+
/**
19+
* URL scheme (ntfy, ntfys, webhook, discord, etc.)
20+
*/
21+
private String scheme;
22+
23+
/**
24+
* Optional username for authentication
25+
*/
26+
private String username;
27+
28+
/**
29+
* Optional password for authentication
30+
*/
31+
private String password;
32+
33+
/**
34+
* Host/domain name
35+
*/
36+
private String host;
37+
38+
/**
39+
* Port number (default: 80 for HTTP, 443 for HTTPS)
40+
*/
41+
private int port;
42+
43+
/**
44+
* URL path (e.g., /topic, /webhook/endpoint)
45+
*/
46+
private String path;
47+
48+
/**
49+
* Topic extracted from path (for NTFY)
50+
* Path "/my-topic" becomes topic "my-topic"
51+
*/
52+
private String topic;
53+
54+
/**
55+
* Query parameters as key-value map
56+
* Common params: title, priority, tags, scheme
57+
*/
58+
private Map<String, String> queryParams;
59+
60+
/**
61+
* Whether to use HTTPS (from ?scheme=https param)
62+
* Defaults to HTTP if not specified
63+
*/
64+
private boolean useHttps;
65+
66+
/**
67+
* Checks if authentication is configured
68+
*/
69+
public boolean hasAuthentication() {
70+
return username != null && password != null;
71+
}
72+
73+
/**
74+
* Builds full server URL (protocol + host + port)
75+
* Example: https://domain:8080
76+
*/
77+
public String getServerUrl() {
78+
String protocol = useHttps ? "https" : "http";
79+
80+
// Default ports don't need to be included
81+
boolean isDefaultPort = (useHttps && port == 443) || (!useHttps && port == 80);
82+
83+
if (isDefaultPort) {
84+
return String.format("%s://%s", protocol, host);
85+
} else {
86+
return String.format("%s://%s:%d", protocol, host, port);
87+
}
88+
}
89+
90+
/**
91+
* Gets query parameter value or returns default
92+
*/
93+
public String getQueryParam(String key, String defaultValue) {
94+
return queryParams.getOrDefault(key, defaultValue);
95+
}
96+
}

src/main/java/com/kcn/hikvisionmanager/entity/BackupConfigurationEntity.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ public class BackupConfigurationEntity {
4444
@Column(name = "backup_path", nullable = false, length = 500)
4545
private String backupPath;
4646

47+
@Column(name = "notify_on_complete", nullable = true)
48+
private Boolean notifyOnComplete;
49+
4750
@Column(name = "created_at", nullable = false)
4851
private LocalDateTime createdAt;
4952

src/main/java/com/kcn/hikvisionmanager/mapper/BackupConfigMapper.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public BackupConfigurationEntity toEntity(BackupConfigDTO dto) {
3737
.backupPath(backupConfig.getBaseDir().toString())
3838
.cronExpression(generateCron(dto))
3939
.retentionDays(dto.getRetentionDays())
40+
.notifyOnComplete(dto.isNotifyOnComplete())
4041
.createdAt(LocalDateTime.now())
4142
.updatedAt(LocalDateTime.now())
4243
.timeRangeStrategy(dto.getTimeRangeStrategy() != null
@@ -62,7 +63,7 @@ public BackupConfigDTO toDTO(BackupConfigurationEntity entity) {
6263
.time(parseCron(entity.getCronExpression()).time)
6364
.dayOfWeek(parseCron(entity.getCronExpression()).dayOfWeek)
6465
.retentionDays(entity.getRetentionDays())
65-
.notifyOnComplete(false)
66+
.notifyOnComplete(entity.getNotifyOnComplete() !=null ? entity.getNotifyOnComplete() : false)
6667
.timeRangeStrategy(entity.getTimeRangeStrategy() != null
6768
? entity.getTimeRangeStrategy()
6869
: BackupTimeRangeStrategy.LAST_24_HOURS)

src/main/java/com/kcn/hikvisionmanager/service/backup/BackupService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ public BackupConfigDTO updateBackupConfig(String id, BackupConfigDTO dto) {
9090
existing.setCameraId(dto.getCameraId());
9191
existing.setRetentionDays(dto.getRetentionDays());
9292
existing.setEnabled(dto.isEnabled());
93+
existing.setNotifyOnComplete(dto.isNotifyOnComplete());
9394
String newCron = configMapper.generateCron(dto);
94-
// Only reset next run if cron changed or it was disabled/re-enabled
95+
// Only reset next run if cron changed or it was disabled/re-enabled
9596
if (!newCron.equals(existing.getCronExpression()) || (!existing.isEnabled() && dto.isEnabled())) {
9697
existing.setCronExpression(newCron);
9798
existing.setNextRunAt(null); // Force scheduler to recalculate immediately

0 commit comments

Comments
 (0)